Technical reference

AFDS (autopilot, flight director, autothrottle logic) — M5b

docs/AFDS.md

Status: M5b-1, 2026-11-04. Subject: zibomod's Glareshield (MCP buttons, AFDS modes, MCP lights, FMA, A/T logic), ap_speed_accel and flight_director_new (flight director commands) with their callees — 189 functions.

How it is ported#

Unlike the earlier modules (hand ports from the decompiled listing), the AFDS is translated mechanically, then verified against the oracle on every frame:

  1. tools/re/decompile.py: Ghidra's decompiler with every function's prototype (mangled C++ signatures; return type from the unoptimised epilogue; XPLM / libm imports), read-only constants read at the size of the instruction that loads them, globals and calls by symbol name.
  2. tools/re/port_cpp.py: the listing becomes a C++ class (wasm/src/core/afds/afds_gen.*): zibomod's globals are members with zibomod's names (type from the instructions that access them, size from the symbol table, initial value from .data), Ghidra's address arithmetic becomes indexing, XPLM calls become hooks.
  3. tools/re/gen_afds.sh runs both over tools/re/afds_functions.txt. Generated files are not edited by hand.

wasm/src/core/afds/afds.{h,cpp} (hand-written): the hooks (dataref writes and X-Plane commands are recorded; the three sim commands zibomod itself handles — servos_fdir_off, knots_mach_toggle, take_off_go_around — loop back to its handlers as in X-Plane), stubs (Read_axis: inputs; VNAV was a stub until build 65: docs/VNAV.md), flight_director_show by hand (Ghidra split its local array), the frame entry points in zibomod's order (Glareshield after Idle_mode / Engine_n1_n2 / Thr_shift; later in the frame ap_speed_accel, then flight_director_new before the servo).

Inputs#

zibomod's own globals: simDR_* (X-Plane datarefs as Read_Xplane_DR copies them), B738DR_* (Lua or zibomod datarefs, Read_B738_DR), and globals other zibomod code writes (MainFlightLoopCallback's air data and speed filters, the servo, Read_axis, writable dataref accessors): tools/re/zibomod_reads.json, tools/re/afds_external.json. The integration in Systems (next step) fills them from MSFS and the other modules.

Verification#

afds_replay_matches_oracle (wasm/test/test_afds_replay.cpp): xphost/test/sci/afds_trace.json powers the aircraft on the ground (battery, APU, both APU generators), goes airborne at FL100 / 250 kt and works the MCP: FD, A/T arm, HDG SEL, ALT HLD, SPEED, CMD A, V/S, LVL CHG, N1, VOR/LOC, CWS A, CMD B, disengage bar, A/T disengage, FD off. The oracle traces every input and state global of the translated code from the plugin's memory; inputs are sampled where the code reads them (host call-site hooks: after Glareshield's Read_axis, before ap_speed_accel, before flight_director_new). The test replays the inputs into the rewrite and compares all 734 state values after every frame: identical on every frame (golden file wasm/test/data/afds_trace_oracle.txt.gz). Build 65: a second scenario, VNAV's climb and level-off (afds_vnav_replay_matches_oracle, docs/VNAV.md); lnav_vnav_horiz_vert (the PFD's GPS / ANP scales, after the flight director in zibomod's frame) is replayed and checked too.

In the systems module (build 24)#

