AFSIM 架构解剖(上):内核与运行机理
面向 InfoQ 的技术连载 · 基于 AFSIM 2.9.0 源码一手资料 · 正文不含代码 全系列共 14 章,因篇幅庞大拆为上、中、下三篇;本篇为上篇,含系列导读与第 1--5 章。
三篇结构
| 篇 | 聚焦 | 章节 |
|---|---|---|
| 上篇 · 内核与运行机理(本篇) | 对象模型、离散事件内核、输入解析、脚本引擎 | 导读 + 第 1--5 章 |
| 中篇 · 平台与组件模型 | 平台组件、传感器与电磁、通信网络、跟踪与任务分配 | 第 6--9 章 |
| 下篇 · 专业领域与生态 | 空间/赛博、分布式互操作、扩展与插件、工具链、总结 | 第 10--14 章 |
本篇目录
- 系列导读
- 第 1 章 · AFSIM 总览与生态定位
- 第 2 章 · 四层对象模型与组件化架构
- 第 3 章 · 离散事件仿真内核
- 第 4 章 · 输入系统与语法解析
- 第 5 章 · AFSIM 脚本引擎
深入 AFSIM:一个军事建模仿真框架的架构解剖
面向 InfoQ 的技术连载 · 基于 AFSIM 2.9.0 源码一手资料 全系列共 14 章,每章独立可读,合起来构成对 AFSIM 的完整技术画像
为什么要读 AFSIM
在仿真软件的世界里,大多数工程师熟悉的是游戏引擎、物理引擎或者业务流程仿真器。但有一类系统,它的复杂度、实时性要求和工程规模都远超上述任何一类------面向军事对抗的作战仿真框架。
AFSIM(Advanced Framework for Simulation, Integration, and Modeling)就是这样一个系统。它由美国空军研究实验室(AFRL)气动系统局主导,最早由波音公司开发,如今已经演化为美军及其盟友在**任务级建模与仿真(Mission-Level M&S)**领域的事实标准工具链之一。它被广泛用于:
- 新型武器系统与传感器的效能评估;
- 作战概念(CONOPS)与战术开发的推演;
- 传感器、通信、电子战、武器的集成验证;
- 人在回路(operator-in-the-loop)训练与想定推演;
- 分布式联合仿真(DIS/HLA)中的一方兵力实体。
它不是一个"开箱即用"的成品软件,而是一个框架:用 C++ 写成的对象库,加一套文本化的领域特定语言(DSL)与脚本语言,再加一整套配套工具。使用者通过编写"想定文件"来搭建仿真世界,通过脚本注入行为逻辑,必要时用 C++ 扩展底层模型。
正因为它是框架而非应用,它的架构设计里有大量值得借鉴的工程决策:如何用组件化架构支撑几十种传感器与运动模型、如何让非程序员也能用脚本扩展仿真、如何做到既能"超实时"批处理又能"实时"人在回路、如何把一套代码同时跑成可执行程序、可视化工具和分布式节点。
本系列的目的,就是顺着源码把这套架构讲透,同时提炼出可迁移到自研系统的设计经验。
关于资料与引用约定
- 本系列所有技术事实均来自仓库源码与随附文档(
src/下的.hpp/.cpp/.rst/.ag等),不依赖外部二手资料。 - 为便于对照,正文在提到关键类或文件时会给出源码位置;但正文不贴代码,只做机制与设计层面的讲解。
- AFSIM 采用 C++ 编写,核心命名空间/类前缀为
Wsf(World Simulation Framework);工具类前缀为Ut(Utility)。看到Wsf前缀,基本可以断定它属于仿真核心;看到Ut,多半是通用工具库。
章节地图
整个系列按"总---分---总"组织,可分为五组:
第一组:认识全局(第 1--2 章)
- 第 1 章 · AFSIM 总览与生态定位:它是什么、解决什么问题、由哪些程序组成、术语体系。
- 第 2 章 · 四层对象模型与组件化架构:Application → Scenario → Simulation → Platform 的四层结构,以及支撑一切扩展的"组件化架构(CBA)"。
第二组:运行机理(第 3--5 章)
- 第 3 章 · 离散事件仿真内核:仿真时钟如何推进、事件如何调度、线程模型、随机数与可复现性、Observer 发布订阅。
- 第 4 章 · 输入系统与语法解析 :文本想定如何变成内存对象,
.ag语法文件与代码生成,类型工厂与类型列表。 - 第 5 章 · AFSIM 脚本引擎:用脚本定义行为、脚本上下文与类型系统、脚本与 C++ 模型互相调用。
第三组:模型体系(第 6--10 章)
- 第 6 章 · 平台组件之运动与处理器:运动学模型家族、处理器与行为树。
- 第 7 章 · 传感器与电磁建模:探测流程、雷达方程、传播/衰减/杂波、ESM/IRST/EOIR/SAR、电子战。
- 第 8 章 · 通信与网络建模:收发信机、天线、协议栈分层、网络与路由、数据链。
- 第 9 章 · 跟踪、任务分配与信息处理:航迹管理、相关/融合/滤波、任务分配状态机。
- 第 10 章 · 空间与赛博专业领域:轨道传播与机动、赛博攻击与效应、多目标跟踪等。
第四组:互联与扩展(第 11--12 章)
- 第 11 章 · 分布式仿真与互操作:DIS、HLA、XIO、事件管道与事件回放。
- 第 12 章 · 扩展机制与插件开发:三种集成方式(Direct / Project / Plug-In)、ADK、CMake、测试与文档规范。
第五组:工具与总结(第 13--14 章)
- 第 13 章 · 工具链生态:Mission、Warlock、Wizard、Mystic、Engage、后处理工具等。
- 第 14 章 · 工程实践总结与启示:设计模式复盘、性能与可扩展性、对自研仿真引擎的启发。
阅读建议
- 架构师:第 1--3 章 + 第 12 章 + 第 14 章,快速掌握框架级设计思想。
- 模型开发者:第 4--10 章,理解各类组件模型的抽象与实现套路。
- 集成/工具开发者:第 5、11、12、13 章,聚焦脚本、接口与工具链。
- 只想快速了解:读第 1、2 章与第 14 章即可建立全貌。
术语速查(全系列通用)
| 缩写 | 全称 | 含义 |
|---|---|---|
| AFSIM | Advanced Framework for Simulation, Integration, and Modeling | 本框架 |
| WSF | World Simulation Framework | AFSIM 的核心对象库与命名前缀 |
| DSF | Distributed Simulation Framework | 分布式仿真接口相关(DIS/HLA 等) |
| DIS | Distributed Interactive Simulation | IEEE 1278 分布式互操作标准 |
| HLA | High Level Architecture | IEEE 1516 分布式仿真标准 |
| XIO | eXternal Input/Output | AFSIM 自有分布式/控制协议 |
| CBA | Component Based Architecture | AFSIM 的组件化架构 |
| CONOPS | Concept of Operations | 作战概念 |
| M&S | Modeling and Simulation | 建模仿真 |
| RCS | Radar Cross Section | 雷达截面积 |
| ESM / EW | Electronic Support Measures / Electronic Warfare | 电子支援措施 / 电子战 |
| IRST / EOIR | Infrared Search & Track / Electro-Optical Infrared | 红外搜索跟踪 / 光电红外 |
第 1 章 · AFSIM 总览与生态定位
本系列基于 AFSIM 2.9.0 源码。第 1 章先回答三个问题:AFSIM 是什么、它由什么组成、它解决什么别人解决不了的问题。
1.1 一句话定义
打开仓库根目录的 README.md,AFSIM 的官方定义只有一句话:
The Advanced Framework for Simulation, Integration, and Modeling (AFSIM) is an object-oriented C++ library that is used to create simulations that model platform interactions in a geographic context.
拆开看,这句话里有四个关键词,恰好构成理解整个框架的四把钥匙:
- object-oriented C++ library(面向对象的 C++ 库)------它首先是一套库,不是软件产品。所有能"跑起来"的东西,都是在这套库之上组装出来的应用。
- platform(平台)------仿真的顶层实体对象。平台是一个"容器",武器、传感器、通信设备、运动模型都挂载在它上面。
- interaction(交互)------仿真的核心是平台之间的交互:谁探测到了谁、谁向谁发了消息、谁击毁了谁、谁和谁相撞。
- geographic context(地理语境)------所有交互都发生在真实的地球坐标系、地形、大气环境中,而不是抽象的二维平面。
这四个词也解释了 AFSIM 的适用边界:它擅长"很多实体在真实地理环境里相互感知与作用"的推演,例如空中作战、导弹防御、传感器覆盖、电子对抗。
1.2 出身与分发方式:为什么它如此"神秘"
AFSIM 由美国空军研究实验室(AFRL)气动系统局主导,代码中大量文件的版权头标注为波音公司(2003 年起),后续由多家承包商(如 Infoscitex / DCS)持续维护。仓库根目录的 README 明确列出了分发与出口管制信息:
- 标记为 CUI(Controlled Unclassified Information,受控非密信息);
- 出口受《武器出口管制法》等法规约束(EXPORT CONTROLLED);
- 分发声明为 DIST-F,仅限美国及授权对象。
这解释了一个现象:AFSIM 在开源社区几乎"查无此人",但在美国国防建模与仿真圈子里却是主流工具。它的使用者社区、文档、发布页都挂在需要权限访问的国防门户(DI2E)上。我们能在本仓库里看到的,是一份源码级的分发版本------这恰恰是研究其架构的最佳素材。
阅读提示:本系列只讨论架构与技术事实,不涉及任何受控的分发内容本身。所有引用均来自仓库中的源码与随附文档。
1.3 核心抽象:平台、组件与交互
AFSIM 的对象模型可以由一张非常朴素的图概括:平台是容器,组件是能力,交互是结果。
**平台(Platform)**是整个仿真的中心。官方文档把它类比为 "bodies / entities / players"(实体/玩家)。一个平台可以代表:
- 空中/地面/水面/水下/太空载具;
- 建筑物;
- 甚至"活体"(living beings)。
每个平台拥有唯一名称(SetName())、阵营(SetSide()),并挂载一个运动模型(WsfMover)以及若干系统组件。平台的定义位于 WsfPlatform.hpp。
**组件(Component)**是挂在平台上的功能单元,官方架构图给出了六大"平台组件"类别:
- Movers(运动模型):维护位置、姿态、速度、加速度等运动学状态;
- Sensors(传感器):产生探测测量,并经数据链以航迹消息发出;
- Weapons(武器):阻止目标正常工作(永久或暂时);
- Processors(处理器):行为与算法------像"大脑"或"计算机",多数由脚本定义;
- Communications(通信):平台间经外部链路收发消息;
- Others(其他):燃料、视觉部件、干扰机等。
除此之外还有一类非平台组件(Non-Platform Components),例如区域(Zone)、地形(Terrain)、大气(Atmosphere)等作用于全局的对象。
交互(Interaction)是仿真推进时自然涌现的结果:传感器探测、消息传递、碰撞、交战。值得注意的是,AFSIM 的交互不是"写死的",而是通过一条统一的电磁(EM)交互管线 、一套消息/航迹(Message/Track)机制 和一套观察者(Observer)机制来完成的------这三条管线会在后续章节详细展开。
1.4 框架分层:从"应用"到"组件"
官方架构图(AFSIM_Architecture.png)把整个系统画成两大层:
上层:AFSIM Framework(框架层)
- AFSIM Services(服务层):Scenarios(想定)、Simulations(仿真)、Extensions(扩展)、Script(脚本)、Observer(观察者)、Tasking(任务分配)、Tracking(跟踪)、Geospatial Data(地理空间数据)、Distributed Simulation Interfaces(分布式仿真接口)、Utilities(工具)。
- AFSIM Components(组件层):即上面说的平台组件(Movers/Sensors/Weapons/Processors/Communications/Others)与非平台组件。
顶层:AFSIM Application(应用层)
应用把框架组装成一个可执行程序。官方文档对这条链路有一段非常精确的描述:
一个 AFSIM 可执行程序通常只包含一个 Application ;Application 维护脚本类型、扩展与插件管理器、应用配置数据。Application 由一个或多个 Scenario 组成;Scenario 拥有类型工厂与类型列表、用户输入、脚本。Scenario 由一个或多个 Simulation 组成;Simulation 拥有类型实例、各种接口(DIS、XIO、Observer、地形)以及运行时数据(含事件管理与线程)。
这段描述实际上就是 AFSIM 的"四层对象模型":Application → Scenario → Simulation → Platform 。第 2 章会专门拆解它,这里先建立一个直觉:越往上越"静态"(配置、类型定义),越往下越"动态"(实例、事件、运行时状态)。
1.5 程序清单:一个框架,一整套工具链
AFSIM 交付的不只是一个 mission 可执行文件,而是一个完整生态。官方文档把它分成两组:
核心应用(Core Applications)
最基础的是 mission(任务级仿真可执行程序)。它读取描述 WSF 语言的文本文件,推进时间、移动平台、做决策、执行交互。它同时支持:
- 事件步进(event-stepped) 与 帧步进(frame-stepped) 两种执行模式;
- 实时(realtime) 与 非实时(non-realtime) 两种时间推进方式;
- 纯构造仿真(含蒙特卡洛多次迭代);
- 作为 DIS 分布式演练的一方参与。
mission 的三种主要输出(后面章节会展开):人类可读的事件日志 (event_output)、逗号分隔的 CSV 事件输出 (csv_event_output)、以及二进制事件报告文件 (event_pipe,可用 Mystic 查看)。
套件应用(Suite Applications)
围绕 mission 的配套工具(位于 src/ 下同名目录),用于开发、执行、分析与处理仿真数据:
| 工具 | 定位 |
|---|---|
| Warlock | 实时人在回路(operator-in-the-loop)仿真与想定推演工具 |
| Wizard | 任务/想定规划(mission planning)工具 |
| Mystic | 仿真结果可视化与事件回放工具 |
| Engage | 交战(engagement)分析工具 |
| post_processor | 事后处理,生成通信/探测/交战/弹道等报告 |
| sensor_plot | 传感器探测与覆盖绘图 |
| weapon_tools | 武器相关辅助工具 |
| mover_creator | 运动模型(如 6DOF)创建器 |
| evt_reader | .evt 事件日志读取器 |
| tools | 通用工具集 |
这些工具将在第 13 章统一介绍。此处只需记住:AFSIM 的"框架+工具链"设计,使同一份想定与模型既能做批处理分析,也能做人在回路推演,无需重写。
1.6 设计哲学:五个可迁移的决策
在深入细节之前,先给出 AFSIM 架构中五个最有代表性的设计决策。它们贯穿全系列,也是本系列反复要"回扣"的主线。
① 文本驱动(Text-driven) 仿真世界用一套人类可读的文本 DSL 描述,而不是图形界面或二进制配置文件。这带来版本控制友好、可脚本化、可批量生成三大好处。第 4 章会讲这套输入系统如何被解析。
② 脚本可扩展(Script-extensible) 大量行为逻辑(处理器、决策、观察者回调)用 AFSIM 自带的脚本语言编写,无需 C++ 编译。这让"作战分析人员"和"程序员"可以分工协作。第 5 章专讲脚本引擎。
③ 插件化(Plug-in) 底层模型可以通过插件动态加载,不必重新编译核心框架,也不必分发框架源码。第 12 章会讲三种集成方式。
④ 事件驱动(Event-driven) 仿真内核是离散事件仿真器(DES)。但对使用者而言,事件被"抽象掉"了------分析人员写的是 on_update、at_time 这类高级概念,而不是手工插事件。第 3 章会揭示这层抽象如何实现。
⑤ 组件化(CBA) 一切能力都以"组件"形式挂载到"组件列表"上,按名称或角色(role)查找。这是 AFSIM 能容纳几十种传感器、几十种运动模型的根本原因。第 2 章与第 12 章重点展开。
1.7 术语与命名习惯
快速掌握 AFSIM 源码的命名习惯,能极大提升阅读效率:
Wsf前缀 (World Simulation Framework):仿真核心对象,如WsfSimulation、WsfPlatform、WsfSensor。绝大多数业务类都在此命名体系下。Ut前缀 (Utility):与仿真无关的通用工具,如UtInput(输入流)、UtScript(脚本)、UtEntity(数学实体/坐标)、UtRandom(随机数)。这些类本系列只在必要时提及。Types结尾 (如WsfSensorTypes、WsfMoverTypes、WsfProcessorTypes):类型工厂 + 类型列表。这是 AFSIM 实现"按名字造对象"的关键,第 4 章详解。Observer结尾 :观察者服务的回调接口/数据结构,如WsfPlatformObserver、WsfSensorObserver。_Extension结尾 :扩展点,如WsfApplicationExtension、WsfScenarioExtension、WsfSimulationExtension。- 接口类 :
*Interface,如WsfDisInterface、WsfXIO_Interface、WsfEventPipeInterface。
1.8 本章小结
- AFSIM 是 AFRL 主导的、面向地理语境的平台交互仿真框架,本质是一套 C++ 库加一整套工具链。
- 四个核心概念:平台(容器)、组件(能力)、交互(结果)、地理语境(舞台)。
- 分层为 Application → Scenario → Simulation → Platform 四层对象模型,配合 Services + Components 的服务/组件分层。
- 交付形态是"一个核心可执行程序 mission + 一组套件工具",覆盖从构造仿真到人在回路的全流程。
- 五大设计哲学:文本驱动、脚本可扩展、插件化、事件驱动、组件化。
第 2 章将正式进入 AFSIM 的对象模型,把"四层结构"和"组件化架构(CBA)"这两块基石拆开来看。
第 2 章 · 四层对象模型与组件化架构
如果说第 1 章回答的是"AFSIM 是什么",那么本章要回答的是"AFSIM 是靠什么长出来的"。答案是两条主线:一条是纵向的四层对象模型 (Application → Scenario → Simulation → Platform),另一条是横向的组件化架构 CBA(Component Based Architecture)。读懂这两条线,你就能读懂 AFSIM 大部分源码的骨架。
2.1 为什么必须从"对象模型"切入
很多工程师第一次读 AFSIM 源码时的困惑是:"它到底在哪里做仿真?"
如果你期待找到一个大文件、一个 RunSimulation() 函数,你会失望。AFSIM 的设计哲学是把"谁拥有谁""谁在什么时候被创建""谁按什么顺序初始化"这件事本身,做成了一套严谨的对象模型。仿真推进(第 3 章的离散事件内核)、传感器探测(第 7 章)、通信(第 8 章)这些"热闹的功能",全部是挂在这套对象模型之上的组件。
因此,本章不急于讲算法,而是先建立两张"地图":
- 纵向地图------从最顶层、最静态的执行环境,一路下沉到最底层、最动态的实体;
- 横向地图------一个平台(或任意宿主)如何用"组件列表"把无数功能单元拼装起来,并保证类型安全、可查询、可扩展。
这两张地图一旦建立,后面每一章都是在其中填内容。
本章的写法是"先问为什么,再看是什么,最后谈启示"。因为 AFSIM 里很多设计看起来"绕"------为什么要分四层而不写一个大类?为什么组件基类几乎没有成员?为什么一个平台要同时继承六七个基类?------这些疑问只有回到"它当初想解决什么问题"才有答案。我们会在每一节里先把动机摆出来,再对照源码里的真实定义,最后把可迁移的经验提炼出来。
2.2 四层对象模型:官方描述与直觉
AFSIM 官方文档 architecture_overview.rst 用一段话精确描述了这四层关系,值得原样理解:
一个 AFSIM 可执行程序通常只包含一个 Application 。Application 维护脚本类型(script types)、扩展与插件管理器(extension and plug-in manager)以及应用配置数据。Application 由一个或多个 Scenario 组成,Scenario 拥有类型工厂与类型列表(type factories and lists)、用户输入与脚本。Scenario 由一个或多个 Simulation 组成。Simulation 包含类型实例(type instances)、接口(如 DIS、XIO、Observer、地形)以及运行时数据,其中包括事件管理与线程。
这段话里隐含了几个数量级递减/递增的重要事实:
- Application 唯一 :整个进程里通常只有一个应用对象。源码 WsfApplication.hpp 的类注释明确写道:"Any WSF application... Only one instance of this object may exist at any given time."(任意时刻只能存在一个实例。)构造函数还会在检测到重复创建时抛出异常。
- Scenario 可多:应用可承载多个想定,但官方也指出"多个 scenario 是相当非标准的用法"(somewhat substandard)------绝大多数应用是"一应用一 scenario"。
- Simulation 可多:一个 scenario 可派生出多个 simulation,这是"同一份想定、多次随机复现(Monte Carlo)"的基础。
- Platform 可多:一个 simulation 维护一份平台列表,平台数量可达成百上千。
我们用一个表格把四层职责、拥有物与"静态/动态"属性对齐:
| 层级 | 关键类 | 拥有什么 | 生命周期定位 | 静态 ↔ 动态 |
|---|---|---|---|---|
| Application(应用) | WsfApplication |
脚本类型、插件/扩展管理器、应用级配置、命令行参数 | 进程级,唯一且最长寿 | 最静态 |
| Scenario(想定) | WsfScenario |
类型工厂与类型列表、用户输入、脚本、环境数据 | 加载期构建,仿真前冻结 | 偏静态 |
| Simulation(仿真) | WsfSimulation |
类型实例、接口(DIS/XIO/Observer/地形)、事件管理器、线程 | 运行期存在,可跑多份 | 偏动态 |
| Platform(平台) | WsfPlatform |
组件(传感器/通信/处理器/运动模型...)、位置姿态状态 | 运行期可随时创建/销毁 | 最动态 |
关键直觉:越往上越静态,越往下越动态
这是理解整套架构的"心法"。可以这样类比:
- Application = 操作系统:进程启动时装配一次,此后基本不变。它决定"这个程序会哪些本事"(注册了哪些类型、加载了哪些插件)。
- Scenario = 程序与配置文件 :把文本输入解析成内存中的类型 ("蓝方战斗机长什么样""某型雷达参数是多少")。它是模板,不是实例。
- Simulation = 一次具体的运行 :根据模板实例化出真实的平台、真实的传感器,并驱动时间前进。
- Platform = 数据行:真正被探测、被打击、被摧毁的那个对象。它随仿真动态生灭。
越往上、越靠近"配置",对象越稳定、越共享;越往下、越靠近"运行时",对象越易变、越专属。一次想定、多次复现(一个 scenario 派生多个 simulation)之所以成立,正是因为"模板"与"实例"被清楚地分在了两层。
2.2.1 Application 层:WsfApplication
WsfApplication.hpp 的类注释点明它的三大职责:
- 维护应用扩展(application extensions) 、插件(plugins)与应用级对象;
- 支撑 scenario 与 simulation 的有序创建;
- 提供命令行的处理。
它"只提供最小功能",实际用户一般会用派生的 WsfStandardApplication,后者补齐了把框架"跑起来"所需的便利设施。这是一个典型的模板方法 + 基类最小化设计:基类只定义扩展点与生命周期骨架,具体装配交给派生类。
2.2.2 Scenario 层:WsfScenario
WsfScenario.hpp 的类注释把它定义为"包含创建一次 simulation 所需的数据,并作为输入文件处理的入口点"。看它的类型定义与成员声明,就能感受到"模板层"的分量:它持有海量的类型注册表 ------从 WsfSensorTypes、WsfMoverTypes、WsfProcessorTypes,到 WsfAntennaPatternTypes、WsfRadarSignatureTypes、WsfFuelTypes,再到通信框架的 wsf::comm::Types、wsf::comm::router::Types。这些"xxxTypes"就是类型工厂与类型列表。
同时它还持有 WsfSimulationInput(冻结的用户输入)、WsfScriptManager(脚本)、WsfComponentFactoryList(组件工厂)、地形、大气、IFF 管理器等全局服务。
一个非常重要的设计点是:WsfScenario 的拷贝构造与赋值被显式 = delete。想定不可拷贝------它是独一无二的类型词典,只被引用、不被复制。这与"多个 simulation 共享同一 scenario"的用法一致:所有 simulation 都指向同一个 scenario,从它那里取"类型模板"。
2.2.3 Simulation 层:WsfSimulation
WsfSimulation.hpp 的类注释称它为"仿真的主控制器",并列出了它提供的核心能力:
- 维护事件队列(event queue);
- 监督平台的增删;
- 协调地把平台系统开关(turn on/off);
- 维护observer(观察者)。
它定义了一个清晰的状态机(State 枚举):cPENDING_INITIALIZE → cINITIALIZING → cPENDING_START → cSTARTING → cACTIVE → cPENDING_COMPLETE → cCOMPLETE,并用 CompletionReason 记录结束原因(到达结束时间、被 reset、被 terminate 等)。从"待初始化"到"活动",再到"完成",simulation 的一生被显式建模------这正是第 3 章离散事件内核的宿主对象。类注释还强调:"大部分方法是虚函数,派生类可用来提供自定义仿真功能。"这又是一个扩展点。
2.2.4 Platform 层:WsfPlatform
WsfPlatform.hpp 是四层里最"接地气"的一层。它的类注释给出创建平台的通用序列:
- 实例化一个
WsfPlatform; - 用
SetName()命名(仿真中每个平台名必须唯一); - 用
SetSide()设定阵营; - 实例化合适的
WsfMover派生类并调用SetMover(); - 实例化各系统(
WsfComm、WsfProcessor、WsfSensor等)并用AddComponent()挂到平台上。
注释里还有一个极重要的生命周期警告 :平台可能在仿真任意时刻被"摧毁",因此不要持有平台的持久指针 ,而应保存"平台索引(index)",用时通过 GetSimulation()->GetPlatformByIndex(index) 取回,取不到即为已销毁。这条规则贯穿整个框架,是"动态层"的典型特征------实例是易失的,只有索引是稳定的。
同时注意"每个平台名必须唯一"这条约束背后的含义:平台名不仅是给人看的标签,更是运行时的查找键 (例如脚本里按名字取平台、通信按名字寻址)。也正因如此,平台作为组件时直接用 GetNameId() 充当 GetComponentName()------名字既是身份,又是索引,一处命名、处处可查。这种"名字即键"的做法,让平台在 WsfComponentList 里有天然的、与生俱来的唯一标识。
平台这一层我们会在 2.8 节展开它最精妙的"双重身份"(既是组件,又是组件列表)。
2.2.5 一次运行中各层的创建时序
把四层按时间轴串起来,就能看清它们相互"托举"的关系。一次典型的运行大致经历如下顺序:
- 进程启动,创建唯一的 Application。此时它读取命令行参数、装配应用扩展与插件管理器,并把"本程序支持哪些脚本类型"登记好。这一步决定了这个可执行文件的"先天能力边界"。
- 创建 Scenario 并解析输入 。输入文件里的平台定义、组件定义、脚本,被逐条翻译成 scenario 内部的一批类型对象(类型工厂产出类型,类型列表把它们存起来)。注意此刻还没有任何真实平台------只有"图纸"。
- 由 scenario 实例化 Simulation。simulation 依据 scenario 的类型列表,"按图纸生产"出真实的平台与组件实例,装配事件管理器、观察者与各类接口。
- 初始化与推进 。simulation 进入初始化状态机,逐个初始化平台及其组件(遵循 2.6.5 的初始化顺序权重),随后进入
cACTIVE,开始按事件驱动推进时间。 - 平台在运行中动态生灭。被摧毁的平台从列表移除、索引失效;需要时又可新建平台。这一层变化最频繁。
- 结束与销毁 。到达结束时间或被 terminate,simulation 进入
cCOMPLETE;scenario 与 application 最后回收。
这个时序揭示了一条重要规律:上层负责"定义可能",下层负责"实现当下"。Application 定义"能注册什么",Scenario 定义"这次要什么",Simulation 负责"把要的做出来并推进",Platform 承担"做出来之后的具体存在"。正因为职责被切得这么清楚,AFSIM 才能把"配置一次、复现多次"的成本压到极低------想定解析只做一次,之后每派生一个 simulation 就只是一次"重新生产实例 + 换随机种子"。
一个容易被忽略的细节是:上层对象对下层是"只读模板"关系 。simulation 只从 scenario 读类型,不会修改 scenario;scenario 的不可拷贝(拷贝构造被删除)正是为了杜绝"被伪造出一份副本"这类破坏模板唯一性的操作。共享只读、独占可变------这条原则贯穿四层。
2.3 组件化架构 CBA:为什么不用"深继承树"
读到这里你可能已经问:既然有 WsfPlatform 这个容器,为什么不干脆让"带雷达的战斗机""带干扰机的轰炸机"各自继承一个大类?
这就是 CBA 要解决的核心问题。纯继承(inheritance)应对"组合爆炸"会失控。设想:
- 平台种类:战斗机、轰炸机、预警机、驱逐舰、潜艇......
- 能力种类:雷达、红外、激光、通信、数据链、干扰、武器、燃料、运动模型......
- 每种能力的多种实现:不同雷达型号、不同通信协议......
如果每接一个新能力就"组合出一个新子类",类的数量会以乘积速度膨胀,且能力无法在运行时增删。AFSIM 的答案是把**"是什么"(类型继承)与"有什么"(组合)**解耦:
- 继承 只负责"这是一个传感器""这是一个可定向部件"这类身份;
- 组合 负责"这个平台上装了哪些能力"这类装配。
而支撑这套组合的底座,就是组件(WsfComponent)+ 组件列表(WsfComponentList) 。官方文档 integration_guide.rst 中专门有 "Component Based Architecture" 一节,配合 AFSIM_CBA.png 图描述其动机与形态。
换个角度说,AFSIM 把"扩展"拆成了两条彼此正交的轴:
- 纵向的"身份轴"(继承) :回答"它本质上是哪一类东西"。例如传感器继承链告诉框架"它可以被当成传感器、可定向部件、平台部件来使用"。这条轴变化慢、数量少。
- 横向的"装配轴"(组合) :回答"此刻它身上挂了些什么"。例如某架飞机挂了 X 型雷达、Y 型数据链、Z 型干扰机。这条轴变化快、组合多。
如果把两者混在一棵继承树里,任何一次"换装"都得改类型系统。而拆成两条轴之后,"换装"只需在装配轴上增删组件,完全不动身份轴 。你可以把平台理解成一块主板 ,组件是板卡:主板的型号(身份)很少变,而插什么板卡(装配)可以随时换。理解这一点,就理解了 AFSIM 为什么能在不重编译核心的前提下,让第三方把新传感器、新通信协议"插"进来。
第三个不容忽视的动机是运行时可插拔 。纯继承的装配关系在编译期就被钉死,想"发射前扔掉一个传感器""被毁后禁用某子系统"都很别扭;而组件列表天然支持运行时 AddComponent() / DeleteComponent(),再配合损伤语义与观察者通知,就能自然地表达"能力随战损增减"。这是军事仿真里非常真实、非常高频的需求。
2.4 WsfComponent:一个几乎"零状态"的抽象基类
打开 WsfComponent.hpp,第一印象是:这个基类几乎没有成员变量 。它只有一个虚析构函数,其余全是纯虚接口与少数可选虚函数。文件顶部还留了一段"编程注意事项",解释了为什么所有方法名都带 "Component" 字样------因为像 WsfPlatform 这样的类会同时继承 WsfComponent 与 WsfComponentList,加上前缀可避免命名冲突,也暗示了"组件"与"组件列表"会合体。
2.4.1 必须实现的四个接口
注释把以下四个方法列为必需接口,派生类必须实现:
CloneComponent():克隆自身(不支持克隆时可返回空)。GetComponentName():返回组件名。注释强调同一列表内组件名唯一 ;代码生成的名字会以#前缀避免与用户命名冲突。GetComponentRoles():返回一个以 0 结尾的角色数组,按"由特殊到一般"降序排列,首元素是主角色(primary role)。QueryInterface(int aRole):按角色返回接口指针;若组件不支持该角色则返回空。
注意这里的措辞"a generic object with discoverable interfaces (可被发现的接口)"------WsfComponent 描述的不是"一个功能",而是"一个可以被按角色查询的接口提供者"。这是 CBA 的立意所在。
2.4.2 可选的公共生命周期虚函数
除必需接口外,基类还提供一批可选虚函数,覆盖组件从"读输入"到"二次初始化"的完整生命周期:
PreInput()/ProcessInput(UtInput&):输入解析阶段;PreInitialize(double)/Initialize(double)/Initialize2(double):分阶段的初始化;GetComponentInitializationOrder():返回初始化顺序权重(默认 0)。
这些函数默认实现为空或平凡返回,派生类按需覆盖,不必全实现------这就是为什么注释说它们"完全可选......作为便利提供,免得派生类被迫定义"。
2.4.3 WsfComponentT:带"父指针"的模板版本
基类之上还有一个模板 WsfComponentT<PARENT_TYPE>,它为组件维护一个指向宿主(parent)的指针 mParentPtr,并提供 GetComponentParent() / SetComponentParent()。关键细节是 SetComponentParent() 在设置指针后会调用虚函数 ComponentParentChanged(),通知派生类"你的宿主变了" 。并且有平台专用的类型别名 WsfPlatformComponent = WsfComponentT<WsfPlatform>------即"平台组件"就是"宿主类型为 WsfPlatform 的组件"。
这套"父指针 + 变更回调"机制,让组件在被挂载/摘除时能及时重建内部状态(例如重编译脚本上下文),是组合架构能安全运作的粘合剂。
2.5 WsfComponentList:把"组件列表"提升为一等公民
如果说 WsfComponent 是"零件",那么 WsfComponentList.hpp 中的组件列表就是"零件仓库"。这是 AFSIM 架构里最能体现功力的一处设计。
2.5.1 存储与基本操作
列表内部用 std::vector<std::pair<WsfStringId, WsfComponent*>> 存储"名字 → 组件"的键值对。它支持:
HasComponents()、GetComponentCount(int aRole);DeleteComponent(name, role)、DeleteAllComponents();- 一组受保护 的底层操作:
AddComponentP、DeleteComponentP、FindComponentP、FindComponentByRoleP、CopyAllP、DeleteAllP、RemoveComponentP等。
这里有一个刻意的设计选择,注释写得很明白:基类不包含 AddComponent 方法 ,因为要保证"类型化组件列表"的类型安全------否则就可能把错误类型的组件注入列表。AddComponent 被下沉到模板子类 WsfComponentListT<COMPONENT_TYPE> 上,只有它才能做类型检查。
值得体会的是这套"基类做通用、子类做类型"的分工方式。基类 WsfComponentList 不知道任何具体组件类型,它只按"名字 + 角色"这类通用的键 来管理条目;真正的类型约束被推迟到模板子类 WsfComponentListT<T>。这样做的收益是:通用逻辑只写一遍 (查找、增删、遍历、拷贝都能在非模板基类里实现),而类型安全在编译期由模板保证。这是 C++ 里"用非模板基类消除代码重复、用模板子类恢复类型安全"的经典套路,AFSIM 把它用在了组件容器上。
2.5.2 模板化的类型安全查询
WsfComponentList 提供了大量函数模板,让"按类型查组件"既简洁又安全:
GetComponent<T>()/GetComponent<T>(name):按角色或名字取组件;GetComponentCount<T>()/GetComponentEntry<T>(i):计数与按下标访问;DeleteComponent<T>(name)/DeleteComponent<T>();FindByRole<T>(T*&):按角色查找。
这些模板都依赖"类型注册了组件角色"这一前提(见 2.6 节)。注释指出:用一个未注册角色的类型去实例化这些模板会产生编译期错误------把错误从"运行时崩溃"提前到"编译不通过",这是非常工程化的取舍。
2.5.3 遍历:RoleIterator 与 RoleRange
列表提供了两种现代 C++ 风格的遍历:
RoleIterator<T>:一个只返回"支持角色 T"的组件的迭代器。它的构造函数就会跳过不支持该角色的条目,operator++的AdvanceP()也持续跳过,直到遇到下一个匹配项。RoleRange<T>+GetComponentRange<T>():把前者包装成可被for-each遍历的"范围",写法清爽得像 C++20 range。
还有一个更朴素的 WsfComponentListT::Iterator(Begin()/End()),用于顺序访问列表内所有 组件(不做角色过滤),并提供自由函数形式的 begin()/end() 以便配合标准算法。
2.5.4 通知回调:ComponentAdded / ComponentDeleted
列表定义了受保护的虚函数 ComponentAdded(WsfComponent*) 与 ComponentDeleted(WsfComponent*),在组件被增删时被调用。这样宿主(如平台)就能在"某个传感器被装上/拆除"时做出反应------重新建立内部链接、重算能力等。"列表的变更"本身成为一种可被监听的事件,这是把组件列表"提升为一等公民"的直接体现。
2.5.5 初始化顺序:GetComponentsByInitializationOrder()
模板子类还提供 GetComponentsByInitializationOrder():它把列表内组件按 GetComponentInitializationOrder() 稳定排序 后返回一个 std::vector。这解决了一个现实问题------运动会话必须先于传感器初始化(因为后者依赖初始位置),而某些组件又必须先于运动模型(如指令链)。用"数值权重 + 稳定排序"表达依赖顺序,比硬编码调用次序更易扩展。参考权重定义见 2.6 节。
2.5.6 拷贝语义的两个"坑"
注释里藏着一个资深工程师才懂的警告:不要在拷贝构造的初始化列表里写"用 this 作为父指针" 。因为此时宿主对象尚处于"不确定状态",对 this 的任何间接引用都很危险。因此 WsfComponentListT 的拷贝构造先把父指针置空,父指针的实际绑定延迟到宿主对象拷贝构造函数体 中通过 SetParentOfComponents() 完成。SetParentOfComponents() 还会遍历所有组件,逐个 SetComponentParent() 并触发 ComponentAdded()。这段处理非常细腻,是"父指针 + 通知"机制在拷贝场景下的必要代价。
2.6 角色机制:为什么"角色 + 查询"胜过"继承 + 强转"
CBA 最核心的一招,是组件角色(component role) 。它由 WsfComponentRoles.hpp 定义。
2.6.1 用宏注册角色
一个类型要能参与"按角色查询",必须先用宏 WSF_DECLARE_COMPONENT_ROLE_TYPE(TYPE, ROLE) 注册。该宏通过特化模板 WsfComponentRole<T> 把"类型 → 角色整数"的映射固化在编译期 。宏里还内置了两条 static_assert:一是该类型必须派生自 WsfComponent,二是角色值必须大于 0。若你忘记注册就使用相关模板,会触发一个精心设计的编译期错误(WsfComponentRole 的默认实现带 static_assert),提示你"用宏注册类型"。代码库还提供了 cCOMPONENT_ROLE<T>() 这个可隐式转换为 int 的小工具类来取角色值。
2.6.2 全局角色枚举与分配规则
WsfComponentRoles.hpp 中定义了一套全局角色编号,注释明确其分配规则(类似 DIS 枚举的"委员会分配"):
- 0--199 :AFSIM 核心组件(
wsf目录); - 200--999:属于 AFSIM 但位于其他目录的组件(出于安全考虑不在此公开,由各目录自行约定);
- 1000 及以上:预留给各站点(site)自行分配。
核心角色里我们能读到平台相关的一组编号:平台本身 cWSF_COMPONENT_PLATFORM = 1、平台部件 PLATFORM_PART = 2、可定向部件 ARTICULATED_PART = 3,以及各类具体部件:MOVER = 4、FUEL = 5、COMM = 6、PROCESSOR = 8、SENSOR = 9、VISUAL = 10 等。除此之外还有通信框架(61--72)、EM 收发机(98--99)、平台外核心组件(100--108)等分区。
2.6.3 多角色与"主角色"
一个组件可以拥有多个角色 ,由 GetComponentRoles() 返回数组,按继承层次由特殊到一般降序排列 。文档给出的经典例子是 WsfSensor 返回:
{ SENSOR, ARTICULATED_PART, PLATFORM_PART, 0 }
也就是说:一个传感器首先 是传感器,同时 是一个可定向部件,同时 是一个平台部件。当有人以 ARTICULATED_PART 角色查询时,它同样会被命中。数组首元素即"主角色",列表的删除等操作会用主角色作为其唯一标识。
2.6.4 QueryInterface:安全的多态分发
QueryInterface(int aRole) 是这一切的出口:组件对"我支持的角色"返回自身指针,否则返回空。它有两层便利封装:
QueryInterfaceT<T>(T*& aRolePtr):类型安全版本,内部用static_cast转换QueryInterface(cCOMPONENT_ROLE<T>());ComponentHasRole(int):判断是否具备某角色。
为什么这比深继承树 + dynamic_cast 更好? 关键对比:
| 维度 | 深继承 + dynamic_cast |
角色 + QueryInterface |
|---|---|---|
| 组合爆炸 | 每新增一种"能力组合"就要新建子类 | 能力用"角色"声明,任意组合无需新子类 |
| 运行开销 | dynamic_cast 沿继承链动态检查,较慢 |
角色是整数,比较/分派极快 |
| 可发现性 | 调用方须知道具体类型名 | 只需知道"角色",面向接口而非实现 |
| 多身份 | 单继承下"多身份"难表达 | 一个组件天然携带多个角色 |
| 运行时装配 | 编译期固定,难增删 | 可运行时 AddComponent/DeleteComponent |
| 编译期安全 | 强转可能运行期崩溃 | 未注册角色直接编译失败 |
一句话:角色把"我是什么"从类型系统里抽出来,变成一组可在运行时分派的数字,从而让"任意能力的任意组合"成为可能,而无需为每种组合编写一个类。这正是 CBA 得以避免继承树爆炸的根本原因。
2.6.5 初始化顺序权重
同一头文件里还定义了一组初始化顺序参考值 (注释说明"留出空隙,便于新组件插入"):指令链最早(-1000000000),航迹管理器次之,运动模型再次(因为它定义初始位置),随后依次是燃料、导航误差、路由器、通信、处理器、传感器、视觉部件、区域。未指定顺序的组件默认为 0,并按输入文件中出现的顺序初始化。这套"带空隙的数值刻度"是一种非常实用的工程惯例------它让新组件既能把"自己插在谁前面",又不必改动已有数值。
2.7 组件继承体系:从 WsfObject 到具体部件
组件本身也有一条清晰的继承链。它不是靠"深"取胜,而是靠"窄而稳"的能力分层 。我们用文字自上而下描述这条链(配合图 wsf_component_hierachy.png、wsf_platform_component_hierachy.png 与 wsf_sensor_component_example.png):
WsfObject(见 WsfObject.hpp):所有 AFSIM 对象的公共根基,提供名字、类型、克隆、脚本等通用能力。WsfPlatformPart(见 WsfPlatformPart.hpp):被平台聚合的所有对象的基类 。它的类注释直接列出了六大子类:WsfComm、WsfFuel、WsfMover、WsfProcessor、WsfSensor、WsfVisualPart。它规定了子类必须支持的抽象接口(Initialize、ProcessInput、Update),并提供"部件间互发消息""归类(category)""损伤状态语义"等平台级公共能力。其声明本身就很能说明问题------它同时继承WsfObject、WsfPlatformComponent、WsfUniqueId、WsfAuxDataEnabled,即"一个对象 + 一个平台组件 + 一个唯一 ID + 一个辅助数据宿主"。WsfArticulatedPart(见 WsfArticulatedPart.hpp):可定向部件 的基类。注释说明它面向"在平台上有位置与朝向,并需要被'指向(cue)'的部件",典型例子正是传感器与通信系统。它引入了转动(slew)模式(固定/仅方位/仅俯仰/方位俯仰)、转动速率、转动限位、指向目标(cue to location/angle)等概念,并继承UtEntityPart、WsfSinglePlatformObserver以获得"相对于平台实体的位姿"以及"平台一动我就更新变换"的观察者回调。- 各具体部件 :传感器、通信、武器、干扰机等在最下端。以图 wsf_sensor_component_example.png 展示的传感器为例,它沿着
WsfObject → WsfPlatformPart → WsfArticulatedPart → WsfSensor这条链获得全部公共能力,再叠加自己的探测、跟踪、报告逻辑。
我们用一个表格总结这条链上每一层"新增了什么":
| 层级 | 新增职责 | 典型子类 |
|---|---|---|
WsfObject |
名字、类型、克隆、脚本绑定 | 几乎所有 AFSIM 对象 |
WsfPlatformPart |
平台聚合、部件间消息、损伤语义、初始化/更新抽象 | WsfComm、WsfFuel、WsfMover、WsfProcessor、WsfSensor、WsfVisualPart |
WsfArticulatedPart |
位置/朝向、转动与限位、指向(cue)、平台观察者 | 传感器、通信天线等 |
| 具体部件 | 领域逻辑(探测、收发、机动...) | 各型号传感器/通信/武器 |
2.7.1 为什么继承链可以这么"浅":组件套组件
一个自然的问题是:雷达的天线、发射机、接收机、信号处理、干扰样式这些千差万别的子功能,如果也全塞进继承树,链子岂不又会长回去?
AFSIM 的答案很关键:组件自己也可以是"组件列表",于是能力可以"套娃"式地下沉,而不必堆叠继承深度 。回看 2.5 节的编程注意事项------"有些类同时继承 WsfComponent 与 WsfComponentList(即它们既是组件,又支持子组件)"------这正是为"组件套组件"准备的。于是一个传感器可以继承 得到"我是一个传感器/可定向部件"的身份,同时组合 出天线、发射机、接收机、信号处理器等子组件。图 wsf_sensor_component_example.png 画的正是这种"组件内还挂着组件"的结构。
这样一来,继承树只在"能力分类"这一维度上保持浅而稳定(身份轴),而"具体实现细节"全部转由嵌套的组件列表 承载(装配轴)。纵向上尽量浅,横向上允许深 ------这八个字可以说是 AFSIM 组件模型的形态纲领。它也解释了为什么 WsfComponentRoles.hpp 里既有"平台部件"这类平台级角色,也有 cWSF_COMPONENT_SENSOR_COMPONENT(挂在传感器上的组件)这类"下级宿主"角色------因为角色机制同样要为"组件套组件"服务。
值得注意的是:这条继承链并不与"角色数组"冲突 。恰恰相反,二者一一对应------WsfSensor 的继承链 Object → PlatformPart → ArticulatedPart → Sensor 正好映射为角色数组 { SENSOR, ARTICULATED_PART, PLATFORM_PART }(由特殊到一般)。继承表达"身份层级",角色把这一层级暴露成可查询的数字,两者配合,既保证了代码复用,又保证了运行时可发现性。
2.8 WsfPlatform:既是"组件",也是"组件列表"
本章最巧妙的一处,是 WsfPlatform 的双重身份。看它的声明,它同时继承:
WsfObject(是个对象);WsfPlatformComponent(自身可作为一个组件);WsfPlatformComponentList(自身又是一个组件列表,可容纳子组件);WsfUniqueId(有唯一标识);UtEntity(是一个可被物理引擎操作的实体);WsfAuxDataEnabled(可挂辅助数据)。
这种"同时是零件、又是零件盒"的设计,正是组合模式(Composite Pattern)的体现:平台可以作为更高层容器 的组件,同时又容纳自己的组件。它让"平台装平台"(如母舰载机、挂架上的武器)在同一个抽象下自然表达,而无需专门设计"容器"与"叶子"两套接口。
平台侧暴露给外部的关键方法:
GetComponentName():返回平台名(复用GetNameId());CloneComponent():返回平台克隆;GetComponentRoles()/QueryInterface():支撑角色查询;AddComponent(...):挂载子系统组件;SetMover(...):设置运动模型。
配合前面 2.5 节的列表能力,平台能做的事就非常直观了:用 AddComponent() 装传感器/通信/处理器,用 GetComponent<WsfSensor>(name) 取某个传感器,用 GetComponentRange<WsfProcessor>() 遍历所有处理器,用 DeleteComponent<WsfComm>() 拆除通信。平台作为"容器"的全部语义,就是"一个组件列表"的语义。
一个必须记住的约束(来自 2.2.4 的警告):平台是"动态层"对象,可被随时摧毁,所以外部对平台的引用要用索引而非裸指针 ;但平台内部拥有的组件(传感器、处理器等)可以安全地持有其宿主的指针------因为它们的生命周期严格从属于平台。这种"所有权决定引用方式"的约定,是这套组合架构能安全运行的前提。
2.8.1 从"想定里的类型"到"平台上的组件"
还有一个环需要补上:WsfScenario 里登记的是类型 ,而平台需要的是实例 ,这两者之间的桥梁是组件工厂 (WsfComponentFactory,在 WsfScenario.hpp 中已被引入并持有其列表)。当一个平台在输入里写"装载某型传感器"时,实际上是:想定按其类型词典找到该传感器类型 ,由类型生产出一个实例 ,再把实例作为组件 AddComponent() 到平台上。类型工厂负责"造",组件列表负责"装",二者一前一后,构成了从静态配置到动态装配的完整闭环。
理解这条闭环,就能明白 AFSIM 里"类型(type)"与"组件(component)"这两个词的分工:类型是可复用的、只读的、位于想定层的模板;组件是可变的、实例化的、位于仿真层的实物。同一型雷达类型,可以在一次仿真里被实例化到几十架飞机上,每架飞机上的那一份都是独立组件、各有独立状态------这正是"模板共享、实例独立"的直接体现。
2.9 三层扩展点:把"可扩展"贯穿四层
CBA 解决了"能力如何组合",而 AFSIM 还通过三层扩展点解决"能力如何在不改核心代码的情况下被加载":
WsfApplicationExtension(见 WsfApplicationExtension.hpp):应用层的可选能力。WsfScenarioExtension(见 WsfScenarioExtension.hpp):想定层,用于注册新组件类型、接入输入加载器。WsfSimulationExtension(见 WsfSimulationExtension.hpp):仿真层,提供仿真特有的可选能力并接入 observer 服务。
architecture_overview.rst 对三者的分工做了精炼总结:应用扩展是"加到应用上的可选能力";想定扩展"注册新组件类型并接入输入加载器";仿真扩展"提供仿真专属能力并接入观察者服务"。插件(plug-in)则是扩展的一种形态,让开发者无需重新编译核心代码即可增补能力。
关键设计对齐 :注意三层扩展点恰好对应四层对象模型的上三层(Application / Scenario / Simulation),而最底层的 Platform 不需要自己的"扩展点"------因为平台的能力扩展完全由 CBA 组件 承担。这形成了一种优雅的分工:"新能力"走组件(组合),"新服务/新类型/新接口"走扩展(继承 + 注册)。第 12 章会详细展开三层扩展的实现与用法,此处仅作定位。
2.10 设计启示
AFSIM 的这套对象模型 + CBA,对任何做通用仿真、游戏引擎、可插拔系统的工程师都有直接借鉴价值。我们提炼五条:
第一,把"模板"与"实例"分到不同层,是支持"一次配置、多次复现"的关键。 WsfScenario(类型词典)与 WsfSimulation(运行态实例)的分离,使得 Monte Carlo 复现变得廉价------改随机种子、派生新 simulation 即可,无需重新解析输入。这是"数据与执行分离"的一种具体而强大的形态。
第二,用"角色 + QueryInterface"取代"深继承 + dynamic_cast"。 深继承树在"能力可任意组合"的场景下会爆炸;把"身份"抽象成整数角色并与类型系统绑定(编译期注册、运行时分派),既保住了 C++ 的静态类型安全(未注册即编译失败),又获得了运行时的灵活查询速度。这是 CBA 的灵魂。
第三,把"组件列表"提升为一等公民。 容器本身也是一个有行为的对象:它能按名字/角色查找、能遍历、能在增删时回调、能按权重排序初始化。**"列表会通知""列表会排序"**这两点尤其关键------它让宿主可以在装配变化时重建状态,让复杂的初始化依赖用数据(权重)而非代码(硬编码次序)表达。
第四,组合模式(Composite)让"容器即叶子"成为可能。 WsfPlatform 同时实现"组件"与"组件列表"两个接口,使得"平台套平台""平台套系统"用同一抽象统一表达,避免了容器/叶子的双份接口。
第五,扩展点分层与"组合 + 继承"分工。 新能力用组件(组合),新服务/类型/接口用扩展(继承 + 注册),三层扩展点精确对应上层对象。这套"想清楚每种变化该走哪条路"的分工,避免了把所有扩展都塞进同一条继承链。
2.10.1 对通用仿真/游戏引擎的三点具体借鉴
- 别把"能装什么"写进类型。 如果你的引擎里"带 A 能力的角色"和"带 B 能力的角色"是不同子类,你迟早会陷入类爆炸。改为"一个角色对象 + 一个能力槽位列表",能力以组件形式动态挂载,类型系统只保留"能力分类"这一层。
- 让"查询接口"成为组件的必备契约。 与其到处
dynamic_cast,不如让每个组件声明自己"能扮演哪些角色",由宿主按角色统一分发。这样一来,调用方只依赖角色(接口),不依赖具体类,替换实现时零改动。 - 容器要有"事件"。 组件被挂上/摘下的那一刻,往往正是宿主需要重建内部状态、重算能力清单、重排初始化次序的时机。把这一时机做成回调(正如 AFSIM 的
ComponentAdded/ComponentDeleted与ComponentParentChanged),能把大量易错的"手工同步"变成框架自动完成。
2.10.2 一个常见误区
初学者容易把"组件"理解成"插件"。二者不是一回事:组件是"一个能力实例",运行在想定/仿真内部,通过 CBA 组装;插件是"一份可动态加载的代码包",用来把新的扩展与新的组件类型引入框架。 组件回答"这台平台上有什么",插件回答"这个程序额外会哪些本事"。把这两个层次分清楚,读源码时才不会把"组件工厂产出的实例"与"插件管理器加载的模块"混为一谈。
一句话总结本章:AFSIM 的强大不在于某个厉害算法,而在于它先把"对象如何拥有、如何组装、如何被发现、如何被扩展"设计得很干净,然后所有功能才得以在其上快速生长。
本章小结
- 四层对象模型 :
WsfApplication(进程唯一,最静态)→WsfScenario(类型模板,不可拷贝)→WsfSimulation(运行态主控,可多份)→WsfPlatform(动态实体,可生灭)。核心直觉是"越往上越静态、越往下越动态"。 - 官方描述 来自 architecture_overview.rst:一个 Application 含一个或多个 Scenario,一个 Scenario 含一个或多个 Simulation。
- CBA 的两块基石 :
WsfComponent(近乎零状态的"可查询接口提供者")+WsfComponentList(支持按名/按角色查找、遍历、回调、排序的容器)。 - 角色机制 :用
WSF_DECLARE_COMPONENT_ROLE_TYPE编译期注册角色;GetComponentRoles()返回降序角色数组(首元素为主角色);QueryInterface(int)按角色分发接口;全局角色编号 0--199 核心、200--999 其他目录、1000+ 站点。 - 组件继承链 :
WsfObject → WsfPlatformPart → WsfArticulatedPart → 具体部件;继承层级与角色数组一一对应。 WsfPlatform的双重身份 :既是WsfPlatformComponent(组件),又是WsfPlatformComponentList(组件列表),组合模式的典型;用AddComponent()装部件、SetMover()设运动模型。- 三层扩展点 :
WsfApplicationExtension/WsfScenarioExtension/WsfSimulationExtension对应上三层对象,"新能力走组件、新服务走扩展"。 - 设计启示:模板/实例分层、角色替代深继承、列表一等公民、组合模式、扩展点分层的"组合 + 继承"分工。
第 3 章 · 离散事件仿真内核
上一章我们把 AFSIM 拆成了"平台---组件---交互"的对象模型;这一章要回答的是:这些对象究竟是被什么东西"驱动"着向前演化的?答案是一套离散事件仿真(Discrete Event Simulation,DES)内核。它是 AFSIM 真正跳动的心脏。
3.1 为什么军事仿真需要一个"事件内核"
在讨论"怎么做"之前,先想清楚"为什么"。绝大多数工程师第一次接触仿真时,脑子里浮现的是类似游戏主循环的画面:每帧更新一次所有实体的位置,绘制,再进入下一帧。这套"固定步长、逐帧全量刷新"的思路在游戏里工作得很好,但在军事建模仿真里几乎立刻就会撞墙。
原因是时间尺度的极度不均匀。设想一个防空想定:一架预警机在 300 公里外巡逻,它每 10 秒扫描一次空域;地面雷达每 4 秒转一圈;而一枚来袭的拦截弹在末段做机动时,其导引头可能每 1 毫秒就要更新一次视线角。如果采用固定帧步进,为了不漏掉导弹末段那 1 毫秒的细节,整个想定都得按 1 毫秒推进------那么预警机那 10 秒的扫描间隔就要白白空转一万次。算力被浪费在"什么也没发生"的时刻上。
离散事件仿真给出的答案非常干脆:根本不需要按固定节拍推进时间,只需要维护一张"下一个有意义时刻"的待办表(事件队列),然后把时间直接跳到表中最早的那个事件时刻。 世界只在"有事发生"的瞬间才被更新,两个相邻事件之间的时间会被一次性跳过。这样,毫秒级的导弹细节和秒级的雷达扫描可以在同一条时间轴上和平共处,而算力只花在真正发生变化的瞬间。
这套思路带来三个在整个架构中反复出现的特性:
- 时间跳跃(time advance)------时钟不再是被"步进"的,而是被"跳转"到下一个事件时间。
- 因果顺序------事件严格按时间排序派发,同刻事件通过优先级与插入顺序消歧,从而保证结果可复现。
- 回调驱动------框架在关键时刻"通知"外界,而外界无需修改框架本身(这直接催生了第 3.9 节的观察者机制)。
理解这三条,就理解了 AFSIM 内核的全部动机。下面进入实现。
3.2 WsfSimulation:一台显式状态机
内核的顶层对象是 WsfSimulation,定义在 WsfSimulation.hpp。它的类注释开门见山地列出了职责:维护事件队列、监管平台的增删、协调平台系统的开与关、维护仿真观察者。注意这四条没有一个涉及"运动方程"或"传感器物理"------WsfSimulation 是调度者 ,不是物理引擎。
状态枚举
WsfSimulation 用一个显式状态机来表达生命周期,State 枚举的七个取值构成一条单向主干:
| 状态 | 含义 |
|---|---|
cPENDING_INITIALIZE |
对象已构造,等待调用 Initialize() |
cINITIALIZING |
Initialize() 正在执行 |
cPENDING_START |
初始化完成,等待调用 Start() |
cSTARTING |
Start() 正在执行 |
cACTIVE |
仿真运行中 |
cPENDING_COMPLETE |
处理已结束,等待调用 Complete() |
cCOMPLETE |
仿真已结束 |
把"正在执行某方法"(cINITIALIZING、cSTARTING)也纳入状态,是一个值得注意的细节:它让框架能够检查出"初始化过程中重入初始化"这类非法调用,也解释了为什么 Start() 在状态不合法时会抛出 StartError 异常。
完成原因
与状态并列的还有 CompletionReason,用于记录"为什么结束":
cNONE:未完成(默认值);cEND_TIME_REACHED:到达设定结束时间;cRESET:被RequestReset()请求复位;cTERMINATE:被RequestTermination()请求终止;cOTHER:留给派生类自定义。
GetCompletionReason() 与 GetCompletionReasonString() 让外部(尤其是脚本与日志系统)能够区分"正常跑完"和"被人为掐断",这在蒙特卡洛批量运行中非常关键。
生命周期方法与主循环
WsfSimulation 暴露的生命周期方法可以归为三类:
- 准备 :
Initialize()(加载平台、建立组件、初始化观察者)、PrepareSimulation(); - 推进 :
AdvanceTime()(推进到下一个事件时刻)、Pause()/Resume()、WaitForAdvanceTime(); - 收尾 :
Complete(double aSimTime)、RequestReset()、RequestTermination()。
头文件里给出了一个极其精简的主循环示例(在该类的文档注释中):只要 IsActive() 为真就不断 AdvanceTime(),循环退出后再调用 Complete(GetEndTime())。这里的 IsActive() 只是 mState == cACTIVE 的判断,所以整个仿真的"心跳"就是这一句 while (IsActive()) AdvanceTime();。
那么 AdvanceTime() 内部到底发生了什么?从 WsfSimulation.cpp 的实现可以读出一条清晰的主线:
- 从事件管理器
PeekEvent()取出下一个事件的时刻;若队列为空,则把仿真时间设为"结束时间 + 0.1"(一个刻意的越界值,用以触发完成); - 把该时间交给时钟源
mClockSourcePtr->GetClock()做覆盖校正------这一步是实时仿真能成立的关键(见 3.6); - 触发观察者的
AdvanceTime回调,对外广播"时间将推进到此处"; - 如果新时间已越过
mEndTime,把状态改为cPENDING_COMPLETE; - 调用
DispatchEvents(mSimTime)派发所有时间小于等于当前时刻的事件。
可以看到,AdvanceTime() 本身就是"取下一个事件时刻 → 广播 → 派发"的三段式。这里有一个很漂亮的设计:时间推进与事件派发是同一件事。在事件驱动的世界里,"推进时间"并不意味着时间流逝,而意味着"把时间游标挪到下一个事件上,并执行它"。不存在"空转"。
Start() 的实现同样值得一读:它要求当前状态是 cPENDING_START 或 cCOMPLETE,否则抛异常;随后把状态切到 cSTARTING,复位并启动时钟(若为外部启动则不启动时钟),依次通知所有扩展与观察者,最后原子地切到 cACTIVE。Pause()/Resume() 则非常轻------它们只是停止或重启时钟源;在事件驱动模式下,这等价于"暂停时间游标的推进"。RequestReset() 与 RequestTermination() 都只是把状态置为 cPENDING_COMPLETE 并记录完成原因,真正的收尾交给主循环之后的 Complete()。
3.3 事件模型:WsfEvent、一次性与周期性事件
事件队列里装的东西是 WsfEvent,定义在 WsfEvent.hpp。它是一个极简的抽象基类,核心只有一个纯虚方法:
Execute() 返回一个 EventDisposition 枚举,只有两个取值------cDELETE(执行后从队列删除)与 cRESCHEDULE(执行后被重新排入队列)。这个返回值设计很不寻常,却非常聪明:它把"是否重复执行"的决定权交给了事件自身。周期性任务(比如每 10 秒一次的传感器扫描)在 Execute() 里算出下一次时间并返回 cRESCHEDULE,同一块内存被反复复用,避免了"删除旧事件、分配新事件"的高频堆操作。头文件注释明确写到了这一点:这样做是为了防止周期性事件在删除与重新分配之间来回抖动。
WsfEvent 携带的元数据有三组:
- 时间 :
GetTime()/SetTime(),即该事件应当被派发的仿真时刻。注意注释中的警告------事件已经在队列里时绝不能改时间,否则它不会在正确位置重新排序。 - 同刻优先级 :
GetPriority()/SetPriority(),默认值为 0。时间相同的事件按优先级排序;优先级也只在入队那一刻生效。 - 可执行标志 :
ShouldExecute()/SetShouldExecute()。这提供了一个"软取消"机制------事件仍在队列里占位,但派发时会被跳过,从而避免了从堆里删除元素的代价。
此外,事件在被加入某个仿真时会通过 AddedToEventQueue() 拿到所属仿真的指针(GetSimulation()),这让事件在 Execute() 里能够访问全局的仿真上下文。
用 std::function 简化事件
手写一个 WsfEvent 子类只为了包一段逻辑,是很啰嗦的。为此框架提供了两个适配器:
WsfOneShotEvent:构造时传入一个std::function<void()>,Execute()调用它并返回cDELETE------典型的一次性事件(比如"5 秒后引爆")。WsfRecurringEvent:传入一个std::function<EventDisposition(WsfEvent&)>,Execute()把事件自身作为引用传进去,回调内部可以调用SetTime()重新安排时间并返回cRESCHEDULE------典型的周期性事件。
这两个类的底层是一个类模板 WsfEventAdapterT,它把 std::function 适配成 WsfEvent 接口。这是 C++11 之后框架现代化改造的一个缩影:核心接口保持面向对象的抽象,而使用侧提供了函数式的便捷入口。
仿真时间事件与墙钟事件
WsfSimulation 提供了两组入队接口:AddEvent(仿真时间事件)与 AddWallEvent(墙钟事件),并分别配了模板辅助函数 AddEventT / AddWallEventT。模板版本内部用 static_assert 强制类型必须派生自 WsfEvent,构造后返回一个非拥有 的裸指针,便于调用方在入队后仍能引用该事件(例如之后调用 SetShouldExecute(false) 来取消它)。
这两类事件的分离对应着 WsfSimulation 内部的两个事件管理器成员:mEventManager 与 mWallEventManager。前者按仿真时间排序,后者按真实墙钟排序。它们的用途截然不同------仿真时间事件是"想定世界里的因果",墙钟事件是"现实世界里的时序",比如周期性向外部接口推送状态、或人在回路场景中的交互定时。
3.4 事件调度:优先队列与稳定排序
事件管理器 WsfEventManager(WsfEventManager.hpp)是一台"时间有序的优先队列"。它的排序键是一个三元组,由 Event::Key 定义:
- 仿真时间 (
double,主键,越小越先派发); - 优先级 (
int,同刻时的次键,越小越先派发); - 自增计数器 (
unsigned int,时间和优先级都相同时的末键,保证先入先出)。
底层用 std::priority_queue 配合 std::greater 比较器,使最小的元素浮到顶部。第三个键------那个自增计数器------是"稳定排序"的关键:如果只有时间和优先级两个键,那么同刻同优先级的事件在堆里的相对顺序是不确定的 (堆排序本身不稳定),同一份想定跑两次可能得到不同结果。加入单调递增的计数器后,同刻同优先级的事件严格按入队顺序派发,仿真结果因此可复现。这是 DES 里一个看似微小、实则生死攸关的设计。
WsfEventManager 的对外接口只有四个动作:AddEvent()(接管所有权)、PeekEvent()(看一眼队首但不弹出)、PopEvent()(弹出并转移所有权)、Reset()(清空)。内部用一把 std::recursive_mutex 保护,因为事件可能从工作线程里被加入。
框架还提供了一个历史变体 WsfStableOrderEventManager(WsfStableOrderEventManager.hpp)。它用一对 (WsfEvent*, 计数) 作为队列元素,比较器先比时间、再比计数,同样保证"同刻事件按插入顺序派发"。它的接口签名使用裸指针而非 unique_ptr(更早期的 API 风格),并在注释里明确声明"相同时刻的事件会 按顺序派发"。可以把 WsfEventManager 看作经过现代化改造、采用移动语义的默认实现,而 WsfStableOrderEventManager 保留了老式接口。二者共享同一个核心思想:时间 → 优先级 → 插入序的三级消歧。
3.5 两种仿真执行官:事件步进 vs 帧步进
WsfSimulation 是基类,真正决定"时间如何推进"的是它的两个派生类,二者对应两种截然不同的"执行官(simulation executive)"。
事件步进:WsfEventStepSimulation
WsfEventStepSimulation.hpp 是默认 且推荐的执行官。它完全遵循 3.2 节描述的主循环:每次都把时间跳到下一个事件的时刻并派发。它是"纯事件驱动"的化身,时间推进与事件派发完全合一。
在默认配置下,它运行在非实时 模式------尽可能快地把事件派发完,仿真时间跑得比墙钟快得多。想定里若需要实时(比如接入 DIS 演练、人在回路),则要在输入里显式加入 realtime 命令。
帧步进:WsfFrameStepSimulation
WsfFrameStepSimulation.hpp 则代表另一种哲学:按固定帧 推进。它重写了 AdvanceTime(),内部通过 AdvanceFrame() 推进"一帧";它维护帧计数 mFrameCount,并挂了一个 AdvanceFrameObjects 回调列表,在每帧派发时通知各帧对象更新。头文件里还保留了一组帧统计量------超时时间累计、欠时/超时计数、跳帧数、最差超时------这说明它天然地关心"帧预算":一帧的工作量必须在帧时长内完成。
值得一提的是,WsfFrameStepSimulation 的类注释坦白它"只是一个帧步进的示例 ,可作为实现自定义帧步进仿真的指南"。这暗示了 AFSIM 的立场:事件步进才是主航道,帧步进更多是兼容与教学用途。
命令行映射
这两种执行官的选择直接映射到 mission 可执行程序的命令行参数,见 mission.rst:
| 参数 | 执行官 | 实时性 | 说明 |
|---|---|---|---|
-es |
事件步进 | 非实时(默认) | 默认选项;要实时需再加 realtime 命令 |
-rt |
帧步进 | 实时 | 官方明确不推荐,见下 |
-fs |
帧步进 | 非实时 | 等价于 -rt + 输入中加 non-realtime |
文档里有一句非常直白的警告:-rt 除了最简单的情形外不推荐使用 ,因为"当前的帧步进执行官无法优雅地处理 broken frame(破帧)"。所谓破帧,是指一帧的计算没能在一帧的墙钟时间内算完,导致后续帧被挤压、时间轴断裂。官方给出的建议是:要实时,就用事件步进(-es)再加 realtime 命令,而不是用帧步进。这条建议本身就说明了事件驱动架构在实时约束下的鲁棒性------它不需要"每帧必须在预算内完成"这种脆弱的假设。
此外文档还列出两个与时间推进体验相关的选项:-fio(每次推进都刷新标准输出,避免输出缓冲导致的"卡住感")与 -mi <interval>(按给定时间间隔打印时间推进消息)。这些看似琐碎,实际是长时间批处理仿真时的可观测性手段。
3.6 时间管理:仿真钟与墙钟的分离
事件内核里最容易混淆、也最精妙的一处,是仿真时间(simulation time)与墙钟时间(wall-clock time)的彻底分离。
- 仿真时间是"想定世界里"的时间,从 0 开始,由事件队列驱动,可以跳、可以停、可以快进;
- 墙钟时间是"现实世界里"的时间,由操作系统提供,永远匀速流逝。
框架用一个抽象类 WsfClockSource(WsfClockSource.hpp)来封装"仿真时钟"。它的接口包括 GetClock()/SetClock()/ResetClock()、StartClock()/StopClock()、SetClockRate()/GetClockRate(),以及 IsStopped()。默认的 mClockRate 是 1.0,即仿真时间与真实时间 1:1。SetMaximumClock() 设了一个上界(源码里是一个神秘的 1E300 魔数,注释坦承其来历不明,怀疑应当用 numeric_limits::max()------这类"考古现场"在本仓库里并不罕见)。
真正体现"实时"语义的是派生类 WsfRealTimeClockSource(WsfRealTimeClockSource.hpp)。它内部持有一个 UtWallClock,记录"上次启动时钟至今流逝的真实 时间",同时用 mTimeAccumulated 记录"停止时累积的仿真 时间"。当仿真处于运行状态时,当前仿真时间就等于"累积仿真时间 + 流逝的真实时间 × 倍率"。这样,仿真时间就被锚定在真实时间上了。
回到 3.2 节的 AdvanceTime():它算出下一个事件时刻后,会调用 mClockSourcePtr->GetClock(mSimTime) 让时钟源覆盖 这个时间。在非实时模式下,这个调用基本原样返回(仿真时间自由奔跑);在实时模式下,它会把仿真时间对齐到墙钟------如果仿真算得比真实时间快,就停下来等 ;如果算得慢,就记录"落后量" mTimeBehind。
这套机制是两种运行模式的物理基础:
- 非实时(non-realtime):尽可能快地派发事件,仿真时间远超墙钟。适合纯构造性推演与批量蒙特卡洛,追求吞吐。
- 实时(realtime) :仿真时间与墙钟同步。这是人在回路(human-in-the-loop)与 DIS 分布式演练的硬性前提------因为真实的人类操作员和外部 DIS 节点都在按真实时钟行动,仿真绝不能跑到它们前面去。
WsfSimulation 里还维护了 GetRealTime()(最近采样的真实时钟)、GetWallTime()(自仿真创建以来的墙钟)、GetTimeBehind()(落后于实时的量)。后者被明确定位为一个"提示":当仿真落后太多时,各组件可以据此主动降低工作量。IsFlexibleRealtime() 则是一个调试用的开关------允许实时仿真在调试时"不必死守实时约束",否则断点一停,仿真就会疯狂追赶。
3.7 线程模型:一种克制的并行
一个自然的疑问是:既然是高性能军事仿真,AFSIM 是不是把一切都并行了?答案是不 。AFSIM 的线程设计相当克制,其目标不是"全并行",而是把可以独立计算的更新并行化。
线程基础设施分三层:
-
WsfThread(WsfThread.hpp):单个工作线程的抽象。它用一个FunctionType枚举表达线程状态(AVAILABLE表示空闲可用,还有ASSIGNED、STOPPED、PAUSED等)。子类必须实现DoWork()(线程的工作函数)与Pause(),而AssignWork()/StopWork()负责状态迁移。这套状态机的意义在于:外部可以查询线程是否"空闲",从而决定是否派活。 -
WsfThreadPool(WsfThreadPool.hpp):一个类模板 线程池,WsfThreadPool<Worker, WorkerFactory>。它提供Start(aNumThreads)/Stop()、AssignWork(aNumTasks)(唤醒指定数量的线程,传 0 表示全部唤醒)、FindAvailableThread()(找第一个空闲线程,找不到就 sleep 一小段再找)、AllWorkDone()/WaitUntilAllWorkDone()/TryWaitUntilAllWorkDone()。注意它是模板而非多态设计------工作线程的类型与工厂在编译期确定,省去了虚函数开销。 -
WsfMultiThreadManager(WsfMultiThreadManager.hpp):把线程池真正用起来的地方。它的注释直说了职责:"当前实现允许对平台(即运动模型)与传感器进行线程化更新"。它维护一个平台队列与一个传感器优先队列(按下次更新时间排序),通过UpdatePlatforms(time)/UpdateSensors(time)把工作分派给SimulationUpdateThread。平台与传感器被分成mThreadedPlatforms与mNonThreadedPlatforms(mThreadedSensors/mNonThreadedSensors)两组------只有被标记为安全的对象才会被丢进线程池。
这个"只并行可独立的部分"的取舍,是理解 AFSIM 线程哲学的关键。为什么不做全并行?因为 DES 的因果一致性极其脆弱:两个事件如果在同一时刻相互依赖(A 的探测基于 B 的位置,B 的运动又受 A 的干扰影响),并行执行会产生竞态,且结果不再可复现。因此框架把并行限制在天然独立的计算上 ------每架飞机的运动学积分彼此无关,每个传感器对环境的采样彼此无关。至于跨对象的事件派发,仍然老老实实跑在主线程上、按时间顺序串行执行。这是一种典型的工程权衡:用可控的并行度,换回因果顺序与可复现性。
顺带一提,WsfSimulation 用 MultiThreadingActive() 暴露"多线程当前是否生效",并提供了 MultiThreaded() 配置项。仿真内部的关键共享资源------比如唯一 ID 分配器与随机数发生器------都用互斥锁(mUniqueIdMutex、mRandomMutex)保护,正是为多线程场景准备的。
3.8 随机数与可复现性
军事仿真的价值有一半建立在可复现上:同一个想定跑一百次,如果每次结果都不一样,你就无法区分"差异来自战术调整"还是"来自随机噪声"。因此随机性必须被严格管理。
AFSIM 的随机性有两个入口,对应两类用户:
-
脚本侧 :
WsfRandom.hpp(WsfRandom.hpp)定义了脚本类RandomClass,提供了一组极为齐全的概率分布方法:Uniform、Bernoulli、Binomial、NegativeBinomial、Geometric、Poisson、Exponential、Gamma、Weibull、ExtremeValue、Gaussian/Normal、LogNormal、ChiSquared、Cauchy、FisherF、StudentT、Rayleigh等,多数还提供多个重载(不同参数个数)。写脚本的用户可以直接调用这些方法抽取随机样本。 -
想定侧 :
WsfRandomVariable.hpp(WsfRandomVariable.hpp)定义了"随机变量"这一可配置对象。它支持cCONSTANT、cEXPONENTIAL、cLOG_NORMAL、cNORMAL、cUNIFORM五种分布,并通过Constraints(cNO_CONSTRAINT/cPOSITIVE/cNON_NEGATIVE)约束取值范围;ProcessInput()让它可以从想定文件里解析定义,Draw()抽取样本。它还有完整的脚本类封装WsfScriptRandomVariableClass,可以在脚本里Set/Draw/LastDraw。
底层真正产生随机数的引擎是 ut::Random(UtRandom.hpp)。这里有一个重要细节:WsfSimulation 持有两个独立的随机数发生器 ------mRandom 用于框架核心模型,mScriptRandom 用于用户脚本。头文件注释特意解释了为什么分开:"脚本不应从同一个池里抽随机数,从而影响仿真本身。" 这是一个深刻的隔离设计。如果没有这道隔离,一段用户脚本多抽了一个随机数,就会让此后整个想定的随机序列发生偏移,导致"改脚本的输出,却意外改变了物理结果"的诡异 bug。把两者分池,脚本的随机性变化就被限制在脚本自己的世界里。
可复现性最终落在两件事上:
- 种子(seed) :给定相同的随机种子,
ut::Random会产出完全相同的序列; - 蒙特卡洛迭代(run number) :
WsfSimulation的构造函数接收一个aRunNumber参数,GetRunNumber()/GetFinalRunNumber()暴露给外部。mission 文档明确把"纯构造性、含多次蒙特卡洛迭代"列为一种执行模式。
把"种子 + 运行号 + 按时间/优先级/插入序排定的事件队列"这三者放在一起看,就能理解 AFSIM 为什么敢于承诺可复现:几何决定论(事件排序)与受控随机性(种子)共同构成了一个封闭、确定、可重放的世界。
3.9 观察者:不改框架就能取数据
前面反复提到"框架在关键时刻通知外界"。这套机制就是观察者(Observer) ,位于 core/wsf/source/observer/ 目录下。
设计动机非常实际:仿真用户几乎总想"在某个事件发生时做点自己的事"------记录传感器何时开机、统计每次探测的几何、收集平台被击毁的时刻。如果没有观察者机制,用户只能去改核心源码,而这会让框架无法升级、无法维护。观察者把这些"外部关注点"从框架里彻底解耦出来:框架负责在正确的时刻"发广播",订阅者负责处理自己的业务。
机制
实现非常轻量。每个观察者对象内部持有一组回调列表(UtCallbackListN<void(...)>),订阅者通过 Connect(&MyObserver::Method, this) 挂上一个成员函数指针,并通过 UtCallbackHolder 管理生命周期。框架在事件点调用列表 operator()(...),所有订阅者被依次调用。WsfSimulationObserver.hpp(WsfSimulationObserver.hpp)的顶部注释给出了完整用法范式,并特别提醒:观察者通常应在 Initialize() 里注册,而不是在构造时------这样才不会把回调挂在"类型模板对象"上(第 2 章讲过的"类型对象"在初始化后才真正实例化)。
观察者家族
AFSIM 为几乎每一个有意义的对象都准备了观察者。WsfSimulation 的成员列表(见 WsfSimulation.hpp)里有一长串 mutable 的观察者对象:
| 观察者 | 关注的领域 |
|---|---|
WsfSimulationObserver |
仿真级里程碑:初始化、启动、暂停、恢复、完成、时间推进、时钟倍率、落后实时 |
WsfPlatformObserver |
平台的加入、删除、初始化、损坏 |
WsfPlatformPartObserver |
平台部件(通用)的状态变化 |
WsfSensorObserver |
传感器开关、探测尝试与结果 |
WsfMoverObserver |
运动模型更新 |
WsfCommObserver |
通信收发 |
WsfProcessorObserver |
处理器状态 |
WsfTrackObserver |
航迹的建立、更新、丢弃 |
WsfTaskObserver |
任务的分派与完成 |
WsfFuelObserver |
燃料状态 |
WsfZoneObserver |
区域的定义与进出 |
WsfExchangeObserver |
交战/交互 |
WsfDisObserver |
DIS 分布式接口 |
WsfBehaviorObserver / WsfAdvancedBehaviorObserver |
行为/状态机 |
WsfScriptStateMachineObserver |
脚本状态机 |
以 WsfSimulationObserver 为例,它暴露的回调覆盖了仿真的全部关键节点:AdvanceTime、SimulationStarting、SimulationPausing、SimulationResuming、SimulationComplete、SimulationClockRateChange、SimulationTimeBehind、FrameStarting/FrameComplete/FramePlatformsUpdated、ApplicationInitialize/ApplicationExit,以及帧步进专有的 RequestTimeAdvance。注意这些回调被猛地调用时机的注释------例如 SimulationComplete 会在"平台被清除之前"触发,此时"事件队列已被清空、不再派发任何事件"。这类时序约定对写输出插件的开发者至关重要。
观察者对象都声明为 mutable,源码给出的理由很优雅:观察者可以被修改(增删订阅者)却不改变仿真的逻辑状态,因此即使通过 const WsfSimulation& 也应能访问观察者。这是一种把"逻辑不可变"与"物理可变"区分开的成熟做法。
它支撑了什么
观察者机制是 AFSIM 生态里许多功能的共同底座:
- 事件日志 :想定里的
event_output/csv_event_output命令,本质是往观察者上挂订阅者,把回调数据写成文本; - 回放与可视化:Mystic、VESPA 等工具消费的记录文件同样来源于此;
- 分布式接口 :DIS 接口作为
WsfDisObserver的订阅者,把本地事件翻译成网络报文; - 自定义输出 :用户通过
observer命令或 Script Observer,用脚本订阅观察者并写自己的输出,完全不需要碰 C++。
换句话说,"不改框架就能取数据"不是一句口号,而是观察者 + 事件输出插件这一对组合拳的工程实现。 这也解释了为什么 WsfSimulation 的类注释把"维护仿真观察者"列为四大核心职责之一。
3.10 设计启示:一层"恰到好处"的事件抽象
把本章串起来看,AFSIM 内核最值得学习的,是它在事件驱动 与用户友好之间找到的那个平衡点。
底层是彻头彻尾的事件驱动:WsfEvent、事件队列、优先级、时钟源、观察者------冰冷、精确、可复现。但用户几乎从不需要直接接触 WsfEvent 。他们写的是 on_update、at_time、execute 这类更贴近意图的概念(详情见第 5 章)。框架内部把这些用户意图翻译成定时的事件,塞进队列,然后在正确时刻回调用户代码。用户关心的"每 5 秒做一次判断",在框架里就是"一个 cRESCHEDULE 的周期性事件"。
这层"事件抽象"的价值有三:
- 认知负荷:用户不需要理解优先队列、稳定排序、仿真钟与墙钟的差异,只需声明"何时做、做什么";
- 性能透明:事件跳跃让时间尺度极不均匀的想定也能高效推进,而用户无需为"该用多大步长"做任何决策;
- 可复现与可观测:事件排序保证了几何确定性,随机种子保证了数值确定性,观察者保证了外部可观测------三者共同构成了仿真结果可信的基础。
还有一个反直觉但重要的观察:AFSIM 的并行是"保守"的 。它没有追求把一切 Update() 都塞进线程池,而是只并行天然独立的部分;它也没有选择帧步进实时这条"看起来更简单"的路,反而明确不推荐它、坚持用事件步进加实时钟来满足实时需求。这种克制背后是对仿真本质的尊重------仿真的产出是"可信的因果链",任何可能破坏因果确定性的性能优化,都必须让位于正确性。
至此,我们已经看清了"驱动仿真向前"的全部机关。但所有这些机关都还没来得及"启动"------平台、组件、事件,究竟是从哪来的?答案在第 4 章:输入系统与语法解析。想定文件里的每一行文字,最终都会被翻译成本章里这些 WsfSimulation、WsfEvent 与组件对象。
本章小结
- AFSIM 采用离散事件仿真作为内核:不按固定节拍推进,而是维护事件队列、把时间直接跳到下一个事件时刻,从而让毫秒级与秒级的时间尺度在同一时间轴上高效共存。
WsfSimulation(WsfSimulation.hpp)是一台显式状态机,State有七个取值(cPENDING_INITIALIZE→ ... →cCOMPLETE),CompletionReason记录结束原因;主循环是while (IsActive()) AdvanceTime();,收尾调用Complete(GetEndTime())。AdvanceTime()的基本流程是:取下一个事件时刻 → 由时钟源校正 → 广播观察者 → 越过结束时间则置cPENDING_COMPLETE→ 派发事件。时间推进与事件派发合二为一。- 事件基类
WsfEvent(WsfEvent.hpp)的Execute()返回cDELETE或cRESCHEDULE,周期性事件借此复用同一对象、避免高频堆操作;WsfOneShotEvent/WsfRecurringEvent用std::function简化了事件的创建。 - 事件管理器
WsfEventManager(WsfEventManager.hpp)用时间 → 优先级 → 插入计数器 三元组排序,保证同刻同优先级事件的稳定(先入先出)顺序,这是结果可复现的基础;WsfStableOrderEventManager是保留旧式接口的同源实现。 - 事件分"仿真时间事件"(
AddEvent/AddEventT)与"墙钟事件"(AddWallEvent/AddWallEventT),对应两个独立的事件管理器。 - 两种执行官:默认的事件步进
WsfEventStepSimulation(WsfEventStepSimulation.hpp)与帧步进WsfFrameStepSimulation(WsfFrameStepSimulation.hpp)。命令行-es(默认)、-rt、-fs对应选择;官方明确不推荐帧步进实时(破帧问题),建议用事件步进 +realtime。 - 时间管理上,仿真钟与墙钟彻底分离 :
WsfClockSource/WsfRealTimeClockSource封装时钟源,实时模式下把仿真时间锚定到墙钟。非实时追求吞吐,实时是人在回路与 DIS 演练的前提。 - 线程模型克制 :
WsfThread→WsfThreadPool<Worker, WorkerFactory>→WsfMultiThreadManager,只并行天然独立的运动学与传感器更新,跨对象的事件派发仍串行,以保住因果顺序。 - 随机性分两池:
mRandom(核心模型)与mScriptRandom(用户脚本)隔离,避免脚本扰动仿真结果;配合种子与蒙特卡洛运行号,实现可复现。 - 观察者(
core/wsf/source/observer/)是发布订阅机制,覆盖仿真、平台、传感器、航迹、燃料、区域、DIS 等几乎所有对象,支撑事件日志、回放、分布式接口与自定义输出,是"不改框架就能取数据"的实现基础。 - 设计启示 :AFSIM 底层是纯事件驱动,但对用户隐藏了事件细节(用户用
on_update、at_time等概念,见第 5 章);它以"保守的并行"和"放弃帧步进实时"为代价,换回因果确定性、可复现性与外部可观测性------这正是仿真结果可信的根基。
第 4 章 · 输入系统与语法解析:从文本想定到内存对象
如果说仿真内核(第 3 章)回答的是"时间如何流动、事件如何调度",那么本章要回答的是一个更前置的问题:仿真开始时,那一大堆平台、传感器、武器、脚本,究竟是怎么从一份纯文本的想定文件,变成内存里活生生的对象图的? AFSIM 给出的答案是:自造一门领域专用语言(DSL),用一整套自定义的语法文件描述"用户可以写哪些命令",再配上一套手写的解析器框架和类型工厂,把命令逐条翻译成对象属性。本章我们就顺着"文本 → 词法 → 语法 → 语义动作 → 类型工厂 → 内存对象"这条链路,把 AFSIM 的输入系统拆开来看。
4.1 为什么要自造一门 DSL
AFSIM 的输入并不是 XML、JSON,也不是某种通用脚本语言,而是一门自定义的、以空白分隔 token 的命令式语言,社区和文档里常称之为 WSF 语言(WSF Reference Guide 中的各类"命令"就是这门语言的词汇)。想定文件、类型定义文件、地形引用、脚本片段,全都用这一套语法书写。
为什么军事仿真框架要费这么大劲自造一门语言?理由其实非常工程化:
- 文本即数据,友好于版本控制。平台编队、传感器参数、交战规则都是纯文本,可以放进 Git,做 diff、做 code review,参数改动一目了然。相比二进制场景文件,文本的可审计性对军事/安全领域尤其重要。
- 可批处理、可脚本化生成。想定可以通过 AFSIM 自己的脚本、Python、或者外部模板引擎批量生成------同一份"模板"改几个参数就能派生上百个实验想定。这在大规模蒙特卡洛分析里是刚需。
- 可读性逼近"自然表达" 。
platform、sensor、mover这些块结构,工程师读起来几乎像伪代码,学习成本低于晦涩的二进制格式或臃肿的 UML 模型。 - 领域可扩展。每个功能插件都能贡献自己的"命令词汇",核心框架不必预知所有领域概念。
代价同样明显:你需要自己实现词法分析、语法分析、错误恢复、语法校验工具链。AFSIM 选择承担这个成本,换来的是极强的数据驱动表达能力------这正是它作为"框架/平台"而非"某个具体模型"的立身之本。
4.2 语法文件 .ag:把"能写什么"形式化
AFSIM 的输入语言规则不是硬编码在 C++ 里的,而是写在一系列 .ag(AFSIM Grammar)文件中。这些文件回答三个问题:用户可以写哪些命令(rule)、命令产生什么值(value)、值如何组织成结构(struct) ,以及读到命令后如何赋值(action)。
语法文件内部又分两层:
第一层是"元语法"。 AFSIM 自己先用一套 Coco/R 风格的语法定义来描述".ag 文件本身长什么样",这就是 AfGrammar.atg。文件头的注释写得很直白:Coco/R 处理这个输入文件,生成 Parser.cpp / Scanner.cpp 等代码(即 WsfGrammar 命名空间下的解析器)。也就是说,先有一次代码生成,把"语法文件的语法"编译成一个能读懂 .ag 的解析器 。AFSIM 对 Coco/R 的使用是克制的,AfGrammar.atg 里明确写道这是"一个非常宽松的 WSF 语法格式定义",真正的语义还要在 WsfParseDefinitions 里进一步处理。
第二层是领域语法。 各个模块贡献自己的 .ag:核心输入语言在 wsf.ag,军事领域扩展在 core/wsf_mil/grammar/wsf_mil.ag,此外还有太空、赛博、Link-16 等模块目录下的 grammar 文件夹(如 core/wsf_space/grammar/wsf_space.ag、core/wsf_cyber/grammar/wsf_cyber.ag、core/wsf_l16/grammar/wsf_l16.ag),以及大量插件目录下的 grammar/*.ag(wsf_plugins/ 下可见数十个)。这种"核心 + 模块"的语法组织方式,和 AFSIM 的插件化架构是一脉相承的。
wsf.ag 的头部注释其实就是一份微型的语言设计说明书,它定义了四种基本构件(这是理解整个输入系统语义的钥匙):
- rule :纯粹匹配文本的规则,不直接映射数据值。形式是
(rule Name { 语法定义 })。 - value :产生一个原子值的规则,例如
Length。被别的规则引用时,它"返回"读到的值。语法里写作<ValueName>,可用于类型检查。 - struct :由若干
(var 类型 变量名)成员变量和子规则组成的结构体。struct 被引用时按引用 操作,会把匹配到的文本写入这些变量。若只想匹配文本、不存值,可用<:StructName>简写(等价于[skip()] <StructName>)。 - action :写在方括号
[ ... ]里的语义动作,用来把解析结果赋值给变量。它出现在规则前后,按出现顺序执行,并且依赖一个"当前值"(current value)。wsf.ag头部列出了可用的动作函数,例如skip()、push(varName)、copy(dest,source)、new(mapVariable,EntryName)、delete(mapVariable,EntryName)、pushBack(listName)、apply($$)等。
此外 wsf.ag 还定义了嵌套规则(nested rule,缩小名字作用域,可用 <ExternalLink.link-command> 引用)、枚举(enumeration,把文本与整数成对绑定,如 BeamSteering 把 azimuth 映射为 1),以及若干简写语法。开头还有一行 (grammar-version 2.2.0),说明这套语法文件格式本身是带版本号、持续演进的。
一句话总结:.ag 是一份"数据驱动的解析规格",它同时承载了词法模板、语法结构、类型系统和语义赋值四件事。这是 AFSIM 输入系统最核心的设计取舍。
4.3 解析器框架 core/wsf_parser
.ag 只描述"应该怎么解析",真正干活的是 core/wsf_parser 目录下的解析器框架。这套框架不是一个自动生成的 LL(1) 解析器,而是手写规则对象 + 运行期组装 的混合体,主要角色有四个:WsfParser、WsfParseDefinitions、WsfParseNode、WsfGrammarInterface,外加索引对象 WsfParseIndex 和源提供者 WsfParseSourceProvider。
4.3.1 WsfParser:面向词法的逐词读取器
WsfParser.hpp 里的类注释非常准确:"WsfParser acts as a lexer for wsf core files. Tokens are produced delimited by whitespace." ------它首先是个词法读取器,token 以空白为界;同时允许通过 UndoRead() 做有限回溯。
从构造函数(见 WsfParser.cpp)可以看出它的依赖脉络:接收一个 WsfParseSourceProvider(负责按路径找到并读取源文件)、一个工作目录、以及可选的 WsfParseDefinitions(若为空则自建一个)。它还维护一个固定大小的环形缓冲区 mRecall 用于回溯,并创建一个 WsfParseIndex(带源文件、include 路径等的解析数据索引)。
最有意思的是 ReadWord() 的实现。它在读取下一个词之后,会检查这个词前三个字符是不是 inc------这是一个廉价的快速预判 ,用来避免每个词都做完整字符串比较。如果是 include 或 include_once,它会继续读出文件名,调用 PushSource() 把被包含的文件压入源栈,然后从回溯缓冲里"抹掉"这两个词,继续读下一条命令。也就是说,文件包含是在词法层展开的 :include 不是一条有语义的命令,而是解析器的一个内建预处理动作------include_once 与 include 的差异体现在 PushSource 的 aForceInclude 参数上。想定可以通过 mIgnoreIncludes 关闭 include 处理(例如做纯语法检查时)。
此外 WsfParser 还提供 ReadTerminator()(读取直到遇到指定终结符)、PartialMatch()(记录部分匹配,供错误恢复使用)、AddError()(收集解析错误)等接口。它严格来说不是"完整解析器",而是一个带回溯能力的 token 流 + 错误收集器,真正的语法匹配由规则对象完成。
4.3.2 WsfParseDefinitions:基础语法元素的登记处
WsfParseDefinitions.cpp 的构造函数干的事非常明确:把一批"基础类型规则"注册进全局规则字典。我们能看到它登记的词汇包括:
string、quotable-string(带引号的字符串)、line-string、string-except(带例外词的正则匹配字符串);file-reference、real、integer;typename(带cLAZY_TYPENAME_NODE标志,惰性求值的类型名)、typeref(带cLAZY_TYPE_REFERENCE_NODE,惰性类型引用)、name(带cNAMED_NODE);Error(带cERROR_INCOMPLETE,用于不完整匹配的错误节点);TypeCommand(类型命令);- 以及三个脚本相关规则:
ScriptFunctionBlock、ScriptBlock、ScriptVariables------对应想定里的函数脚本块、隐式函数脚本块和script_variables变量声明块。
这些基础规则是"预置词汇"。真正把 .ag 文本变成解析规则的入口是 AddGrammar():它把整个 .ag 文件读成字符串,交给 WsfGrammar::Scanner 词法扫描、WsfGrammar::Parser 语法解析,调用 parser.Parse(),然后把解析出的根规则 mRootRule 换入成员 mInputGrammar;若产生错误,则通过 AddDefinitionError() 逐条报出。这正是"元语法代码生成"的落地时刻 ------AfGrammar.atg 生成的 WsfGrammar::Parser 在这里被驱动,去读懂各个模块的 .ag。
WsfParseDefinitions 还保存根规则指针、根 struct 指针、模板化类型表(mTemplatedTypes),以及 GetInputGrammar() 之类的访问器,是整套解析规格的容器。
4.3.3 WsfParseNode:解析树节点
解析的产物是一棵 WsfParseNode 组成的树。看 WsfParseNode.hpp,节点通过 mParent / mDown / mLeft / mRight 组成一种**"向下是子、向右是兄弟"**的树形结构(与经典 AST 表示一致)。每个节点带一个字符串字段 mType(描述节点种类,如 real 或用户定义的 platform-part-command)、一个规则指针 mRulePtr,以及叶子节点保存文本范围的 mValue。
节点的关键信息浓缩在 Flags 位标志里,这些标志直接对应本章前面讲过的语言概念:
cBLOCK_NODE、cBLOCK_START、cBLOCK_END------块结构的边界;cSCRIPT_BLOCK、cSCRIPT_FUNCTION、cSCRIPT_VARIABLES------脚本块、脚本函数、脚本变量;cTYPE_NAME_NODE、cTYPE_REFERENCE_NODE、cLOAD_TYPE_NODE及其惰性变体cLAZY_*------类型名/类型引用的延迟解析;cNAMED_NODE------命名节点;cERROR_INCOMPLETE------部分匹配(用于错误恢复);cPRUNE_NODE------仅用于序列化时裁剪。
WsfParseNode.cpp 提供了树的遍历与打印基建:Add()(把子节点接到 mDown 链尾)、Next()(带可选的"是否下钻"参数的深度优先遍历)、NextLeaf() / PreviousLeaf()(跳过无值节点找叶子)、GetNodeString()(把一串子节点的文本拼成字符串,注释坦白这是给某些工具类读值的"quick hack")、以及 DebugPrint()。节点还有一个 Print(std::ostream&, int aIndent, bool aVerbose)------可以把整棵解析树打印出来,这是调试语法的第一手工具。
值得注意的是 WsfParseNode 的 mType 与标志组合,让"类型名"和"类型引用"可以惰性求值 :解析时先记住一个符号,等到真正需要(比如加载某个 platform_type)时才去类型表里查。这解决了一个现实问题------.ag 里引用的类型可能定义在后面 的文件或别的模块里。
4.4 从命令到对象属性:ProcessInput 与语义动作
解析出语法树只是第一步。真正把命令变成对象属性的机制是双向的:
一方面是 .ag 里的语义动作(action) :在方括号里写 [myVar=$1]、[debugIsOn=true] 之类,把读到的值赋给"当前值"的成员。wsf.ag 头部详细说明了这套动作模型------它有一个"当前值"概念,push() 切换当前值指向哪个变量,new() / pushBack() 在 map / list 上追加条目,apply($$) 把刚刚 new/load 出来的代理值应用上去。注释里那个 MyStruct 的例子展示了完整的赋值流程:begin_links [push(links)] <InternalLinks> end_links [linksSet=true]------先推进到 links 变量,用子规则填它,再切回来设置 linksSet。这就是声明式的"语法 → 数据"映射。
另一方面,很多东西无法、也不适合用纯声明式动作表达------比如构造一个传感器、初始化一个移动模型。AFSIM 的做法是让 C++ 对象自己实现一个标准的 ProcessInput(UtInput&) 方法,在方法内部用 if/else if 逐条匹配命令并设置成员。看 WsfPlatform.cpp 里的 WsfPlatform::ProcessInput:拿到命令字符串后,side 读一个字符串写进 mSide,icon 写 mIcon,marking 写 mMarking,indestructible / destructible 翻转 mIsIndestructible,on_broken 读动作名再映射为 cON_BROKEN_REMOVE / cON_BROKEN_DISABLE。这是一种典型的命令分发(command dispatch)模式。
(这里有个容易误解的点:WsfGrammarCheck 源码里明确写着"WSF does not rely on the grammar or the wsf_parser library to parse input"------运行时的想定加载主要走 UtInput + 各对象的 ProcessInput 这条命令式路径;.ag/core/wsf_parser 更多用于语法校验、IDE 辅助和文档。两套机制共享同一门语言,但承担不同职责,这也是为什么语法检查工具是独立存在的。理解这一点,才能理解 AFSIM 输入系统的真实分工。)
4.5 类型工厂与类型列表:*Types 的魔法
想定里随处可见这样的写法:platform X WSF_PLATFORM、sensor S WSF_RADAR_SENSOR。这条语句背后的机制是 "类型列表 + 克隆工厂"。
关键类是 WsfObjectTypeList.hpp,它的类注释把设计意图讲得很清楚:一个"类型对象"是可以被克隆出具体实例 的对象;例如一个 platform_type 叫 Honda_Civic,把它加入类型列表后,就可以克隆它的实例加入仿真。也就是说,类型列表既是"按名字造对象"的工厂,也是"所有类型定义"的注册表。
具体的类型列表是一个个薄薄的子类,例如 WsfPlatformTypes.hpp 里的 WsfPlatformTypes : public WsfObjectTypeList<WsfPlatform>。它只提供三样东西:一个 Get(WsfScenario&) 静态方法(返回某想定关联的类型列表)、LoadType()、ProcessInput() 和 CompleteLoad() 的覆写。"平台类型是什么"这个领域逻辑,被收敛进一个极小的类 ,其余全由模板基类提供。同构地还有 WsfMoverTypes、WsfProcessorTypes、WsfSensorTypes、wsf::comm::Types 等等。
谁拥有这些类型列表?答案是 WsfScenario 。在 WsfScenario.cpp 里,CreateTypeList<T>() 被反复调用,一次性创建了 WsfPlatformTypes、WsfMoverTypes、WsfProcessorTypes、WsfSensorTypes、WsfZoneTypes、WsfAntennaPatternTypes 等几十个类型列表。想定(Scenario)是所有类型定义的所有者,这带来两个好处:
- 作用域清晰:类型定义的生命周期和想定绑定,多个想定互不污染;
- 数据驱动 :
type块定义"模板",platform块定义"实例",二者分离。新增一种传感器,往往只需写一个新类型定义 + 注册一个新*Types子类,无需改动核心。
sensor S WSF_RADAR_SENSOR 的执行语义因此可以概括为:用 WSF_RADAR_SENSOR 这个名字去 WsfSensorTypes 里查类型定义,克隆出一份,命名为 S,挂到平台 X 上 。这就是"数据驱动的仿真"在类型系统层面的落地------模板与实例分离,名字即工厂键。
4.6 一份想定文件长什么样
结合上面机制,一份典型 AFSIM 想定的结构要素可以概括如下(这里只做描述,不贴语法):
| 结构要素 | 作用 | 背后机制 |
|---|---|---|
platform 块 |
定义一个平台实例(名称 + 类型) | 查 WsfPlatformTypes,克隆实例 |
platform_type / type 块 |
定义可复用的平台/部件模板 | 注册进对应类型列表 |
mover / sensor / processor / comm |
平台的组成部分 | 各自的 *Types 类型列表 + 部件 ProcessInput |
weapon |
武器系统 | wsf_mil 领域类型列表 |
script / script_variables / on_initialize / on_update |
内嵌脚本与生命周期回调 | ScriptBlock / ScriptVariables 规则 + 脚本引擎(第 5 章) |
execute at_time |
定时执行动作 | 事件内核(第 3 章) |
include / include_once |
文件包含 | 解析器词法层的 PushSource 展开 |
zone / terrain |
区域与地形引用 | 相应类型列表 + 文件引用规则 |
在 wsf.ag 里能直接看到这些语法的影子,例如 platform <name> <typename> 的规则、platform_type 规则(内部会 (new (type platformType $1) ...) 创建类型条目)、execute <string> in <typeref>、以及 script-var 之类的声明。想定文件因此读起来像一份"对象图的文本快照":块 = 对象/作用域,命令 = 属性赋值,类型名 = 克隆来源。
4.7 语法检查与调试
写了这么复杂的语法,怎么保证 .ag 与 C++ 实现一致、想定文件没写错?AFSIM 提供了专门的工具:WsfGrammarCheck.cpp(core/wsf_grammar_check),它作为应用扩展 wsf_grammar_check 注册,校验 .ag 语法文件能否成功解析想定 ,解析错误打到控制台,还带行号定位(内部靠遍历文本统计换行数得到 GetLineNumber)。它甚至能校验"开发者是否为某功能补上了必要的 grammar"。
调试方面,WsfParseNode 的 Print() / DebugPrint() 能把解析树结构打印出来,配合节点的 mType 与标志位,可以快速定位某条命令为什么没匹配上、或匹配成了什么。WsfParser 的 PartialMatch() / AddError() 则支撑了"部分匹配 + 错误恢复"------即便想定里有语法错误,解析器也尽量继续往下走,一次性报出多个问题,而不是遇到第一个错误就崩溃。
4.8 设计启示
回看整个输入系统,有三点对架构师很有借鉴意义:
其一,DSL + 代码生成的权衡。 AFSIM 用 Coco/R 从 .atg 生成"语法文件的解析器",再用 .ag 描述领域语言,最终由运行期框架解析。这是一种"二次元语法"的巧妙分层:把语言定义本身也做成数据。收益是极致的表达力和领域可扩展性,成本是工具链(代码生成、语法检查、IDE 支持)需要长期维护。是否值得,取决于框架是否需要承载大量、持续增长的领域概念------AFSIM 显然需要。
其二,命令式 ProcessInput 的可扩展性。 让每个对象实现 ProcessInput 逐条匹配命令,看似"土",但极其好用:新增一条命令只需在一个类里加一个分支,不需要改中心化的巨型 switch,也不依赖自动生成的代码。配合类型工厂,一个插件作者可以完全独立地扩展语言词汇,这是开闭原则在输入系统上的朴素实现。
其三,类型工厂对"数据驱动仿真"的意义。 platform X WSF_PLATFORM 之所以能工作,是因为 *Types 把"名字 → 可克隆模板"的映射做成了基础设施。模板与实例的分离,让想定作者专注于"装配什么",而让框架与插件作者专注于"有哪些可装配的部件"。这种分层,正是 AFSIM 能成为一个平台、而非一个具体模型的根本原因。
理解了输入系统,我们就拥有了"想定如何变成对象图"的完整图景。下一章,我们将深入那些块里最灵活、也最危险的部分------script 块背后的 AFSIM 脚本引擎。
本章小结
- AFSIM 输入是自定义 DSL(WSF 语言),选择文本是为了版本控制、批处理、可读性与领域可扩展性。
- 语法由
.ag文件描述,采用"元语法(AfGrammar.atg经 Coco/R 生成解析器)+ 领域语法(wsf.ag及各模块.ag)"两层结构;rule/value/struct/action是四个核心构件。 - 解析框架在
core/wsf_parser:WsfParser是带回溯的逐词读取器,ReadWord在词法层处理include/include_once;WsfParseDefinitions登记基础规则并借AddGrammar()把.ag解析成规则;WsfParseNode以mDown/mRight构树,用cBLOCK_*/cSCRIPT_*/cLAZY_*等标志承载语义。 - "命令 → 属性"走两条路:
.ag的语义动作负责声明式赋值,对象自身的ProcessInput(UtInput&)负责命令式构造;运行时加载主要走ProcessInput,.ag/wsf_parser更多承担校验与辅助。 *Types(WsfPlatformTypes/WsfMoverTypes/WsfProcessorTypes/WsfSensorTypes/wsf::comm::Types等)是"按名字造对象"的工厂兼类型注册表,由WsfScenario通过CreateTypeList统一拥有;platform X WSF_PLATFORM即"查表 + 克隆"。wsf_grammar_check工具校验语法与输入,WsfParseNode::Print可打印解析树用于调试。- 设计启示:DSL 与代码生成以工具链成本换表达力;
ProcessInput命令分发带来朴素的开闭扩展性;类型工厂支撑了模板/实例分离的"数据驱动仿真"。
第 5 章 · AFSIM 脚本引擎:用脚本驱动仿真行为
AFSIM 最有辨识度的能力,不是它内置了多少种传感器或导弹模型,而是它把一门内嵌脚本语言放在了整个框架的"驾驶位"上------让不会写 C++ 的作战分析人员,也能直接定义平台的行为逻辑。这一章我们就拆开这台脚本引擎:它为什么存在、脚本在想定里长什么样、它是如何被编译进一套强类型系统的、以及脚本与 C++ 模型之间那条双向通道是怎么打通的。
5.1 为什么需要内嵌脚本语言
5.1.1 行为逻辑的"高频变更"困局
先做一个思想实验。在一个防空想定里,某型雷达平台需要在"发现三条以上航迹"时才向指控中心上报,并且上报间隔不得小于五秒。这本质上是一段很短的决策逻辑:读取本地航迹表、计数、比较阈值、查上一次上报时间、决定是否发消息。
如果这段逻辑只能用 C++ 实现,会发生什么?每一次阈值调整、每一条上报规则的微调,都要走一遍"改源码 → 重编译 → 重新链接 → 重新部署"的流程。对于一次持续数周、需要反复迭代参数的作战分析研究来说,这是不可接受的。更关键的是,能改这段逻辑的人必须是掌握 C++ 工具链和 AFSIM 内部 API 的开发者,而不是提出需求的作战分析人员。知识的拥有者和工具的拥有者被强行割裂了。
AFSIM 对这个问题给出的答案是:把"行为逻辑"从编译期搬到解释期。核心模型(运动学、电磁传播、传感器物理)继续用 C++ 写死,保证性能与数值稳定性;而决策、触发、条件判断、输出格式这些高频变更的部分,则用内嵌脚本表达。脚本改一行、重跑一次想定即可生效,无需触碰编译器。
5.1.2 官方定位:脚本是"基础设施",不是"一个功能"
理解 AFSIM 脚本设计哲学的关键,在于它不是把脚本当成某一个模块的附属功能,而是当成一套供全框架复用的基础设施 。源码目录 core/wsf/source/script/ 下密集地排布着上百个 WsfScript*Class.hpp,看的出来这不是"某个处理器支持脚本",而是"整个框架的对外能力都预先准备好了脚本化封装"。
这套基础设施由两个核心对象承担。一个是脚本服务 ,它"提供实现与扩展 AFSIM 脚本语言所需的基础设施";另一个是脚本管理器 (WsfScriptManager.hpp),它"理解脚本类型并启用脚本语言,而且脚本类型易于扩展,以便容纳新集成的模型"。
请注意最后半句的价值:"脚本类型易于扩展"。这意味着脚本语言本身是一个可插拔的开放系统------任何第三方模型、任何想定专用对象,都可以在运行时把自己注册成脚本里一个全新的类型,让分析人员像调用内建方法一样调用它。这一点我们在 5.7 节和后续章节还会展开,但结论先放在这里:脚本引擎是 AFSIM 扩展能力的总入口。
5.2 脚本在哪里出现:从想定文本到运行时回调
要理解脚本,先要看它在想定文件里"住"在哪里。答案比想象的要多。
5.2.1 编译器入口:WsfScriptContext::ProcessInput
脚本相关命令的统一识别入口是 WsfScriptContext.cpp 中的 ProcessInput 方法。它逐条判断当前输入命令是不是脚本相关命令,被识别到就交给脚本系统处理;识别不到就返回 false,让调用方(平台、处理器等)继续处理自己的命令。这种"责任链"式设计,是脚本系统能无缝插入到任意层级的想定对象里的前提。
在 WsfScriptContext::ProcessInput 里被识别的命令,构成了脚本语法面的骨架:
script------ 定义一个具名脚本块(script...end_script),是最基本的载体;script_variables------ 定义一个变量块(script_variables...end_script_variables),块内声明的变量对整个上下文可见;script_silent/script_silent_mode------ 关闭脚本的执行期输出;script_debug_writes------ 打开或关闭脚本的调试写输出;script_call_trace------ 打开调用跟踪,便于定位脚本执行链;script_print_code------(内部使用)打印被编译的脚本文本;on_initialize------ 定义一个在对象初始化时自动执行的隐式脚本;on_initialize2------ 第二个初始化回调,但在全局脚本上下文中属于非法;on_update------ 定义一个在对象的Update周期里被自动调用的隐式脚本,同样不允许出现在全局上下文;execute------ 定义一个"定时执行"的脚本,其时间语义由紧随其后的at_time(绝对或相对)或at_interval_of(周期)决定;at_time------ 更早的定时语法,源码里已经标注为"已废弃",并提示改用execute at_time ... end_execute。
这里有两个细节值得工程师特别注意。
第一,"隐式脚本"是一类没有显式 script/end_script 包裹体的脚本。 on_update、on_initialize 这类名字本身就说明了它的触发时机,函数的返回类型和参数列表是框架预定义的,所以想定里只需要写函数体,以 end_on_update、end_on_initialize 之类收尾即可。CompileImplicitScript 方法还兼容了更古老的一种写法:如果 on_xxx 后面紧跟的关键字恰好是 script,则按"老形式"(on_xxx script ... end_script)解析,否则按"新形式"(on_xxx ... end_on_xxx)解析。这种"同一语义、两种语法"的并存,是长生命周期框架里常见的兼容策略,值得借鉴。
第二,on_update 与 on_initialize2 被明确禁止出现在全局脚本上下文里。 源码中的 UtInput::OutOfContext 异常说明,这两类回调只对"有更新周期的对象"(平台、处理器等)有意义。全局上下文没有 Update 语义,因此它们在这里是非法命令。这体现出一个设计原则:脚本命令的合法性是随上下文而变的,不是一套放之四海皆准的扁平关键字表。
5.2.2 全局层的另一个入口:WsfScriptManager::ProcessInput
在平台之上,还有一层更全局的脚本配置,入口是 WsfScriptManager.cpp 的 ProcessInput。它先尝试把命令交给 WsfScriptContext 处理,若未识别,再看是不是下面这几类:
script_interface------ 一个块,内部可配置silent/silent_mode、debug_writes,以及已废弃的listing/compile_listing、占位的debug与no_debug;script_abort_on_null_references------ 控制当脚本里出现空引用调用时是否直接中止;script_abort_on_assert------ 控制脚本断言失败时的行为;- 以及结构体(
UtScriptStruct)相关命令。
script_interface 块里的语义映射关系耐人寻味:silent true 相当于把脚本写输出关掉(SetWritesEnabled(false)),debug_writes true 相当于把写输出打开。也就是说,"静默"和"调试写"是同一个开关的两个方向。这种把多个用户命令折叠到少量内部状态(writesEnabled)的做法,减少了下游判断的分支数量。
5.2.3 观察者脚本:让脚本"看"整个仿真
除了平台和处理器内部的脚本,AFSIM 还有一类特殊的脚本载体------脚本观察者 (WsfScriptObserver.hpp)。它继承自 WsfPlatformObserver,挂在整个仿真或平台观察链上,能够对平台初始化、平台删除、航迹更新、消息收发、传感器探测等各类仿真事件做出脚本化响应。观察者里的脚本参数是通过 GetScriptTypes() 打包传递的,也就是说,观察者脚本拿到的对象,仍然是脚本类型系统里那些有类型、有方法的安全引用,而不是裸指针。
一个常被混淆的点:"脚本处理器"与"脚本观察者"不是一回事 。脚本处理器(WsfScriptProcessor.hpp)是"用脚本实现的一个处理器部件",它是平台的一部分,有自己的 Update 和消息处理语义;而脚本观察者则是"用脚本订阅仿真事件"的旁路监听器,它不改变平台结构,更像是全局的分析探针。前者改变仿真行为,后者观察仿真行为。这个区分在 5.6 节会再展开。
5.3 编译、上下文与作用域
5.3.1 从文本到 UtScript:编译链
当 ProcessInput 识别到 script 命令后,会调用 WsfScriptContext::Compile。这个方法做的事非常清晰:把当前输入流包装成一个 UtScriptStream(负责按给定的结束标记 end_script 截取文本),交给脚本上下文(UtScriptContext)去 Parse,解析成功后做一次 PostCompile 收尾,最后返回一个编译产物 UtScript*。
注意这里的分层:WsfScriptContext 属于 WSF 框架层,负责"哪些命令是脚本、脚本挂在哪个上下文、何时触发";真正的语法分析、类型检查、字节码/解释执行,则下沉到 UtScript 系列(属于更底层的通用脚本运行时)。框架层定义"脚本如何融入仿真",运行时层定义"脚本语言本身"。这条边界很干净,也是 AFSIM 能把脚本语言复用到各处而不失控的原因。
Compile 还有几个值得注意的参数:脚本名、返回类型、结束标记、命名空间,以及一个关键的布尔量 aIsVariableBlock------用于区分"普通脚本块"和"script_variables 变量块"。变量块的编译走 CompileVariables,它的结束标记是 end_script_variables,并且传入 aIsVariableBlock=true。这意味着脚本运行时在编译变量块时会采取不同的语义(例如变量块里的定义和赋值不允许调用函数或其他脚本),而普通脚本块则没有这个限制。
5.3.2 script_variables 与作用域
script_variables 是脚本作用域体系的最直观体现。用官方文档里的话说:如果你在某个平台命令里写了 script_variables,那么该平台内定义的所有脚本都能访问这些变量;而可脚本化的平台部件(比如处理器)需要通过 extern 关键字显式声明,才能访问父平台的变量。
这背后是一套树状、可继承但访问受控 的作用域模型。平台脚本上下文是子级,部件脚本上下文是孙级,全局上下文是根。变量不是自动全局可见的,而是"声明在哪儿,就在哪棵子树里可见";跨层访问必须 extern。这样做的好处是:想定规模一大,成千上万个部件如果都能隐式读写彼此的变量,调试将无从谈起。extern 的存在,相当于给跨层访问加了一道显式的"意图声明",把耦合关系摆到台面上。
5.3.3 上下文的初始化:Initialize 做了什么
真正让脚本"活起来"的是上下文初始化。以平台/部件上下文为例,WsfScriptContext::Initialize 依次完成几件事:
第一,注册父子关系。平台上下文挂在全局上下文下,部件上下文挂在平台上下文下------作用域的树结构在这一步物理成型。
第二,注入预定义变量。PLATFORM 被绑定到当前平台对象;如果这是部件上下文,则设置 this 指针并把部件变量(例如处理器的 PROCESSOR)绑定进上下文。源码注释特别强调:这两步必须放在脚本变量初始化循环之前,因为用户自己声明的脚本变量可以引用 PLATFORM。这是一个很典型的"引导顺序"约束。
第三,继承调试配置。如果用户没有显式设置 script_call_trace,则从全局上下文继承;若父上下文显式设置过,则以父的为准。
第四,执行所有 script_variables 的初始化脚本(ExecuteInitScripts),然后调用 on_initialize 脚本。最后,把定时脚本注册成事件(AddTimedEvents),并把 on_update 的脚本指针缓存下来(mOnUpdateScriptPtr)。
第五,缓存的用意是性能。on_update 每个周期都要调用,如果每次都去按名字查找脚本指针,累积开销可观。缓存成裸指针后,热路径上只是一次判空加一次调用。"冷路径查找、热路径缓存" 是脚本引擎里反复出现的优化套路。
对应地,非平台对象的上下文初始化走另一个重载版本,它是"设置 TIME_NOW 变量 + 调用 on_initialize"这套约定的通用实现。也就是说,任何 C++ 对象只要接入脚本上下文,就自动获得同样的初始化契约。
5.4 类型系统:把 C++ 对象图安全映射进脚本
脚本能调用 C++ 对象,靠的不是反射魔法,而是一整套显式的类型注册与脚本类机制。这是这一章技术含量最高的部分。
5.4.1 WsfScriptManager::RegisterTypes:一次大规模登记
打开 WsfScriptManager.cpp 的 RegisterTypes 方法,你会看到一段极长的登记代码:把所有可被脚本访问的 C++ 类型,逐一注册进脚本类型表。注册的形态大致有两类:
- 由业务类自己提供脚本类工厂,如
WsfTrack::CreateScriptClass("WsfTrack", typesPtr)、WsfRoute::CreateScriptClass("WsfRoute", typesPtr)、WsfMessage家族的CreateScriptClass("WsfTrackMessage", ...)等; - 由脚本目录下的专用脚本类直接构造,如
WsfScriptPlatformClass、WsfScriptSensorClass、WsfScriptProcessorClass、WsfScriptMoverClass、WsfScriptGeoPointClass、WsfScriptDateTimeClass、WsfScriptZoneClass、WsfScriptCommClass、WsfScriptTerrainClass等。
注册后,脚本里这些名字就成了可用类型 。想定作者可以声明 WsfPlatform、WsfTrack、WsfSensor 类型的变量,也可以调用相应的方法。这些名字和 C++ 侧的类名基本一一对应,但它们是脚本世界的独立命名空间------脚本类型表是运行时构建的,与编译期的 C++ 类型互不干扰。
5.4.2 脚本类的继承基座:WsfScriptObjectClass
所有这些脚本类有一个共同的基座:WsfScriptObjectClass.hpp 中的 WsfScriptObjectClass。它继承 UtScriptClass,为所有"脚本可访问的 WSF 对象"提供了几个统一方法:Name、Type、BaseType、IsA_TypeOf(类型判断),以及一个非常有意思的 ProcessInput 方法------脚本对象自己也能处理输入命令。
WsfScriptObjectClass 还负责对象的生命周期语义:Create、Clone、Destroy、ToString。换句话说,脚本世界里"新建一个对象""复制一个对象""销毁一个对象""把对象打印成字符串"这些操作,最终都落到这里定义的契约上。
而具体类型通过继承它来扩展方法。以 WsfScriptPlatformClass.hpp 为例,WsfScriptPlatformClass : public WsfScriptObjectClass,然后一长串 UT_DECLARE_SCRIPT_METHOD 声明了平台在脚本里能用的方法:Index、CreationTime、Side、Icon、Commander_1、Peers_1、Subordinates_1、CommandChain、Mover、SpatialDomain、坐标系转换 WCS_To_ECS_DCM、视线速率 LineOfSightRatesTo,等等。
UT_DECLARE_SCRIPT_METHOD 这个宏是关键抽象。每一个被它声明的名字,都会在脚本类内部生成一个函数对象,负责把脚本调用"翻译"成对 C++ 对象成员的调用。这就是"方法暴露"的全部机制------没有反射,只有显式的函数对象挂接。 它的优点是类型安全、可控、可文档化;代价是每暴露一个方法都要写一行声明。
5.4.3 为什么值得这样做
把类型注册和方法暴露做得这么"笨重",值得吗?站在架构角度,这是把庞大 C++ 对象图安全暴露给脚本的标准做法,其价值有三:
第一,边界可控。脚本能碰到的对象和方法,是设计者逐一挑选出来的白名单。传感器内部的一个临时缓冲区、一个不该被外部修改的成员,天然不在注册列表里。相比"把整个对象内存摊开给脚本",这个白名单机制把风险收敛到已知范围内。
第二,类型化 。脚本拿到的是带类型的引用(UtScriptRef),可以做类型判断(IsA_TypeOf)、可以比较、可以打印。这保证了脚本里的错误在编译期或运行期能被定位到具体类型,而不是变成"野指针崩溃"。
第三,可扩展。注册表是运行时可写的。任何新模型接入框架,只要提供一个脚本类并注册,就自动获得脚本能力。这正是 5.7 节要讲的扩展点。
5.5 双向交互:脚本调 C++,C++ 调脚本
脚本引擎的完整图景是双向的。理解了类型系统,只理解了"脚本 → C++"这一半。
5.5.1 脚本调用 C++:对象方法 + 内建函数
脚本侧调用 C++,有两条途径:
一是对象方法调用 。脚本里写 platform.Side()、platform.Mover()、track.TrackId() 之类,最终经由脚本类的函数对象转发到 WsfPlatform::GetSide()、WsfPlatform::GetMover() 等 C++ 成员函数。想定作者感觉像在调一个普通对象,底层是一次经过授权的、类型安全的转发。
二是内建函数 。脚本运行时和 WSF 框架会注册一批全局函数,供脚本直接调用,例如生成航迹(PLATFORM.MakeTrack() 这类)、读写仿真时间与随机数、地形与地理坐标计算、以及最基本的输出函数 writeln。这些函数让脚本不必先拿到对象就能执行常见操作。关于内建函数的完整清单,框架的脚本文档里有专章(core/wsf/doc/script/builtin.rst),本章不逐一展开。
5.5.2 C++ 调用脚本:事件、回调与调度
反过来,C++ 侧如何触发脚本?主要靠三种机制:
第一,生命周期回调。 平台/处理器初始化时触发 on_initialize,每个更新周期触发 on_update,收到消息时触发 on_message(WsfScriptProcessor 的注释明确列出了 on_update 与 on_message 两种约定,以及 PLATFORM、PROCESSOR、TIME_NOW、MESSAGE 四个预置变量)。这些回调由框架在正确的时机替你调用。
第二,定时事件。 execute at_time / execute at_interval_of 定义的一次性或周期性脚本,会在初始化阶段被转成事件对象,交给仿真的事件调度器。承载它的类是 WsfScriptEvent.hpp---一个继承自 WsfEvent 的事件,记录了执行该脚本的仿真时刻、所在的平台或处理器上下文、脚本名和参数。当仿真时间推进到该时刻,事件调度器执行它,脚本就被"唤醒"。
第三,脚本回调。 WsfScriptCallback.hpp 的 WsfScriptCallback 继承自 WsfCallback,它的职责是"执行一个脚本列表,且每个脚本可以指定自己在平台本体还是某个处理器上执行"。也就是说,它把一个可执行脚本列表包装成一个标准的回调对象,可以挂到 C++ 侧任何需要"回调点"的位置。
此外还有一个直接入口:WsfScriptManager::ExecuteScript(simTime, scriptId),允许 C++ 代码按键名直接执行一个全局脚本。这是插件与脚本之间最直接的桥。
把这三种机制合起来看,会发现一条清晰的设计线索:C++ 侧从不"直接调用脚本函数体",而是通过回调与事件这两类契约来触发。回调定义"在什么语义点触发",事件定义"在什么时刻触发"。脚本的调用时机因此是被结构化管理的,而不是散落在代码各处。
5.5.3 状态机:把控制逻辑也交给脚本
脚本能力的进一步延伸是状态机 。WsfScriptStateMachineProcessor.hpp 定义了 WsfScriptStateMachineProcessor,它继承自 WsfScriptProcessor,内部持有一个 WsfScriptStateMachine。它是一个"可脚本化的有限状态机"------用户可以在想定文件里定义状态、以及状态之间的转移规则,而框架负责在运行时评估(Evaluate 方法)并推进状态。
为什么要引入状态机?因为纯粹的 on_update 脚本是"无记忆"的:它每个周期都会执行一遍,所有上下文都要靠脚本变量自己维护。而作战行为天然是分阶段的------搜索、跟踪、交战、脱离------用状态机表达比用一堆 if 判断当前处于哪个阶段要清晰得多。状态机的引入,等于把"行为阶段管理"这个通用模式,也纳入了脚本的表达能力。
5.6 脚本能力面与典型形态
综合前面的机制,可以粗略勾勒出脚本能做的事:
- 平台与部件:创建、查询、修改平台及其部件(传感器、处理器、通信设备、运动模型);
- 航迹:读写本地航迹表、构造航迹、发布航迹消息;
- 运动与航路 :控制平台运动、操作航路(
WsfRoute)与航路点(WsfWaypoint); - 通信与消息:发布消息、访问通信网络与地址、处理消息;
- 仿真环境:读写仿真时间、访问随机数与随机变量、访问地形与地理坐标;
- 输出 :通过
writeln写日志,通过脚本观察者输出各类仿真事件。
但要提醒读者区分两种典型形态,它们常被混为一谈:
脚本处理器 (WsfScriptProcessor.hpp)是"用脚本实现一个处理器部件"。它有自己的 Update、有消息处理、有 PLATFORM/PROCESSOR/TIME_NOW/MESSAGE 变量。它改变仿真行为------它是平台的一部分,参与仿真推进。
脚本观察者 (WsfScriptObserver.hpp)是"用脚本订阅仿真事件"。它挂在观察链上,监听平台初始化/删除、航迹更新、消息收发、传感器探测等事件。它观察仿真行为------它不参与行为决策,只做旁路响应,通常用于统计、日志、可视化。
理解这个区分,对读懂一个想定的架构至关重要:如果你看到平台的行为逻辑写在 WSF_SCRIPT_PROCESSOR 里,那它是"承重结构";如果你看到事件统计写在脚本观察者里,那它是"监控旁路",删掉它不影响仿真结果(只是丢失了统计)。
5.7 脚本作为扩展点
脚本引擎真正体现"基础设施"价值的地方,在于它是开放的。
在 5.1 节我们引用过脚本管理器那句"脚本类型易于扩展,以容纳新集成的模型"。这不是口号,而是有明确 API 支撑的。WsfApplication.hpp 暴露了 GetScriptTypes(),WsfScenario.hpp 同样暴露了 GetScriptTypes();而 WsfScriptManager 提供了 Add(std::unique_ptr<UtScriptClass>) 方法,把一个新的脚本类注册进类型表。
这意味着一个完整的"脚本化扩展"只需两步:写一个脚本类(继承 WsfScriptObjectClass 或 WsfObject 家族,用 UT_DECLARE_SCRIPT_METHOD 声明要暴露的方法),然后把它注册进脚本类型表。 注册完成后,脚本世界里立刻多出一个新类型,分析人员就能像调用内建类型一样使用它。
这条路径的威力在于:插件开发者把 C++ 能力"翻译"进脚本语言,而分析人员用脚本语言组合这些能力,两者通过脚本类型表这个稳定的契约解耦。 插件可以独立演进(只要不破坏已发布的方法签名),脚本想定不必随之重编。第 12 章讲应用框架与扩展时会回到这个 API,现在只需记住:脚本类型表是 AFSIM 最重要的扩展接缝之一。
顺带一提,AFSIM 的军事扩展(wsf_mil)和各种插件(例如 OMS/UCI 集成)正是沿着这条路径工作的------它们各自在 script/ 目录下提供一批 WsfScript*Class,把武器、制导、威胁处理、任务管理等能力注册进脚本。脚本类型表的规模,直接反映了框架被脚本化的深度。
5.8 设计启示
从 AFSIM 脚本引擎的设计中,可以提炼出几条对通用系统同样适用的经验。
第一,脚本就是一门内嵌 DSL,而 DSL 的价值在于"变更频率的解耦"。 把高频变更的逻辑从编译期搬到解释期,让需求方和实现方各自掌握趁手的工具------这是所有成熟仿真/游戏/规则系统共有的选择。判断"哪些逻辑该脚本化"的经验法则是:变更频率高、性能不敏感、且需要领域专家参与的部分。
第二,"脚本类 + 类型注册"是把庞大 C++ 对象图安全暴露给脚本的通用做法。 AFSIM 没有走"反射一切"的路线,而是显式声明白名单方法。代价是样板代码多,收益是边界可控、类型安全、可文档化、可扩展。这套思路在今天的"宿主语言 + 内嵌语言"设计中依然是主流。
第三,语言级的作用域与访问控制是可维护性的基石。 平台的变量不自动下沉到部件,跨层必须 extern------这类约束在单个脚本里显得啰嗦,但在上千个对象的想定里,它是唯一能让调用关系可追踪的手段。规模化的可维护性,往往来自"限制自由度"而非"增加自由度"。
第四,把调用时机结构化,而不是散落化。 C++ 触发脚本只有回调(语义点)和事件(时间点)两类契约,脚本因此像被"挂"在明确的钩子上,而不是潜藏在随意的地方。这种结构性让脚本系统在长期演进中不至于退化成"哪里都能插一脚"的意大利面。
第五,冷路径查找、热路径缓存。 on_update 脚本指针在初始化时被缓存,这类小优化在每周期被调用成百上千次的路径上,是相当有效的工程习惯。脚本引擎的性能不只在解释器本身,也在这些宿主侧的调度细节里。
本章小结
本章解剖了 AFSIM 的脚本引擎。我们从"为什么需要脚本"出发:多数行为逻辑变更频繁、需要领域专家直接参与,不该每次都走 C++ 编译流程。接着我们梳理了脚本在想定中的出现位置------script/script_variables 块、on_initialize/on_update/on_message 隐式回调、execute/at_time 定时脚本,以及脚本观察者(入口是 WsfScriptContext.cpp 与 WsfScriptManager.cpp)。然后我们深入编译与作用域:Compile 把脚本交给 UtScript 运行时解析,script_variables 构成树状的、extern 受控的可见性模型,Initialize 完成上下文挂接、变量注入与回调绑定。核心部分在于类型系统------RegisterTypes 把大量 C++ 类型登记进脚本命名空间,WsfScriptObjectClass 及其子类(如 WsfScriptPlatformClass)通过 UT_DECLARE_SCRIPT_METHOD 显式暴露方法,构成了"庞大对象图的安全脚本化"。双向交互由对象方法、内建函数、回调、事件(WsfScriptEvent.hpp)与 WsfScriptCallback 共同支撑,状态机(WsfScriptStateMachineProcessor.hpp)进一步把行为阶段管理纳入脚本。最后我们看到脚本是框架最重要的扩展接缝:通过 WsfApplication::GetScriptTypes() 与脚本管理器的 Add/Register,插件可为脚本语言增加全新类型。设计的核心启示是:脚本作为内嵌 DSL,用显式白名单与结构化触发时机,换取变更解耦、类型安全与可扩展性。