使用说明
AFSIM MCP
本项目是一个本地 MCP(Model Context Protocol)服务器,让大模型能真正跑起 AFSIM (Advanced Framework for Simulation, Integration, and Modeling)仿真并读懂结果。
只有 4 个工具。 不是一个功能齐全的想定编辑器,而是一层薄薄的、专门补上 模型自身做不到的那几件事的垫片。
边界:工具归工具,知识库归知识库
这个 MCP 只做两件事,两件事都不含判断:
一、AFSIM 知识库(core/knowledge/) — 只读 AFSIM 安装目录,只报告里面有什么。
不含任何作者编写的规则,AFSIM 升级后可以整体重新生成。
二、工具(core/runtime|validation|analysis) — 执行能力。薄,不含知识。
分层与依赖方向
core/knowledge/ 事实:从安装目录提取 不含判断
├ source.py 归一化读取(CRLF 会静默吞声明)
├ type_index.py 类型命名空间
└ mission_grammar.py mission 输出的语法
core/policy.py 判断:哪些事实算失败 全部具名、可覆盖
core/runtime.py 执行器:起进程
core/validation.py 执行器:跑 + 解析
core/analysis.py 执行器:evt → 指标
core/results.py 执行器:运行记录
core/server.py 配置解析与 JSON-RPC 分发
tools/ MCP 接口:模型答不了的问题
transport/stdio.py 传输
依赖严格单向,下层不导入上层(tests/test_layers.py 会检查)。
一个事实不能悄悄变成判断,一个底层模块不能悄悄依赖上层。
什么进知识库,什么不进
| 进知识库 | 不进知识库 |
|---|---|
这台机器上有哪些 platform_type |
什么样的防空部署是合理的 |
| mission 的报错长什么样 | 这个想定该怎么设计 |
.evt 怎么分类 |
Pk 意味着什么、要不要追求 |
判据:能从 AFSIM 安装里提取出来的进,靠经验猜的不进。
这不是理论。这个仓库里两件"嵌知识"的事成功、两件失败:
- 成功:类型目录、错误语法。都是从 mission 真正读取的字节里提取的。
装上之后模型不再编造
AUTO_BLUE_FIGHTER_AIR。 - 失败:关键词推断层、想定模板。都是作者根据经验猜的。结果是提示词写
"不要任何飞机",照样生成空中战斗机,平台类型只有
icon+mover。
4 个工具,每个对应一个模型答不了的问题
| 工具 | 模型为什么答不了 |
|---|---|
afsim_types |
platform_type 命名空间是封闭的,编错 mission 直接拒绝。 |
run_mission |
编译、实例化、运行在 AFSIM 里是一次 mission.exe 调用,报错格式必须从真实输出解析。 |
analyze_run |
KPI 和时间线来自数 MB 的 .evt,模型数不准。 |
launch_afsim_tool |
启动 GUI 工具。 |
每个工具都要能回答"模型为什么不能自己问这个问题"。
按这条规则 list_demos 站不住(68 个目录,ls 一下就行),已经删掉。
判断是显式的
core/policy.py 里的每条规则都具名、可从参数覆盖:
// 一份"跑了但什么都没发生"的仿真,默认判失败
{ "ok": false, "policy": { "inert_is_failure": true, ... },
"problems": ["simulation ran but produced no activity: ..."] }
// 不认同这个判断的调用方可以覆盖
run_mission { scenario_file: "...", inert_is_failure: false }
// → problems 变 caveats,ok 变 true
之前这些判断埋在 validation.py 里,读起来像是 mission 自己说的。
前置条件
- Windows
- Python 3.10+
- 已安装 AFSIM(
mission.exe等)。类型目录从 AFSIM 自带的 demos 索引而来。
服务只依赖 Python 标准库,不需要安装 mcp 包:transport/ 内置了完整的
JSON-RPC / MCP stdio 实现。
快速开始
python configure_mcp.py
脚本会询问 AFSIM 根目录、工程目录、demos 目录和 bin 目录,写入
%USERPROFILE%\.afsim_mcp\config.json,并输出客户端配置片段。
| 环境变量 | 作用 |
|---|---|
AFSIM_ROOT |
AFSIM 安装根目录 |
AFSIM_PROJECT_ROOT |
你的工程目录 |
AFSIM_DEMOS_DIR |
demos 目录(类型目录的数据源) |
AFSIM_BIN |
mission.exe 等可执行文件所在目录 |
AFSIM_MCP_CONFIG_DIR |
配置文件所在目录,客户端只需传这一个 |
AFSIM_MCP_STATE_DIR |
状态目录(类型目录缓存、运行记录) |
客户端配置形如:
{
"mcpServers": {
"afsim": {
"command": "python",
"args": ["<仓库路径>/transport/stdio.py"],
"env": { "AFSIM_MCP_CONFIG_DIR": "C:\\Users\\你\\.afsim_mcp" }
}
}
}
工作流
1. afsim_types 查真实的 platform_type(模型必须选,不能编)
2. (模型自己写 AFSIM 文本到工程目录)
3. run_mission 跑 + 校验,拿到 file:line:column 的真实报错
4. 按报错改文本,再跑
5. analyze_run 出战斗报告与 KPI
第 1 步:类型目录
AFSIM 有两个封闭的类型命名空间,两个都被索引了(2.9.0 实测):
| 层级 | 数量 | 说明 |
|---|---|---|
platform_type |
657(224 个含传感器或武器) | 完整的平台类型,含运动器/传感器/武器/处理器清单 |
| 组件类型 | 535 | sensor / weapon / mover / processor / comm / filter |
| AFSIM 内置库类 | 88 | WSF_*,编译进 DLL,从不在 demos 里声明,但确实存在 |
区分方法是缩进:顶格的 sensor aesa WSF_RADAR_SENSOR 是类型定义,
缩进的 sensor rdr1 aesa 是平台内的实例(实例 rdr1,类型 aesa)。
afsim_types(domain="air")
→ AIR_MOVER_FIGHTER, DCA, ESCORT, HVAA, LTE_FIGHTER, SIX_DOF_FIGHTER, SOJ ...
afsim_types(name="LTE_FIGHTER")
→ sensor: aesa, esm
weapon: MEDIUM_RANGE_RADAR_MISSILE, SHORT_RANGE_IR_MISSILE
mover: WSF_BRAWLER_MOVER
processor: radar_track_cueing, weapon_datalink_manager
include_path: platforms/lte_fighter.txt
afsim_types(kind="sensor", keyword="radar") # 列传感器类型
afsim_types(name="aesa", kind="sensor")
→ base: WSF_RADAR_SENSOR
parameters: ignore_domain, initial_mode, selection_mode, slew_mode,
azimuth_slew_limits, elevation_slew_limits, ...
used_by: AIR_MOVER_FIGHTER, DCA, ESCORT, LTE_FIGHTER
默认只列含传感器或武器的平台类型是有意的:一个既不能探测也不能开火的平台, 出现在交战想定里没有意义。每个组件类型还记录了它接受哪些参数和被哪些平台 使用,所以写组件时不用猜关键字。目录首次构建约 9 秒,之后走缓存。
脚本处理器的接口:这是"加了战术处理器却不开火"这个最常见故障的根因。目录会
把处理器声明的 script_variables 抽出来,标出默认为空因而必须赋值的变量,并
给出 demo 里的可用配置:
afsim_types(name="AA_TACTICS", kind="processor")
script_interface:
must_set = ['SENSOR_NAME', 'WEAPON_NAME']
working_configuration:
platform_type = BLUE_NAVAL_SAM_BATTERY
on_initialize = [{WEAPON_NAME: "blue_naval_sam"}, {MAX_ENGAGEMENTS: "20"}]
赋值必须写在 on_initialize 块里;在 script_variables 里重声明会被 mission
拒绝——子 Agent 实测正是在这里卡住过。535 个组件类型中有 75 个带这样的脚本接口。
第 3 步:校验
run_mission 真正跑 mission.exe 并解析它的真实输出,而不是只看退出码:
{
"ok": false,
"returncode": 0, // mission 自己认为成功
"simulation_complete": true,
"inert_simulation": true, // 但只有 PLATFORM_INITIALIZED,什么都没发生
"evt_events": 4,
"evt_categories": { "PLATFORM_INITIALIZED": 4 },
"problems": ["simulation ran but produced no activity: ..."]
}
判定失败的条件:Mission 报错、退出码非 0、超时、没跑到 Simulation complete、
一个输出文件都没写(no_evidence),或者跑完了却只有 PLATFORM_INITIALIZED
事件(inert_simulation)。
另有一类是"通过了但没法分析"(not_analyzable):仿真确实跑了、.aer 也有内容,
但一条 .evt 事件都没记录——通常是想定里 event_output 的 enable 全被注释掉了。
这种情况返回 ok: true 并附 caveat 说明,而不是误判成失败。
报错带精确定位:
ERROR Unknown command: bogus_keyword
at C:\proj\s.txt:2:22
source: "bogus_keyword 5"
关于内置库类:WSF_AIR_MOVER、WSF_RADAR_SENSOR 这类是编译进 AFSIM 的,
demos 里不会声明,但 mission 认。目录把这类名字也收进来了(从别的类型的 base
字段和平台组件引用中反推),查不到时会明确区分"不在 demos 里"和"不是已知类型",
不会像之前那样误导你说 WSF_AIR_MOVER 不存在。
include 解析:demo 里的类型文件用相对路径 include 兄弟文件,只有当入口的
file_path 也指向那个 demo 时才能解析。run_mission 遇到
Cannot open file: X 时会直接在诊断里给出可执行的修复行:
"include_resolution": {
"found": true, "match": "basename",
"candidates": [{
"path": "air_to_air/platforms/lte_fighter.txt",
"add_to_entry_file": "file_path C:\SoftCode\Sim\AFSim\demos\air_to_air"
}]
}
调试行为:include_stdout=true 会带回 Mission 的原始 stdout。AFSIM 把
show_state_transitions、show_task_messages 这类行为诊断放在 stdout,
查"为什么这个战术处理器不开火"基本只能靠它。
第 5 步:分析
analyze_run 除了 KPI 和交战统计,还会给出 diagnostics,说明报告为什么是空的
——这两种情况完全不同:
"diagnostics": [
{ "issue": "empty_evt", "detail": "output/scn.evt is 0 bytes.",
"likely_cause": "The scenario's event_output block enables no events..." },
{ "issue": "no_events", ... }
]
对比之下,一次健康的交战跑出来的 diagnostics 是 []——有诊断信息本身就是
信号:它在告诉你结果可信还是不可信。
分析还给出 first_event_sec / last_event_sec(从全部解析记录算,不是从截断的
时间线),以及 kill_attribution 说明:AFSIM 的事件输出不记录击毁方的射手,
所以 first_kill 是按"损失方归给对面"推断的,油耗耗尽、坠毁或同归于尽都会被
算到对方头上——要下结论前请对照 weapon_statistics。
损失是怎么算出来的
red_blue_losses 里最容易被误读的是 platforms_lost。一发打出去的导弹在 AFSIM
里也是一个平台,命中后同样会产生 PLATFORM_BROKEN,所以"损失"这个词本身有歧义。
本项目不靠平台名字判断,判据全部取自事件流自身:
WEAPON_FIRED的第三个 token 就是这发弹的实例名,它随后会原样出现在PLATFORM_BROKEN里。名字对上的记为weapons_expended,不计入战斗损失。PLATFORM_INITIALIZED列出想定开场就摆上场的平台。名字在其中的才算真实损失。- 两者都覆盖不到时(
.evt被截断,或event_output没开这两类事件),该条 损失进loss_accounting.unverified_losses,照常计入platforms_lost,但明确 标注依据不足。
"loss_accounting": {
"basis": "AFSIM event stream (WEAPON_FIRED / PLATFORM_INITIALIZED), not name spelling",
"launched_weapon_platforms": 14,
"initialized_platforms": 9,
"unverified_loss_count": 0
}
早期版本用名字关键词判定,把 BLUE_SAM_SITE 因为含 "sam" 当成了一枚导弹,
于是一份零战斗损失的仿真报出 9 架飞机损失。宁可报"依据不足",也不报一个看起来
精确、方向却错了的数字。
平台类型的域
afsim_types 的 domain 取自平台自己声明的 mover,并沿 base 链向上查找。
链上任何一环都没有声明 mover 的类型返回 undetermined——这是真实答案,不是失败。
早期版本会在类型名和定义文件路径里搜 "air""space" 这类词,结果把 GPS 归到
space、把 HVAA 和 SSM_MISSILE 归到 air。
仓库结构
core/knowledge/ AFSIM 知识库:只从安装目录提取事实
source.py 归一化读取
type_index.py 类型命名空间(platform_type / 组件类型 / 内置库类)
mission_grammar.py mission 输出的语法
core/policy.py 判断层:哪些事实算失败,具名可覆盖
core/runtime.py 执行器:进程
core/validation.py 执行器:跑 + 解析
core/analysis.py 执行器:evt → 指标
core/results.py 执行器:运行记录
core/server.py 配置解析与 JSON-RPC 分发
tools/ 4 个工具的定义与路由
transport/stdio.py stdio 传输入口
tests/
test_layers.py 层级纪律:下层不得导入上层
test_knowledge.py 知识层提取规则
test_type_index.py 类型索引提取规则
test_validation.py mission 输出解析
test_protocol.py MCP 协议
测试
python -m pytest tests/ -v
许可
MIT,见 LICENSE。
一个平台类型能不能开火
platform_type 继承是叠加的:派生类型只覆盖一个 mover,也会保留基类声明的
全部传感器、武器和处理器。所以只看类型自己那一段会把每个派生类型都读成裸的——
SAM-MR_RB6 会报"无传感器、无武器",而它其实继承了 SAM-MR 的导引头和四个
处理器。afsim_types 因此同时给出两份:
components—— 这个类型自己声明的effective_components—— 沿base链解析后的实际配置
还有一个坑:老版本里 armed 的定义是"声明了传感器或武器",于是只有雷达、
根本没有导弹的阵地也被标成 armed。一个 Agent 挑中这样的类型建了防空阵地,工程
干净运行、返回 0、然后全场一弹未发,而 MCP 一直在说它是武装的。
现在分开报三个事实:
| 字段 | 含义 |
|---|---|
has_sensors |
有效配置里有传感器 |
has_weapons |
有效配置里有武器 |
has_processors |
有效配置里有处理器 |
can_engage |
同时有武器和处理器 |
can_engage 是必要条件而非充分条件:脚本处理器在变量没配之前仍然是惰性的,而
mission 不会在加载时拒绝一个没接线的平台——照样跑、照样返回 0、照样不开火。
<length-value> 到底是什么
命令索引里每个带数值的参数都写成一个占位符——altitude <length-value>、
end_time <time-value>——而占位符正是语法变得不可校验的地方。<length-value>
是两个 token,第二个是单位,所以 altitude 5000m msl 会被拒,而索引无法
表达这个理由;end_time 00:00:30 也会被拒,因为 <time-value> 是一个数加一个
单位,不是时钟。这些全是一个 Agent 反复运行 mission 直到它不再报错试出来的。
AFSIM 在 documentation/html/docs/argumenttypes.html 里正式定义了这套产生式,
本项目直接读那份定义:
altitude <length-value>
-> altitude <real> <length-units>
length-units 36 个可接受拼写: meters meter m kilometers km feet ft kft miles mi nm nmi cm mm ...
命令索引里 2267 个 <*-value> 占位符,现在 2068 个(91%)能被展开。剩下的是文档
本身没定义的(裸 <value>、dbratio-value、坐标类的 X-value),保持未定义。
拼写表里的每个词都来自 AFSIM 自己,不是谁试出来的。
include 是怎么找文件的
include 找不到文件时的提示以前指向 demo 的顶层目录,于是给出
file_path demos/suppressor,而文件其实在 demos/suppressor/platforms 下面——
加了这行没有任何变化。
AFSIM 是把 file_path 条目拼到它被要求的名字上来找文件的,所以正确的条目不是文件
所在目录,而是把请求的名字从文件路径里去掉之后剩下的目录:
| mission 找不到 | 正确的 file_path |
|---|---|
sam_radar.txt |
demos/suppressor/sensors/radar |
sensors/radar/sam_radar.txt |
demos/suppressor/platforms |
搜索顺序取自 AFSIM File Commands 页的说明(它说这套算法和 C/C++ 预处理器一致):
先做 $(name) / ${name} 路径变量替换,然后绝对路径直接用,然后拼到当前正在
处理的那个文件所在目录,最后依次拼到每个 file_path 条目。declared_duration
也走同一套遍历,所以 end_time 写在被 include 的文件里一样算数——以前只读入口
文件,于是一份跑了 7200 秒的仿真一边报"用了 AFSIM 的 60 秒默认值"、一边建议加一个
已经存在且生效的 end_time。
仿真事件名
event_output 的 enable / disable 接受事件名,mission 对不认识的名字报
Invalid event name for event_output。这些名字既不在命令索引里(索引收的是命令,
不是命令的参数),也不可靠地在 HTML 文档里:documentation/html/docs/event/
有 80 页,而 AFSIM 自带 demo 里实际出现的 21 个事件有 17 个不在其中,包括
PLATFORM_BROKEN 和全部 SENSOR_TRACK_*、LOCAL_TRACK_*。
它们的声明在 SDK 头文件里。每个事件结果类给 cNAME 字符串一个字面量,那就是
event_output 接受的全集。这些类不集中在一个头文件里:核心的在
WsfEventResults.hpp,每个扩展自带一份——WsfMilEventResults.hpp 携带整个
WEAPON_* 事件族(WEAPON_FIRED、WEAPON_HIT、WEAPON_MISSED……)。
只读核心头文件会静默丢掉 55 个名字(早期版本就丢过整个武器事件族),所以索引
对 SDK include 树里所有 *EventResults*.hpp 取并集:153 个。GUI 工具的
偏好设置类也用 cNAME,但没有一个叫这个名字的文件,按文件名过滤即可精确排除。
afsim_command kind=event keyword=SENSOR_TRACK # 列出
afsim_command name=PLATFORM_BROKEN # 查一个
afsim_command name=WEAPON_FIRED # 武器事件族(扩展头文件声明)
afsim_command keyword=PLATFORM_BROKEN # 查不到命令时自动回落到事件名
每个事件还带一个 seen_in_demos 标记,表示它是否真的在 AFSIM 自带场景的输出里
出现过——153 个里有 21 个。
实例接线:类型没写在那里的那一半
afsim_types(name=...) 返回的类型事实是"这个类型是什么",但 BRAWLER_TEST 这类
战术类型族还有另一半事实藏在 demo 的入口想定里:平台实例怎么接线。指挥链
(commander / command_chain)、战术处理器读的 script_variables
(MISSION_TYPE、ROUTE.Append、WINCHESTER……)、edit processor 改配置——
这些都写在平台实例上,不写类型就不生效;入口文件自己还得带一个顶层
script_variables 全局块(log、iout 等),否则脚本上下文根本初始化不了。
省掉它们的表现完全静默:仿真照样跑、照样退出 0,只是跟踪了十分钟、一弹未发。
索引因此在构建时把 demo 入口里实例化每种类型的块解析出来(每型最多 3 份、按
完整度排序),连同入口文件的 include、命名航路和全局脚本变量一起返回。force
layout 常被入口委托给被 include 的 scenarios/<名字>.txt(往往经
${SCNRIO} 路径变量),而序言在父入口里——索引沿 include 链把父入口的
$define、序言逐行合并下来,并按"序言前 / 序言后"拆分父入口的 include:
消耗这些全局变量的 observer 必须在序言之后 include,放错位置脚本上下文就
初始化失败:
afsim_types(name="BLUE_FIGHTER")
→ demo_instantiations:
file: air_to_air/scenarios/2v2.txt
wiring: commander SELF, command_chain IFLITE SELF, ...
script_variables: START_TYPE = "route"; MISSION_TYPE = "SWEEP"; ...
entry: includes [platforms/lte_fighter.txt, observer.txt, ...]
global_script_variables [closest_hostile, log_print, iout, ...]
照抄一份就是"这个类型被使用的方式"。配套地,analyze_run 把"跟踪很多、零开火"
单列为一个诊断项(tracking_without_engagement),并把原因指向实例接线而不是
让报告看起来只是运气不好。
脚本 API
战术处理器就是脚本,而脚本是写在对象上的——PLATFORM、SENSOR、WEAPON、
TRACK、PROCESSOR——以及它们的那些方法。这些此前在这个 server 上无处可查:
命令索引收的是输入文件命令,事件索引收的是事件名,所以"WsfTrack 有哪些方法"
在哪儿都没有答案。把一个处理器接起来,只能猜标识符,然后反复运行 mission 直到
它不再说 Unknown identifier。
产品自己文档化了:documentation/html/docs/script/ 330 页,295 页是 WSF 类,
每页列出方法、返回类型、参数和说明;另外 208 个模型页在文档根目录也带脚本方法。
本项目把两处一起读,得到 329 个类、4551 个方法:
afsim_command kind=script name=WsfTrack # 列出方法
afsim_command kind=script name=TrackQuality # 按方法名反查是哪些类的
afsim_command kind=script keyword="track quality" # 搜参数和说明正文
afsim_command kind=script # 列全部类
WsfAssetManager 的方法里有一组是 GetEWRequired / GetTARRequired /
GetTTRRequired / GetWeaponRequired——这就是"传感器没指定分类,资产永远不合格,
于是永远不开火"那条规则的接口面。现在它可查了。
WsfTrack.TrackQuality() 会连文档说明一起返回:"Return the track's quality as
a value in the range [0..1]"。
一个处理器要配成什么样才会开火
处理器出厂时 SENSOR_NAME / WEAPON_NAME 是空的,而 mission 拒绝在
script_variables 里重声明它们——只能在处理器内部的 on_initialize 块里赋值。
以前只返回第一条匹配,结果目录里 18 个需要外部配置的处理器,只有 1 个有示例, 其余 17 个被告知"要从脚本源码推断"——而答案就在旁边一行的派生类型里:
processor SR_SAM_TELAR_TASK_MGR TELAR_TASK_MGR
on_initialize
ACQ_SENSOR_NAME = "sr_sam_acq_radar_sensor";
SENSOR_NAME = "sr_sam_ttr_radar_sensor";
WEAPON_NAME = "sam";
...
现在按完整度排序返回全部示例,优先给出类型自己定义里那段(哪怕它在一个派生
类上),并逐条标明覆盖了哪些变量、还缺哪些。TELAR_TASK_MGR、AA_TACTICS、
SAM_BATTERY_TASK_MGR 都能直接拿到一份完整可抄的配置。
on_initialize 的存储也从"扁平赋值列表"改成"保留块边界"——实际使用时是整块照抄,
而需要 SENSOR_NAME 和 WEAPON_NAME 的处理器只有两者在同一个块里才工作。
交战包线
zone 是平台声明自己能在哪里活动、交战的地方,而 zone 的名字正是战术处理器里
ENVELOPE = "..." 要填的把手。两者必须一起给:只给范围不知道叫什么,只给名字
不知道边界。
"zones": [
{ "name": "full_kinematic", "shape": "circular",
"extent": { "maximum_radius": "6 nm", "maximum_altitude": "20000 ft" } }
]
SR_SAM_TELAR 此前两个数字都看不见——maximum_radius 根本不在关键词表里,而且
_performance 只对组件调用、平台类型从来没调用过。一个 Agent 想知道这个阵地够
不着 9 km 高 60 km 外的目标,只能靠二分二十几个几何。现在一眼可见。
zones 和性能限值都沿 base 链继承,因为交战包线通常写在基类里。657 个平台类型
里 10 个声明了 zone,314 个带性能限值。
同一个关键字在不同块里参数不同
命令索引里 5646 条命令带着 context 字段,但埋得太深。hits_to_establish_track
要两个整数,而且只在传感器的 mode 块里合法——只读语法的人写
hits_to_establish_track 3 被拒,然后断定命令写错了,而不是自己放错了块。
现在每条 form 都带 legal_in(_ 解释成 "top level")和一句说明。end_time 就有
四种形式,其中三种限定在别的块内。