wasm/src/core/systems_afds.cpp, every frame after the other modules:

  • Inputs: air data and attitude from MSFS (calibrated airspeed, TAS, OAT / TAT -> speed of sound, magnetic heading, pitch, bank, altitudes, V/S, radio height, ground speed, Mach, track, wind, AoA, flaps, on ground, yoke, trim, baro); buses, lost_ac_ap, transfer bus sources (electrics); IRS modes and source switch; MCP heading / altitude / course / bank (mcp/); VHF NAV deviations in dots, DME, active frequency, MMR mode and station identified (MSFS NAV + nav/). zibomod's own indicated airspeed filter (ap_speed_accel) is kept, as X-Plane keeps it with override_ias. FMS values: Zibo's defaults without a flight plan (M5c). Temporary: hydraulic system A / B pressurised while engine 1 / 2 runs (hydraulics not rewritten yet).
  • MainFlightLoopCallback parts (Afds::mainLoopStart / mainLoopEnd, from the listing): TAS / speed of sound / TAT filters, Mach, kspd, the MCP dial in knots while in Mach, plane_on_ground, first_time, first_timer.
  • Speed dial: shared with mcp/ — the knob moves it there, the AFDS writes it back (e.g. held at 100 without DC buses 1 and 2, zibomod check_mcp_speed).
  • Events (PRESS -> phase 0, RELEASE -> 2; _2 duplicate nodes): FLIGHT_DIRECTOR, FIRST_OFFICER_FLIGHT_DIRECTOR, AUTOTHROTTLE_ARM, HDG_SEL / LVL_CHG / VNAV / LNAV / VORLOC / APP / ALT_HLD / VS / N1 / SPEED_MODE, CMD_A / B, CWS_A / B, AP_DISCONNECT, SPEED_INTERVENTION, CAPTAIN / FO_AP_DISENGAGE, LEFT / RIGHT_A_T_DISENGAGE_BUTTON, LEFT / RIGHT_TO_GA_BUTTON; AP_VERTICAL_SPEED INC / DEC -> zibomod CMD_vert_spd_up / _down (50 fpm steps below 1000 fpm, 100 above; the dial is 0 outside V/S).
  • Outputs: 168 L:vars L:LU737_<SCI state> (cockpit model: MCP lights and buttons) or the dataref path (displays: FMA, FD bars, speed / altitude mode, ...), wasm/src/core/afds/afds_outputs.inc generated by tools/re/afds_io.py from zibomod's dataref accessors (tools/re/zibomod_datarefs.json). The PFDs read 57 of them (AFDS_LVARS in avionics/src/du/PfdSources.ts).
  • Order: the AFDS runs after the electrics / IRS / NAV of the same frame (zibomod: Glareshield before its electrics), so a bus or IRS change reaches it one frame earlier than in Zibo.

Tests: afds_mcp_buttons_engage_modes, afds_vs_wheel_steps_the_dial (wasm/test/test_afds.cpp).

Autothrottle and servo (M5b-3, build 25)#

  • Thrust levers / A/T: zibomod's B738_N1_thrust_manage_new2 owns the levers (already translated). X-Plane's throttle ratio is MSFS's throttle lever position (simDR_throttle); zibomod's joystick axis (B738DR_joy_axis_throttle*), by which it sees the pilot take over from the A/T, is the throttle as the pilot last set it (a change of the MSFS throttle we did not command). When zibomod writes the throttle ratio, Systems moves MSFS's throttles (THROTTLE1/2_SET). Engine inputs from the engine module (N1, N2, throttle use, maximum use, idle shift, idle mode); N1 limits as zibomod Eicas_thrust (n1_to_thr_use of 100 % / 94 % of the FMS N1). Temporary until the FMS: N1 limits take-off 95.2 %, climb and active 93.5 %.
  • AFS speed limit (B738.calc.lua): min(maximum speed from speeds/, flap placard by the lever, 235 kt gear down); PFD maximum / minimum manoeuvre speeds from speeds/.
  • Servo (afds/servo.{h,cpp}, our control laws): while the AFDS enables a channel (enable_servo_pitch / _roll with the AP on) it holds the flight director's pitch / bank (master side): attitude error -> rate command (pitch <= 2.5 deg/s, roll <= 5 deg/s) -> PI on the rate error -> AXIS_ELEVATOR_SET / AXIS_AILERONS_SET, gains scaled with (220 / IAS)^2, stabiliser trim (ELEVATOR_TRIM_SET) offloading the elevator; bumpless engage from the yoke, nothing written when disengaged. Signs assumed (elevator axis negative = pull, aileron axis positive = right roll, trim positive = nose up): L:LU737_AP_ELEV_SIGN / _AIL_SIGN / _TRIM_SIGN = -1 flips one. Build 27: owner's test — the roll diverged on engagement. MSFS's axis events are opposite to its control SimVars (AILERON POSITION / YOKE X positive = right, ELEVATOR POSITION / YOKE Y positive = nose up; AXIS_AILERONS_SET negative = right roll, AXIS_ELEVATOR_SET negative = pull): the aileron command is now negated, the yoke and trim are read in their physical sense (the sign L:vars flip only what is sent), commands limited to bank +-30 deg, pitch -10 .. +20 deg. Test afds_servo_corrects_in_the_right_direction. Diagnostics L:LU737_AP_SERVO_PITCH_ON / _ROLL_ON / _ELEVATOR / _AILERON / _PITCH_CMD / _ROLL_CMD. Tested against a simple aircraft model (test_servo.cpp: 140 / 220 / 300 kt, half and double control power); the real tuning is MSFS's flight model (owner test).

