
简介StableApproach 是面向 X-Plane 11 飞行模拟爱好者和飞行训练者的飞行数据监控与稳定进近分析配置包主要用于记录并评估进近着陆阶段的速度、高度、下降率、航道对准、姿态与推力等关键参数帮助用户定位操作偏差、改进飞行技巧。压缩包共 19 个文件约 19KB以 15 个 sacfg 机型配置文件为主另含 2 个 json 参数/版本文件、1 个 md 说明文档和 1 个 gitignoresacfg 可用于不同机型的监控阈值定制json 与说明文档便于了解配置结构和导入方式。已有约 700 人学习适合希望系统复盘模拟飞行表现、提升进近稳定性的初中级模拟飞行玩家。通过调整各机型配置中的警告阈值与监控范围读者可围绕着陆稳定性建立可复用的分析流程并结合生成的数据报告查看着陆阶段的偏差从而在虚拟飞行中形成更规范的操作习惯。1. 在最后 200 英尺才发现偏了复盘只能靠回忆模拟飞行里最容易骗自己的环节就是进近。手飞一段 ILS目视看跑道在风挡里挺正接地那一刻才发现速度比 VREF 高了十几节下降率表指着 900 英尺每分航向道其实早偏了一个点。麻烦在于 X-Plane 11 只给你当下的仪表读数落地之后这些数字全没了想复盘只能凭记忆。StableApproach 补的正是这一段它在 X-Plane 11 进程内持续做飞行数据监控按真实航司的进近达标判据在 1000 英尺和 500 英尺各判一次落地后输出一份带接地速度、接地下降率、坡度、偏差的报告。适合正在练手飞进近、又不想每次都请真人坐旁边盯着的开发者。2. 技术选型与工程骨架Lua 脚本怎么在 X-Plane 11 进程内做实时监控先把定位说清楚它不是一个落地后去解析日志的外部分析工具而是挂在模拟器内部、每帧读 dataref 的实时监控层。这个差别决定了整个技术栈。外部程序走 UDP 拿数据天然多一层跨进程延迟接地瞬间那几帧最容易丢而放在进程内接地前后每一帧都能抓到。所以选型基本被锁死在 FlyWithLua 上Lua 5.1 的运行环境由插件自带脚本放进Scripts目录就被加载dataref()一行就能订阅任意仿真变量绘制、日志、文件读写全是标准库。代价也要认Lua 是单线程每帧的预算可能只有几百微秒任何字符串拼接、文件 IO 都不能放进无条件执行的路径里。这一点在后面讲避坑时会反复出现。2.1 目录结构与文件职责一个监控插件最怕写成两千行的单文件改一条阈值要翻半天。常见做法是按「数据 → 窗口 → 判据 → 输出」四层拆开主脚本只负责把它们串起来文件职责改动频率StableApproach.lua入口注册回调串联各模块低sa_datarefs.lua集中声明所有 dataref 与单位换算低sa_sampler.lua定频采样、写入环形缓冲低sa_state.luaIDLE/APPROACH/FLARE/ROLLOUT 状态机中sa_criteria.lua判据表纯数据高sa_config.luaVREF、判决高度、跑道表高Output/StableApproach/landings.csv每次落地的记录运行时生成sa_config.lua和sa_criteria.lua之所以单独拆出来是因为它们才是实际使用中反复调的东西。换机型只改 VREF换标准只改阈值业务代码一行不用碰。FlyWithLua 支持dofile按相对路径加载同级文件模块返回一个 table 就够用。2.2 数据源dataref 声明与采样节拍X-Plane 11 的 dataref 命名分得极细同一个物理量往往有好几个版本选错了读数就是错的。这里的原则是高度用无线电高度而不是气压高度速度用座舱指示空速而不是真空速偏差用「点」为单位而不是原始偏航距。-- sa_datarefs.lua所有外部数据在这里一次性声明不散落到业务代码里 -- 第三个参数 readonly 表示只读写操作会被 FlyWithLua 直接拒绝 dataref(AGL_M, sim/flightmodel/position/y_agl, readonly) -- 无线电高度单位米 dataref(VS_FPM, sim/flightmodel/position/vh_ind_fpm, readonly) -- 垂直速度英尺/分 dataref(IAS_KT, sim/cockpit2/gauges/indicators/airspeed_kts_pilot, readonly) -- 指示空速节 dataref(ROLL_DEG, sim/flightmodel/position/phi, readonly) -- 坡度度 dataref(LOC_DOTS, sim/cockpit2/radios/indicators/nav1_hdef_dots_pilot, readonly) -- 航向道偏差点 dataref(GS_DOTS, sim/cockpit2/radios/indicators/nav1_vdef_dots_pilot, readonly) -- 下滑道偏差点 dataref(FLAP_RATIO, sim/cockpit2/controls/flap_ratio, readonly) -- 襟翼手柄位置 0~1 dataref(GEAR_DOWN, sim/cockpit2/controls/gear_handle_down, readonly) -- 起落架手柄 1放下 dataref(ON_GROUND, sim/flightmodel/failures/onground_any, readonly) -- 任意机轮接地 dataref(RUN_TIME_S, sim/time/total_running_time_sec, readonly) -- 模拟器运行秒数用于节拍这里踩过的最典型一坑是y_agl的单位是米而所有人的判据表都是英尺写的1000 英尺的判决点如果忘了乘 3.28084脚本会在 305 英尺才触发判决看起来像是「完全不工作」。采样节拍不能跟着帧率走。帧率 60 和帧率 25 下同样的「连续 10 帧超限」代表的时长完全不同判据就失去了意义。所以用模拟器时间自己做节流-- sa_sampler.lua自己控节拍20Hz与帧率解耦 local SAMPLE_HZ 20 local last_sample 0 function sa_sample_tick() -- RUN_TIME_S 是模拟时间不是墙钟暂停时不会继续累加 if RUN_TIME_S - last_sample 1.0 / SAMPLE_HZ then return end last_sample RUN_TIME_S sa_push({ t RUN_TIME_S, agl_ft AGL_M * 3.28084, -- 米换英尺判据表内统一用英尺 vs VS_FPM, ias IAS_KT, roll ROLL_DEG, loc LOC_DOTS, gs GS_DOTS, flap FLAP_RATIO, gear GEAR_DOWN, }) sa_state_update() -- 采样后立刻喂状态机保证两者看到同一帧数据 end do_every_frame(sa_sample_tick()) -- 每帧调用但内部按时间戳节流SAMPLE_HZ是最值得调的参数。10Hz 足够判断 1000 英尺和 500 英尺的判决点但接地瞬间的峰值可能被抹平20Hz 是一个折中一秒钟 20 个样本环形缓冲占的内存可以忽略。do_every_frame在这个用法下并没有想象中危险因为函数体第一行就返回了真正干活的只有每秒 20 次。真正危险的是把string.format和io.write直接写进这个函数。2.3 状态机为什么不能用一堆 if 堆出来早期版本我用if agl 1000 then check() end直接判结果落地滑跑阶段高度在 0 附近抖动判定被反复触发日志刷了几百行HUD 上的结论在「达标」和「不达标」之间来回跳。这类玄学问题的根子在于所有判断都基于瞬时值而状态本身没有记忆。四个状态就够用待机、进近、拉平、滑跑。关键是每个状态只在一处跃迁跃迁时做一次动作绝不重复。-- sa_state.lua四态机跨线才动作天然抗抖动 local ST { IDLE 0, APPROACH 1, FLARE 2, ROLLOUT 3 } local state ST.IDLE function sa_state_update() local s sa_latest() -- 取刚才那一帧 if state ST.IDLE then -- 低于 3000 英尺 起落架放下 空速低于 200 节认为进入进近 if s.agl_ft 3000 and s.gear 1 and s.ias 200 then state ST.APPROACH sa_reset_window() end elseif state ST.APPROACH then if s.agl_ft 30 then state ST.FLARE elseif s.agl_ft 3500 then state ST.IDLE -- 复飞或爬升离场退回待机 end elseif state ST.FLARE then if ON_GROUND 1 then state ST.ROLLOUT sa_on_touchdown() -- 结算上一帧数据不能用当前帧 end elseif state ST.ROLLOUT then if s.ias 30 then sa_write_report() -- 滑跑到低速才落盘避免半截数据 state ST.IDLE end end endST.FLARE和ST.ROLLOUT分开是有原因的。接地瞬间ON_GROUND从 0 变 1 的那一帧垂直速度往往已经被地面法向力抹掉真正的接地下降率在上一帧。所以状态机在跃迁时调用sa_on_touchdown()函数内部取的是缓冲里倒数第二个样本。这个细节直接决定了报告里的「接地下降率」是不是废数据。3. 核心判定链路从 20Hz 采样到一份着陆报告的完整推导整条链路的顺序是固定的采样进环形缓冲 → 需要时取窗口中位数 → 在判决高度取快照 → 逐条跑判据 → 记录失败项 → 落地后结算成一行 CSV。每一环都有它存在的理由砍掉任何一环都会在某个具体场景下翻车。3.1 滑动窗口单帧数据不能直接拿来做判断X-Plane 11 的vh_ind_fpm在乱流和低帧率下会有明显的尖峰某一帧跳到 -1600 再把下一帧拉回 -700如果按瞬时值判这架飞机会在 500 英尺被判不达标而飞行员什么也没做错。更隐蔽的是航向道偏差飞机压着航向道飞偏差值会在 0 附近以 ±0.15 点的幅度抖某帧恰好 1.02 点就误判了。解法是取窗口内的中位数而不是均值。均值会被尖峰拉偏中位数直接把离群点丢掉。窗口长度 1 秒、20 个样本对进近判据来说足够。-- sa_window.lua1 秒滑动窗口20 个样本取中位数抗单帧毛刺 local N 20 local buf, idx, cnt {}, 1, 0 function sa_reset_window() buf, idx, cnt {}, 1, 0 end function sa_push(s) buf[idx] s idx idx % N 1 if cnt N then cnt cnt 1 end end -- 按字段取中位数调用方只关心这个量现在是多少 function sa_window(key) if cnt 0 then return 0 end local tmp {} for i 1, cnt do tmp[i] buf[i][key] end table.sort(tmp) return tmp[math.floor((cnt 1) / 2)] endsa_reset_window()必须在进入APPROACH时调用一次否则上一段航程的残留样本会污染新窗口出现刚进近就判不达标的怪现象。table.sort在这只有 20 个元素开销可以接受如果想把采样提到 50Hz就该换成定长插入排序或者直接维护一个局部最大值别在每帧做全排序。3.2 判据表把 SOP 写成数据而不是代码真实航司的进近标准大同小异在 1000 英尺仪表条件或 500 英尺目视条件必须同时满足若干条件任一条不满足就是不达标要么复飞要么修正后重新判定。把这些条件写进if-else是最容易腐化的做法因为每加一条就要动一次函数体测试成本成倍上升。改成表之后判据就是数据新增一条只是加一行判据字段判决高度阈值说明起落架gear1000 ft 1手柄必须在放下位襟翼flap1000 ft≥ 0.9襟翼接近着陆位速度ias1000 ft≤ VREF10相对值随机型变下降率vs500 ft≥ -1000 fpm绝对值上限 1000坡度roll500 ft≤ 15°目视段更严航向道loc500 ft≤ 1.0 点双座取偏差大的一侧下滑道gs500 ft≤ 1.0 点同上-- sa_criteria.lua判据即数据加一条判据只加一行 -- cmp 说明越界方向lt 小于即错gt 大于即错absgt 取绝对值后大于即错 -- ref vref 表示阈值是相对 VREF 的偏移量运行时再算 return { { idGEAR, fieldgear, cmplt, limit1, at1000, label起落架未放下 }, { idFLAP, fieldflap, cmplt, limit0.9, at1000, label襟翼未到着陆位 }, { idSPEED, fieldias, cmpgt, limit10, at1000, refvref, label速度高于 VREF10 }, { idSINK, fieldvs, cmplt, limit-1000, at500, label下降率超过 1000fpm }, { idBANK, fieldroll, cmpabsgt, limit15, at500, label坡度超过 15 度 }, { idLOC, fieldloc, cmpabsgt, limit1.0, at500, label航向道偏差超过 1 点 }, { idGS, fieldgs, cmpabsgt, limit1.0, at500, label下滑道偏差超过 1 点 }, }判据表只描述「什么是错」判定函数只负责按cmp分支两者职责不混-- 在指定高度做一次判决返回是否达标 失败项列表 function sa_evaluate(agl_target, snap, vref) local fails {} for _, c in ipairs(CRITERIA) do if c.at agl_target and snap[c.field] ~ nil then local lim c.limit if c.ref vref then lim vref c.limit -- 相对阈值VREF 偏移量 end local v snap[c.field] local bad (c.cmp lt and v lim) or (c.cmp gt and v lim) or (c.cmp absgt and math.abs(v) lim) if bad then fails[#fails 1] c.label end end end return #fails 0, fails endsnap是从窗口里取中位数拼出来的快照表不是单帧原始值vref从配置里来随机型和着陆重量变化。返回失败项列表而不是布尔值是为了让 HUD 能显示「速度高于 VREF10、下滑道偏差 1.3 点」这样的具体原因——只告诉飞行员「不达标」等于没说。还有一点容易忽略at是精确匹配所以必须在AGL_M穿越 1000 英尺和 500 英尺的那一帧触发采样快照而不是等到「小于 1000 之后随便一帧」。做法是在状态机里记一个last_agl检测last_agl 1000 and now 1000这样的下穿事件-- 下降穿越检测保证判决点只触发一次 local function crossed(cur, prev, line) return prev ~ nil and prev line and cur line end function sa_check_crossing(s) if crossed(s.agl_ft, last_agl, 1000) then last_verdict { ok, fails sa_evaluate(1000, sa_snapshot(), SA.vref) } elseif crossed(s.agl_ft, last_agl, 500) then last_verdict { ok, fails sa_evaluate(500, sa_snapshot(), SA.vref) } end last_agl s.agl_ft end3.3 告警与落地报告HUD 一行字和 CSV 一行记录输出分两种走的是完全不同的路径。实时告警用 FlyWithLua 的画字接口直接画在屏幕上不写日志——写日志会被Log.txt刷屏而且日志窗口在进近时挡视线。落地报告走文件追加一次落地只写一行。-- 实时 HUD只在非待机状态绘制坐标是屏幕像素 function sa_draw() if state ST.IDLE then return end local txt string.format( AGL %4.0fft VS %5.0f IAS %3.0f ROLL %4.1f LOC %.2f GS %.2f %s, sa_window(agl_ft), sa_window(vs), sa_window(ias), sa_window(roll), sa_window(loc), sa_window(gs), (last_verdict and (last_verdict.ok and STABLE or GO AROUND)) or -----) draw_string(txt, 20, 20) end do_every_frame(sa_draw())draw_string的坐标以像素为单位跟着窗口分辨率走所以在 1080p 上调好的位置到 4K 会偏到屏幕外。想省事就按SCREEN_WIDTH算比例别写死20, 20。另外这个函数同样在每帧调用string.format每次都跑实测在低端机上会占掉一两个百分点的帧率。常见做法是只在数值变化时重拼字符串缓存上一次的结果。落地报告更简单但字段设计要想清楚因为它决定了你以后能不能做纵向对比-- 结算函数里预先算好 td 表这里只负责落盘 function sa_write_report() local td sa_last_landing local f io.open(SCRIPT_DIR .. /Output/StableApproach/landings.csv, a) if not f then return end f:write(string.format(%s,%s,%.0f,%.0f,%.1f,%.2f,%.2f,%s\n, os.date(%Y-%m-%d %H:%M:%S), td.rwy, -- 跑道标识来自配置里的跑道表 td.td_vs, -- 接地下降率取接地前一帧 td.td_ias, -- 接地指示空速 td.td_bank, -- 接地坡度 td.loc_dev, -- 接地时航向道偏差 td.vs_50ft, -- 50 英尺时下降率判断拉平质量的抓手 td.verdict)) -- STABLE / GO_AROUND endvs_50ft这个字段是我后来才加的它比接地下降率更能说明拉平手法接地率低但 50 英尺下降率很大说明是在跑道上「等着」而不是主动拉平长期这么飞对机体不友好。CSV 用追加模式、落地后一次性写入避免飞行过程中反复开关文件句柄。4. 跑起来FlyWithLua 安装、参数配置与一段可复现的验证航段4.1 安装与首次加载的三个确认点环境准备不复杂但有三处必须逐一确认跳过任何一个都会得到「脚本装好了但没反应」。# 1) 确认 FlyWithLua 已经装好并且被加载过 ls X-Plane 11/Resources/plugins/FlyWithLua/Scripts # 2) 把项目整体拷进 Scripts 下的独立子目录别把文件平铺进去 cp -r StableApproach X-Plane 11/Resources/plugins/FlyWithLua/Scripts/ # 3) 启动后看 Log.txtFlyWithLua 的加载信息和 Lua 报错都写在这里 grep -i flywithlua X-Plane 11/Log.txt | tail -n 40第一点Scripts目录必须存在。FlyWithLua 的安装包解压后如果只有Modules没有Scripts说明版本装错了脚本永远不会被加载。第二点子目录里要有主脚本文件FlyWithLua 会扫描Scripts下的.lua文件。放进子目录是为了让dofile的相对路径成立同时方便整包删除。文件名不要带空格和中文某些系统上会解析失败。第三点Log.txt里如果出现FlyWithLua: Error ...那就是 Lua 语法或运行时错误括号和行号都写得很清楚。最常见的报错是attempt to index a nil value一般意味着某个dofile返回了nil检查模块文件最后是不是忘了return。启动进游戏后在 FlyWithLua 菜单里能看到脚本名字说明加载成功。如果菜单里没有别急着改代码先确认菜单本身有没有打开——这是一个非常丢人的排查经历。4.2 配置怎么调VREF、判决高度与跑道表配置集中在一个 Lua 表里改完不用重启模拟器用 FlyWithLua 的 reload 功能重载脚本即可。但注意 reload 之后部分全局变量会残留状态机会停在上一次的状态所以重载后先手动把飞机放到停机位等状态机回到 IDLE。-- sa_config.lua机型相关的量全放这里判据代码不动 SA { vref 140, -- 着陆参考速度节随机型和着陆重量走 check_imc_ft 1000, -- 仪表条件下的判决高度 check_vmc_ft 500, -- 目视条件下的判决高度 sink_limit_fpm 1000, -- 下降率上限正数表示不超过 bank_limit_deg 15, -- 500 英尺坡度上限 dot_limit 1.0, -- 航向道/下滑道偏差上限单位点 touchdown_vs_lim 300, -- 报告里标重接地的提示线英尺每分 sample_hz 20, -- 采样频率 runways { -- 跑道表用于算接地点距离键名自定坐标用十进制度 [DEMO/36] { thr_lat 30.00, thr_lon 120.00, elev_ft 30, length_m 3200 }, }, }vref是最需要认真的一个值。写小了判据永远显示速度偏高你会以为脚本坏了写大了超速根本判不出来。正确做法是按当前着陆重量查机型手册一般比失速速度高 1.3 倍。check_imc_ft和check_vmc_ft的区别在于天气能见度差、云底低的时候用 1000 英尺这一档目视条件好可以用 500 英尺两者不是同时生效的用哪个取决于你在模拟什么运行环境。runways这张表是可选功能。不填也能跑只是报告里没有「接地点距离跑道入口多少米」这一项。要填就得填准坐标差 0.01 度就是一千米量级的误差算出来的距离毫无意义。4.3 首飞验证一段 15 分钟就能跑完的复现航段装完不要直接上长航线先做一个可控的验证把每一环都逼出来。第一步选一条有 ILS 的跑道天气设成静风、能见度 10 公里以上重量配到中等。第二步从 8 海里外建立盲降自动驾驶跟到 1500 英尺然后断开手飞。第三步故意在 1200 英尺时把速度加到 VREF20观察 1000 英尺的判决是否报「速度高于 VREF10」。第四步在 700 英尺时故意压一个 20 度的坡度看 500 英尺的判决是否报坡度超标。第五步正常落地去看 CSV 里是不是多了一行。这套动作能把状态机跃迁、两次判决点、失败项渲染、文件写入四条路径全部走一遍。如果第四步没报八成是crossed的下穿检测写成了导致在边界帧反复触发又被覆盖如果第五步没写文件八成是SCRIPT_DIR拼接的路径目录不存在io.open返回了nil而代码里只写了if not f then return end静默失败了。验证通过之后再去跑真实航线。我一般还会开着tail -f Log.txt跑第一趟看有没有零散的运行时错误被吞掉。5. 避坑与排查五次让我在接地前翻车的具体问题5.1 现象HUD 一直显示待机进近全程不触发原因AGL_M的单位是米而进入进近的判断写的是agl_ft 3000实际拿到的是agl_m的值305 米换算前相当于 1000 英尺飞机在 900 米高度就已经满足条件起飞爬升阶段就被误触发然后又因为gear和速度条件不满足退回 IDLE反复横跳进近时反而卡在一个中间态。解决在sa_datarefs.lua里就把单位换算做掉对外只暴露agl_ft业务代码里不允许出现* 3.28084。这条规矩看起来啰嗦但能一次性消灭一整类错误。5.2 现象报告里的接地下降率是 0 或者个位数原因ON_GROUND变 1 的那一帧vh_ind_fpm已经被地面法向力抹平读出来接近 0。判定逻辑在跃迁时读的是当前帧而不是接地前的最后一帧。解决环形缓冲天然就是后悔药sa_on_touchdown()里取倒数第二个样本作为接地值同时用倒数第三个样本校验两者差异超过 200 fpm 就标记该条记录可疑。X-Plane 的回放功能可以精确复现接地瞬间用它对比一次就能确认取的是哪一帧。5.3 现象进近阶段帧率从 60 掉到 22原因sa_draw()在每帧做一次string.format加上sa_sample_tick()无条件执行两项叠加。更糟的是有人在采样函数里顺手写了io.write做调试输出磁盘 IO 直接卡在渲染线程上。解决采样函数第一行先做时间戳比较不满足就return绘制函数缓存上一次拼好的字符串数值变化超过阈值才重拼所有日志只在状态跃迁时写一行绝不逐帧写。5.4 现象航向道和下滑道偏差恒为 0判据永远通过原因nav1_hdef_dots_pilot需要 NAV1 实际调谐到 ILS 频率才有值忘了调频率、或者飞机默认调的是 VOR读出来就是 0。另一种情况是在右座飞读的却是pilot侧值也不对。解决判定前先看偏差值是否有效真机上这类 dataref 在无信号时会返回 0 而不是 NaN所以不能靠判空得结合 NAV1 频率和「是否处于进近状态」一起看。稳妥做法是同时订阅pilot和copilot两套取绝对值大的那个。5.5 现象改了阈值但行为没变原因FlyWithLua 的 reload 只重载脚本sa_criteria.lua用dofile加载后返回的 table 被局部变量持有重载时机不对就会读到旧表。另一种情况是文件末尾漏了returndofile返回nilipairs直接报错但错误被静默吞掉。解决所有模块文件末尾必须有return加载时做一次非空断言改完配置后不要靠 reload直接重进一次飞行用最笨的方法换确定性。6. 二次开发往判据表里加一行再用回放验证想加自己的标准别去动sa_evaluate。比如你飞的是小飞机想在 500 英尺额外要求「油门不在慢车位」只要在sa_criteria.lua里加一行{ idPWR, fieldthr, cmplt, limit0.05, at500, label油门收光 }然后在采样结构体里补一个thr字段指向油门杆位置 dataref。整个改动不超过五行回归测试范围也就在这一条判据上。导出数据做纵向对比是另一个高频需求。现在的 CSV 一场飞行只留一行想分析整个进近过程可以加一个「进近轨迹」开关在APPROACH状态里每 0.5 秒追加一条t,agl,vs,ias,roll,loc,gs到独立文件。字段别贪多七个就够多了以后做图表反而要筛。验证方法上X-Plane 自带的回放是最省事的手段找一段已知不达标的落地录下来回放时把脚本挂上看判决点、失败项、接地数据是否与手动飞时一致。回放时间戳和RUN_TIME_S不对齐也没关系因为脚本用的是相对时间差。我第一次加判据的时候没做这一步直接上真人飞结果一个单位写错飞了三个晚上都在怀疑手感最后发现是英尺和米的问题。从那以后我每加一条判据都强制用同一段回放跑三遍一遍正常、一遍故意越界、一遍边界值跑完才允许自己上飞机。希望帮到你。本文还有配套的精品资源点击获取