
简介StableApproach 是面向 X-Plane 11 模拟飞行用户的飞行数据监控与稳定进近分析工具包适合希望复盘着陆品质、改进操纵精度的飞行爱好者与训练者。压缩包共 19 个文件约 19KB以 15 个 sacfg 飞机配置文件为主体覆盖波音、空客及塞斯纳等多类机型另含 2 个 json 参数/版本文件、1 个 md 说明文档和 1 个 gitignore结构轻量便于按机型导入和调整监控项。借助这些配置用户可针对下降率、航道对准、速度控制等着陆阶段指标设置告警阈值并结合生成的数据分析报告定位操作偏差逐步形成更稳定的进近习惯。目录按机型集中组织配合说明文档可较快理解配置含义与套用方式也方便在既有设置上做二次修改。目前已有 700 人学习适合作为 X-Plane 11 飞行数据监控入门与稳定进近专项练习的参考包。1. 落地后才发现问题出在五百英尺之前模拟飞行最骗人的地方是“感觉”。有一次我飞一个短跑道 ILS接地很轻回放一看500 英尺时速度已经超出目标 12 节下滑道低了小半个点下沉率一路挂在 1100 英尺/分——座舱里根本来不及看这些数。StableApproach 就是解决这个问题的它是挂在 X-Plane 11 上的飞行数据监控插件实时读 DataRef按稳定进近判据给每一次进近打分超限就告警落地后落一份 CSV 当黑匣子复盘。适合三类人想认真练进近的模拟飞行玩家、拿 C 写插件练手的人、给自建训练器加客观评分的人。数据链路本身不复杂一天能跑通难的是判据怎么定。2. 定位与选型稳定进近判据怎么落成 8 个 DataRef先把项目边界说清楚它不碰飞机模型、不做天气、不做航路规划只做一件事——在进近阶段持续采样把“稳不稳”变成一个可枚举的结论。所以整个工程的复杂度集中在两处第一采样要足够密、时序要对第二判据要能解释飞完你得知道自己到底哪一条没过。很多同类小工具翻车不是因为算法难而是因为把“判断”和“采样”写在了同一个函数里改一个阈值要重编整个插件还测不了。这个项目从一开始就把纯计算抽出来后面所有坑都因此好排。2.1 判据不是玄学两道高度门 七条硬指标稳定进近的标准做法是“门限判定”在 1000 英尺 AGL 做第一次检查允许继续修正到 500 英尺 AGL 做第二次检查还不满足就直接建议复飞。两个门用同一套指标只是坡度容差收紧。项目里把指标压到七条每条都能对应到一个具体 DataRef这是后续排错的基础。判据DataRef 片段门限单位航向道偏差nav1_hdef_dots_pilot绝对值 ≤ 1.0点下滑道偏差nav1_vdef_dots_pilot绝对值 ≤ 1.0点指示空速airspeed_kts_pilot目标 10 / −5节下沉率vh_ind_fpm下沉不超过 1000英尺/分坡度phi≤301000 英尺/ ≤15500 英尺度起落架gear_handle_down必须为 10/1襟翼flap_ratio达到着陆位0~1 比例速度那条我特意做成不对称的 10/−5和真实运行习惯一致慢了比快了更危险五边速度掉下去拉平的时候很容易砸。下沉率取的是“负值超限”vh_ind_fpm上升为正、下降为负代码里判断符号写反是新手最常见的错。坡度用两个门限而不是一个是因为 500 英尺以下还在压 20 度坡去对正本身就是个危险动作。2.2 三条数据通路插件 SDK、UDP RREF、日志回放要把 X-Plane 11 里的数据拿出来常见做法是三条路项目在选型上做了明确取舍。第一条是插件 SDK也就是进程内读取。编一个.xpl放进插件目录模拟器加载后你就能拿到XPLMDataRef指针20Hz 甚至 50Hz 采样都没压力还能画小窗、弹提示、写文件。代价是每个平台编一份SDK 版本和模拟器要对得上。这是项目的主路径。第二条是 UDP 的 RREF 订阅从外部进程按名字订阅 DataRef模拟器按频率把值回发过来。这条路最大的价值是不用重编插件——调阈值、试判据的那几天全靠它改完 Python 脚本直接跑。缺点也明显只能读不能画采样频率和网络栈有关而且要先在模拟器的网络设置里把 UDP 输出打开。第三条是日志回放解析已有的飞行数据输出或者录像做批量评分。它没有实时性但特别适合回归测试判据改完把上周那段进近重新灌一遍看结论有没有变。项目的做法是主用 SDK、旁用 UDP两条路共用同一份判据代码。这一点很关键判据写在不依赖任何 SDK 头的.cpp里插件和外部脚本都调它否则你迟早会发现“插件里过了、脚本里没过”这种自相矛盾的情况。2.3 运行形态与依赖一个目录三份 .xpl产物形态很简单Resources/plugins/StableApproach/下面放一个64/目录里面按平台放win.xpl、mac.xpl、lin.xpl。X-Plane 11 是纯 64 位别折腾 32 位。配置用一份limits_default.ini放在插件目录里按机型覆盖门限——用户重装插件时配置文件跟着走比塞进系统目录靠谱。依赖尽量清零。C17 加标准库足够日志用 SDK 自带的XPLMDebugString落到Log.txtCSV 用std::ofstream。窗口想画就挂一个即时模式 GUI 库不想画就只留日志和语音提示编译体积能压到几百 KB。第三方动态库能不加就不加Windows 上缺一个运行库 dll插件就直接不加载而且 Log 里往往只有一行看不出原因的报错。3. 核心链路拆解从 FlightLoop 采样到 CSV 落盘的四个模块整条链路是“回调触发 → 读八个值 → 算一个结论 → 状态机去重 → 按秒落盘”。看起来只有四步但每一步都有一个具体的坑拆开看比较清楚。3.1 目录结构为什么把计算单独拆一个 .cppStableApproach/ ├── src/ │ ├── plugin.cpp # 生命周期、菜单、FlightLoop 注册与回调 │ ├── dataref_bind.cpp # 只做一件事名字 - 指针 │ ├── stability.h # Sample / Limits / Reason 定义 │ ├── stability.cpp # 纯计算不 include 任何 XPLM 头 │ ├── logger.cpp # CSV 打开、节流写盘、事件去重 │ └── config.cpp # 读机型门限 ini ├── data/ │ └── limits_default.ini ├── build/ └── CMakeLists.txtstability.cpp不引用 SDK 是整个结构的关键。它只吃一个Sample结构体、吐一个Verdict所以可以直接在普通电脑上编一段 main 跑单测把“500 英尺、速度超 12 节、坡度 18 度”这种组合喂进去看结论对不对。真机上复现一个特定进近状态很难靠单测省下的时间远比拆文件的那点多。dataref_bind.cpp单独拆出来是因为 DataRef 名字是运行时字符串最典型的故障就是名字写错。把它关在一个文件里出问题时就查这一个地方。3.2 DataRef 绑定名字写错一次后面全是 0绑定只在插件启动时做一次之后循环里只用缓存的指针。每次XPLMFindDataRef内部都要走字符串哈希20Hz 乘八个值不算贵但没必要而且飞行循环里做字符串查找会放大帧时间抖动。// dataref_bind.cpp只在插件启动时跑一次之后循环里只读指针 #include XPLMDataAccess.h #include stability.h static XPLMDataRef dr_agl nullptr, dr_ias nullptr, dr_vs nullptr; static XPLMDataRef dr_roll nullptr, dr_loc nullptr, dr_gs nullptr; static XPLMDataRef dr_gear nullptr, dr_flap nullptr; static void Bind(const char* name, XPLMDataRef slot) { slot XPLMFindDataRef(name); if (!slot) { // 把名字一起打出来否则排错只能靠猜 XPLMDebugString([StableApproach] dataref NOT FOUND: ); XPLMDebugString(name); XPLMDebugString(\n); } } void BindAll() { Bind(sim/cockpit2/gauges/indicators/radio_altimeter_height_ft_pilot, dr_agl); Bind(sim/cockpit2/gauges/indicators/airspeed_kts_pilot, dr_ias); Bind(sim/flightmodel/position/vh_ind_fpm, dr_vs); Bind(sim/flightmodel/position/phi, dr_roll); Bind(sim/cockpit2/radios/indicators/nav1_hdef_dots_pilot, dr_loc); Bind(sim/cockpit2/radios/indicators/nav1_vdef_dots_pilot, dr_gs); Bind(sim/cockpit2/controls/gear_handle_down, dr_gear); Bind(sim/cockpit2/controls/flap_ratio, dr_flap); } Sample ReadSample() { Sample s{}; s.agl_ft dr_agl ? XPLMGetDataf(dr_agl) : -1.0f; // -1 表示这帧没数据 s.ias_kt dr_ias ? XPLMGetDataf(dr_ias) : 0.0f; s.vs_fpm dr_vs ? XPLMGetDataf(dr_vs) : 0.0f; s.roll_deg dr_roll ? XPLMGetDataf(dr_roll) : 0.0f; s.loc_dots dr_loc ? XPLMGetDataf(dr_loc) : 0.0f; s.gs_dots dr_gs ? XPLMGetDataf(dr_gs) : 0.0f; s.gear_down dr_gear ? XPLMGetDatai(dr_gear) : 0; // 整型必须用 GetDatai s.flap_ratio dr_flap ? XPLMGetDataf(dr_flap) : 0.0f; return s; }几个参数值得单独说。radio_altimeter_height_ft_pilot是无线电高度单位英尺正好对应“1000 英尺门”和“500 英尺门”不要用海压高度去减机场标高山区机场会差出几十英尺。nav1_hdef_dots_pilot是航向道偏离的“点”满刻度一般 2 点取绝对值比 1 是常规阈值。起落架那个是整型用XPLMGetDataf读整型内存拿到的可能是个极小值而不是 1这个错不崩但判据永远是“没放轮”。ReadSample里所有读取都判空宁可返回 −1 让上层跳过这一帧也不要拿默认 0 去算——高度 0 会被当成“已落地”整个进近记录直接断掉。3.3 判据计算把一堆浮点数压成一个 Verdict判据函数的输出不是一个布尔值而是一个三态加一个原因位图。原因位图是为了复盘光告诉用户“不稳定”没用要能说出是快了、低了还是坡度大了CSV 里存一位十六进制数落地后一眼能看出主要矛盾。// stability.cpp不依赖任何 XPLM 头可以在普通 PC 上单测 #include stability.h Verdict Evaluate(const Sample s, const Limits lim, float target_ias, unsigned why) { why R_NONE; if (!s.gear_down) why | R_GEAR; if (s.flap_ratio 0.90f) why | R_FLAP; // 襟翼未到着陆位 if (s.ias_kt target_ias lim.ias_plus) why | R_FAST; if (s.ias_kt target_ias - lim.ias_minus) why | R_SLOW; if (s.vs_fpm -lim.vs_max) why | R_SINK; // 下沉是负值 if (std::fabs(s.loc_dots) lim.loc_max) why | R_LOC; if (std::fabs(s.gs_dots) lim.gs_max) why | R_GS; // 坡度门限随高度收紧1000 英尺以上宽500 英尺以下严 const float bankLimit (s.agl_ft 500.0f) ? lim.bank_1000 : lim.bank_500; if (std::fabs(s.roll_deg) bankLimit) why | R_BANK; if (why R_NONE) return Verdict::Stable; if (s.agl_ft 500.0f) return Verdict::GoAround; // 过了 500 英尺还没稳建议复飞 return Verdict::Deviating; // 1000 英尺门还有修正机会 }参数含义target_ias是这类机型在这个重量下的目标进近速度从配置文件读不写死lim是一组容差默认值速度 10/−5、下沉 1000、偏差 1 点、坡度 30/15why是引用传出调用方拿它做告警文案和 CSV 归因。Verdict::Deviating和GoAround分开是有意的。1000 英尺门不满足只是“偏离”提示一下还能救500 英尺门不满足就是“该复飞了”这时候再温和地提示等于没提示。三态而不是两态是这套判据里我最满意的一个设计因为它符合真实飞行的决策节奏。3.4 告警与落盘状态机 1Hz 写盘飞行循环回调是 20Hz如果每帧都播报屏幕和喇叭都会炸。做法是加一层状态机只在结论发生跃变时告警一次落盘则单独节流到 1Hz既保证曲线够密文件又不会几十兆。// plugin.cppFlightLoop 回调0.05 秒一次 float FlightLoopCB(float, float, int, void*) { if (XPLMGetDatai(dr_paused)) return 0.2f; // 暂停时别采样避免假数据进 CSV Sample s ReadSample(); if (s.agl_ft 0.0f) return 0.05f; // 这帧没绑上直接跳过 unsigned why 0; Verdict v Evaluate(s, gLimits, gTargetIas, why); static int last -1; if ((int)v ! last) { // 只在结论变化时喊一次 Announce(v, why); // 写 Log 可选语音/小窗 last (int)v; } gLogger.Tick(s, v, why); // 内部节流到 1Hz 落盘 return 0.05f; // 0.05 秒后再叫我即 20Hz }return 0.05f是“下次调用的间隔秒数”返回 0 会停掉循环返回 −1 是“下一帧再来”。暂停判断用的是sim/time/paused这一步不做的话你在菜单里暂停十分钟CSV 里就会出现一段高度不变、速度不变的僵尸数据复盘时很难解释。CSV 的列建议固定成t_sim, agl_ft, ias_kt, vs_fpm, roll_deg, loc_dots, gs_dots, verdict, reasons。t_sim用模拟器内部运行时间而不是系统时钟否则暂停、加速、重载场景之后时间轴会乱跳。原因列存why的十进制或十六进制值配一张位含义表落地后想定位哪一条超限筛选一下就行。4. 跑起来SDK 编译、插件落位与 UDP 无插件联调这一段是照着做就能出结果的部分。建议顺序是先跑 UDP 脚本确认数据通再编插件最后进模拟器验证——反过来做你会在“插件没加载”和“数据没读到”之间反复怀疑人生。4.1 环境准备SDK 头文件和 64 位编译器从官方开发者页面下载 SDK 压缩包解压后只需要CHeaders/XPLM用到窗口再加CHeaders/Widgets。编译器按平台来Linux 和 macOS 用系统自带的 clang 或 gccWindows 用 MSVC 建一个 DLL 工程。C17 标准别用太老的编译器std::filesystem之类的东西在新标准里省事。一个容易忽略的点Linux 上编译插件要把 libstdc 静态链进去否则用户机器上的运行库版本对不上插件加载失败还不一定报错。加上-static-libstdc -static-libgcc两个参数体积大一点但省掉一整类玄学问题。4.2 编译与落位一条 g 命令和三个路径# Linux / macOS(x86_64) 最小构建SDK 路径按自己解压位置改 export XP_SDK$HOME/sdk/XPSDK mkdir -p build/StableApproach/64 g -stdc17 -O2 -fPIC -shared \ src/plugin.cpp src/dataref_bind.cpp src/stability.cpp src/logger.cpp \ -I$XP_SDK/CHeaders/XPLM \ -static-libstdc -static-libgcc \ -o build/StableApproach/64/lin.xpl # 组目录再拷进模拟器 mkdir -p $XPLANE/Resources/plugins/StableApproach/64 cp build/StableApproach/64/lin.xpl $XPLANE/Resources/plugins/StableApproach/64/ cp data/limits_default.ini $XPLANE/Resources/plugins/StableApproach/-shared出的是共享对象改后缀成.xpl就行。Windows 上编 DLL 之后改名win.xplmacOS 编出来的动态库改名mac.xplmacOS 上注意编 x86_64 架构只编 arm64 的话在多数机器上加载不了。三个平台的文件名是硬约定放错目录或者名字拼错模拟器会安静地什么都不做。装好之后进模拟器插件菜单里应该能看到条目。看不到就去Log.txt末尾翻插件加载失败一般会留一行没有的话说明路径就是错的。4.3 无插件联调用 40 行 Python 先订阅 DataRef调判据那几天我最常干的事就是一边飞一边改阈值这时候重编插件太慢。UDP 订阅可以直接把四个关键值打到终端上改脚本不用重启模拟器。# rref_probe.py不写插件先确认数据读得到 import socket, struct, time HOST, PORT 127.0.0.1, 49000 NAMES [ sim/cockpit2/gauges/indicators/radio_altimeter_height_ft_pilot, sim/cockpit2/gauges/indicators/airspeed_kts_pilot, sim/flightmodel/position/vh_ind_fpm, sim/flightmodel/position/phi, ] sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, PORT)) sock.settimeout(2.0) # 订阅包5 字节头 采样频率 若干 (序号, 400 字节定长名字) pkt bRREF\x00 struct.pack(i, 20) for i, name in enumerate(NAMES): pkt struct.pack(i, i) name.encode(ascii).ljust(400, b\x00) sock.sendto(pkt, (HOST, PORT)) deadline time.time() 10 while time.time() deadline: data, _ sock.recvfrom(4096) body data[5:] # 跳过回包头部 for off in range(0, len(body) - 7, 8): # 每条 4 字节序号 4 字节浮点 idx, val struct.unpack_from(if, body, off) print(f{NAMES[idx]:66s} {val:10.3f})几个细节名字必须是 400 字节定长少了模拟器会直接忽略整包序号是自己编的回包里靠它对应名字所以订阅顺序和解析顺序要一致收到全是 0 的包说明订阅成功但飞机静止或者值就是 0一个包都收不到就是端口或者 UDP 输出没开。采样频率填 20 就够了太高对判据没帮助反而增加丢包。4.4 三分钟验证让数据“该动的时候动”绑定完别急着飞进近先在地面做几个动作确认每个值都活着。这一步花三分钟能省掉后面半小时的自我怀疑。动作应该变化的量说明地面推油门空速、地速上涨数不动说明空速 DataRef 名字错拉杆抬机头升降速度为正值符号反了说明用错了垂直速度源压 20 度坡度坡度值随之变化数值单位是度不是弧度放起落架起落架从 0 变 1一直是 0 说明整型读成了浮点调 NAV1 到 ILS 且偏离航向道点数为非零全 0 说明没截获或电台没调对五项全过数据链路就是通的。这时候再把飞机放到 1000 英尺、五边 3 度下滑道上看Evaluate是不是给出 Stable——如果地面五项都对、进近还是报不稳定问题一定在判据参数上不用再回头查数据。5. 避坑与排查五个让判据失真的现场问题下面五条都是我在真机上撞过的按“现象 → 原因 → 解决本文还有配套的精品资源点击获取