Level-off and the MCP speed (build 28)#

Owner (build 27): the autopilot climbs through the MCP altitude (LVL CHG and V/S); the MCP speed sometimes jumps to 100 kt. Both were integration gaps around the translated code, which itself matches the oracle; the replay test could not see them because it injects every global zibomod reads, including those the integration forgot.

  • Level-off: the flight director's altitude hold / ALT ACQ (fd_althld) read altitude_pilot / _copilot, globals MainFlightLoopCallback copies from the X-Plane datarefs every frame (0x1657d4..0x165864, with radio_altitude_pilot, airspeed_pilot; flight_phase at 0x166964). Afds::mainLoopStart did not copy them: the FD held altitude 0, commanded +1700 fpm in ALT ACQ / ALT HOLD and the aircraft kept climbing with ALT HOLD on the FMA. Now copied. Closed-loop tests (test_afds_capture.cpp, an aircraft that follows the FD pitch, thrust and gravity on the speed): V/S climb, V/S descent, LVL CHG climb and descent with the A/T level off within 5–11 ft of the MCP altitude and hold it.
  • MCP speed to 100 kt: above the crossover altitude a LVL CHG or V/S climb switches the MCP to Mach (ap_lvl_chg / ap_vs -> crossover_altitude -> sim/autopilot/knots_mach_toggle) and zibomod reads X-Plane's airspeed_is_mach back right after the command. zibomod's handler returns 1, so X-Plane's own handler then flips that flag and converts its airspeed dial; nothing did that here: the flag read back was the old 0, the MCP held Mach 0.7x as knots and check_mcp_speed clamped it to 100 kt. Afds::xplaneKnotsMachToggle now does X-Plane's part. Same path: TO/GA in Mach, check_mcp_speed's IAS restore. Test afds_lvl_chg_crossover_switches_to_mach.
  • IAS/MACH C/O button: through zibomod's CMD_autopilot_co_press (only with the IAS shown, Mach from 0.6), the MCP module follows at once (Systems::afdsChangeOver); before, the MCP module toggled by itself.
  • Zibo's own 100 kt resets (oracle xphost/test/sci/afds_mcp_speed_ground.json, test afds_mcp_speed_on_the_ground_as_zibo): LVL CHG pressed on the ground with the MCP altitude away from the field elevation sets the MCP speed to 100 kt (ap_lvl_chg_cmd, plane_on_ground); V/S on the ground reverts to LVL CHG after 0.5 s and does the same; DC bus 1 or 2 unpowered holds it at 100 (check_mcp_speed). As in X-Plane.
  • Inputs added: flap surface angles (flap1_deg, the FD's flap rate) from TRAILING EDGE FLAPS LEFT / RIGHT ANGLE. Audit of the other external globals: the rest are FMS values (M5c), zibomod's own servo (replaced by ours) or unused.
  • Zibo behaviour seen in the tests: in LVL CHG the A/T stays armed for the first 150 s after lift-off (lift_off_150), then N1.

Yoke, CWS and the disconnect lights (build 29)#

  • The pilot's controls. zibomod overrides X-Plane's joystick: X-Plane's yoke ratios (pitch_ratio2, roll_ratio2, joy_axis_*) are the pilot's stick, and the servo flies the surfaces. MSFS has one elevator / aileron for both, so the gauge observes the joystick's axis events (fsEventsRegisterKeyEventHandler: AXIS_ELEVATOR_SET, ELEVATOR_SET, AXIS_AILERONS_SET, AILERON_SET, the throttle axes) and gives them to Systems::pilotAxis, leaving out the events the module sends itself. These are zibomod's joystick axes:
    • CMD with the yoke moved more than 0.11 from its engage position: CWS P / R (pitch / roll modes off); below 400 ft or 150 ft RA: A/P disconnect (zibomod check_yoke, ap_system).
    • CMD pressed with the yoke more than 0.1 off centre: refused, A/P disconnect warning (ap_cmd_a / _b).
    • Before build 29 MSFS's YOKE X / Y (the servo's own output) stood for the pilot's yoke.
  • CWS (afds/servo): with the A/P in CWS on an axis, the servo leaves the controls to the pilot while the yoke is out of the detent (0.05) and holds the attitude at the release: the pitch, and the bank, or wings level below 6 deg (the 737's CWS roll). Before build 29 nothing held the attitude in CWS (zibomod's servo does it).
  • Thrust levers: the pilot's throttle axis, when the gauge sees it, is zibomod's joy_axis_throttle (the A/T's hardware-lever logic); keyboard throttle changes still count as the pilot's when MSFS's lever moved without the A/T. MSFS still applies the hardware lever's events itself (only observed, not masked).
  • Disconnect lights (zibomod Annunciators): red A/P (ap_disconnect), amber A/T (at_disconnect, at_disconnect2), amber FMC (FMC message, not yet: no FMS; or 0.33 s of the test), all at the annunciator brightness. The A/P disconnect test switches (TEST 1 / TEST 2, spring loaded) drive zibomod's CMD_ap_disconnect_test1/2_up / _dn. Bank angle selector at 25 deg at flight start (Lua flight_start).

ILS approach and autoland (build 70)#

afds_ils_replay_matches_oracle (wasm/test/test_afds_replay.cpp): xphost/test/sci/afds_ils_trace.json (tools/re/afds_ils_scenario.py), in the approach world with KPDX's ILS IPDX 110.30 for runway 10R (wasm/test/approach_world.h addKpdxIls). At 2500 ft, 180 kt, 15 nm out and 3 nm north of the localizer, heading 120 (a 20 degree intercept): both MMRs on 110.30 (Zibo's MMR panel: MODE to ILS, the digits, the transfer), both courses 100, the bank angle selector 30, FD both, ALT HLD, HDG SEL, CMD A, APP, CMD B; flaps 15 and 160 kt, flaps 30 and 150 kt; the A/P and A/T disconnected at about 80 kt on the rollout.

The oracle has no flight model, so this one is a closed loop through zibomod's own laws: the host flies a kinematic aircraft whose bank and pitch follow zibomod's flight director (fly: 1 s lag, a coordinated turn, the flight path angle from the pitch, X-Plane's local velocity for zibomod's track, the touchdown at the runway's elevation, then 3 kt/s down) and computes X-Plane's receivers from the geometry (ils: the localizer deviation, 1 dot a degree; the glideslope's, 0.28 degree a dot, positive with the aircraft above the path; the DME; the localizer's course, to which Zibo's Lua slews the OBS once the localizer is received). zibomod: LOC captured 10.4 nm out, G/S captured, FLARE armed with LAND 3 at about 1380 ft, the flare at 100 ft, RETARD at 64 ft, the touchdown about 1600 ft past the threshold, the rollout. The translation: identical on every frame (11648 frames, 732 values).

Found on the way:

  • The oracle printed int globals to 9 digits. zibomod keeps its PID states (floats) in int arrays (fd_pitch: the flare); 10 digit bit patterns lost their last digit, and a replay window restarting from the oracle's state started from a slightly wrong flare PID. Ints are now printed exactly (the host's trace); the old goldens are unaffected (no PID in use at their restarts).
  • Sign conventions. zibomod's LOC law wants X-Plane's hdef positive with the course to the right (the aircraft left of it) and vdef positive with the aircraft above the glideslope. Our MSFS mapping passes NAV CDI and NAV GSI through: FSUIPC documents MSFS's GSI (offset 0C49) as "-119 up to +119 down", positive with the aircraft above, so the senses agree.
  • Zibo's LOC turns are limited by the MCP bank angle selector (10 degrees at its first position): a 30 degree intercept at 10 degrees overshoots.

afds_ils_approach_and_autoland (wasm/test/test_afds_capture.cpp): the same approach through the systems module and our servo (the kinematic aircraft follows L:LU737_AP_SERVO_ROLL_CMD / _PITCH_CMD), MSFS's NAV CDI / NAV GSI from the geometry: LOC captured 8 nm out and then within 0.06 degree, G/S within 0.1 degree to 200 ft, FLARE armed at 1420 ft with LAND 3, the flare at 63 ft, the touchdown 1860 ft past the threshold at 200 ft/min, the rollout on the centreline. (Without the A/T engaged in it: no RETARD there.)

Next#

  • M5b-2: flight director bars on the PFD; M5b-3: our servo on MSFS's controls (zibomod's autopilot_servo drives X-Plane's yoke) and the A/T driving MSFS's throttles; VNAV with the FMS (M5c).