Migrating XLua 1.x Scripts to XLua 2.0

Practical translation guidance for moving an XLua 1 aircraft script to XLua 2. For the shared concepts see README.md; for the source and target surfaces see xlua1-syntax.md and xlua2-syntax.md.

Scope

The audience is both human authors migrating scripts by hand and automated / AI-assisted translators doing it in bulk. The rules below cover every helper and convention in use across the shipped Laminar Research fleet. A handful of items are still pending verification — those are collected in the Pending items section at the end.

Translation goal: functional equality

The aim of a migration is that the v2 script has the same observable behaviour as the v1 original, not that it preserves the v1 structure verbatim. This permits — and in some cases requires — the translator to:

One explicit exception: commented-out code stays. The author meant something by commenting a line out — a debugging print left for next time, a callback skeleton kept as a reminder that the hook exists, a dofile("") preserving a slot for future loader work. A translation is not the right moment to decide any of it is gone for good. Preserve commented-out lines verbatim. If a commented-out construct references v1 helpers that no longer exist in v2 (e.g. --find_dataref(…)), leave it in the v1 form — it is a comment, not code to be rewritten.

Any judgement call the translator makes under this rule — what got removed, what got collapsed, what got inlined — belongs in the migrated file's header note (see Recording doubts and assumptions) so a reviewer can find every such decision without re-running the translator.

Migration workflow and output layout

Never overwrite the source. A migration must leave the original scripts/ folder untouched and write its output to a sibling folder:

<aircraft folder>/plugins/xlua/
    scripts/                   <- source, untouched
        foo/foo.lua
        bar/bar.lua
    scripts_migrated/          <- migration output
        foo/foo.lua            <- translated copy
        bar/bar.lua

The scripts_migrated/ folder mirrors the scripts/ folder's subfolder layout exactly (folder name = master-script filename, same as under v1). Leaving the source in place preserves:

This rule applies strictly to automated and AI-assisted migration. Manual migration should follow it too.

Recording doubts and assumptions (automated/AI migration)

Any assumption or uncertainty the migrator makes while translating a file — a defaulted dataref type, a lifecycle callback collapsed into a particular entry point, a timer with ambiguous teardown semantics — must be recorded as a Lua comment block at the top of the migrated file. Each note should give:

Example header:

-- XLua 2 migration notes (auto-generated):
-- [L14  find_dataref]    assumed type Dataf for "laminar/aircraft/custom_thing" —
--                         not found in DataRefs.txt and not locally declared.
-- [L87  after_physics]   moved to XPLMCreateFlightLoop at AfterFlightModel,
--                         interval -1. Verify phase is correct for this logic.
-- [L122 flight_crash]    mapped to XPLM_MSG_PLANE_CRASHED branch in
--                         XPluginReceiveMessage. Verify param usage.

The notes let a reviewer find every spot the tool was not confident without re-running the translator. The original script's line numbers are the authoritative reference.

Going live — activating a migrated master

The scripts_migrated/ folder is a staging area. XLua does not load it — whatever sits there has no runtime effect. To activate a migrated master, the user manually copies the file from scripts_migrated/<name>/<name>.lua to scripts/<name>/<name>.lua, overwriting the v1 original. This is intentional:

A migration tool must not perform the copy itself: activation is a deliberate act by the author, usually after reading the migration-notes header and satisfying themselves that the translated file does what the original did.

Symbol categorisation — the first pass

Before applying any translation rule, the migrator must scan the source to build a table of every top-level identifier and its binding category. The rule picked for each later reference depends on which category the identifier belongs to, and the same syntactic form (foo = 2, if foo == 1 then …) means different things across categories:

The rule sections below assume this table has been built. A linear pass down the file that applies rules without the categorisation will misclassify writes to symbols whose definitions are hundreds of lines away.

Handle persistence and naming

Every handle created in XPluginStart or XPluginEnable — dataref handles from XPLMFindDataRef, custom-dataref handles from XPLMRegisterDataAccessor, command handles from XPLMFindCommand / XPLMCreateCommand, flight-loop handles from XPLMCreateFlightLoop — must live in a variable that outlives the creation function. A local declared inside XPluginStart is useless to XPluginDisable, which needs the same handle to tear the asset down. The standard shape is a forward-declared module-scope local:

local simDR_throttle            -- forward decl
local myDR_smoothed_amps        -- forward decl
local CMD_reset                 -- forward decl
local fl_tick                   -- forward decl

function XPluginStart()
    simDR_throttle = XPLMFindDataRef("sim/…")
    CMD_reset      = XPLMCreateCommand("laminar/…", "…")
    return true
end

function XPluginEnable()
    myDR_smoothed_amps = XPLMRegisterDataAccessor("laminar/…", …)
    fl_tick            = XPLMCreateFlightLoop({ … })
    XPLMRegisterCommandHandler(CMD_reset, reset_handler, true, nil)
    …
end

When the v1 source bound the return of find_dataref / create_dataref / find_command / create_command to a name, reuse that name for the v2 handle. When the v1 source did not (e.g. create_command(name, desc, handler) with the return discarded — fine in v1 where the script never re-invokes the command, but the v2 code still needs a handle for XPLMUnregisterCommandHandler), invent one using prefix + last path component of the dataref or command name:

Existing aircraft-specific prefixes (simDR_, sr22_, simCMD_, etc.) are fine to carry over unchanged; the invent-a-name scheme above applies only to handles the v1 source did not bind.

File skeleton

A migrated v2 file follows this top-to-bottom shape. The newly-created XPlugin* entry points sit close to the top of the file, where a reader arriving for the first time finds them immediately; everything else keeps its relative position from the v1 source so an author familiar with the v1 layout can still find each piece of logic roughly where it used to be.

  1. XLua 2.0 marker--[[ XLua 2.0 ]] as the literal first line of the file, with nothing (not even whitespace-only lines) preceding it. This is how the runtime distinguishes XLua 2 scripts from XLua 1 scripts; it overrides any "preserve original leading comments in place" rule — the marker goes first, always.
  2. Original file header comments — purpose, author, copyright, licence, revision history. Preserve verbatim, shifted down by the one-line marker.
  3. Migration-notes block — the per-file header of assumptions and doubts described in Recording doubts and assumptions. Place before or after the original header per tool preference, but clearly delimited from it.
  4. require statements — the XPLM modules the script uses.
  5. Module-scope state — in order: any v1 module-level locals being carried over, storage variables for custom datarefs (local g_<name> = 0), and forward-declared local bindings for the dataref, command, and flight-loop handles that XPluginStart and XPluginEnable populate.
  6. XPluginStart — dataref lookups, command creation, any one-off setup.
  7. XPluginEnable — custom-dataref registration, command-handler registration, flight-loop creation.
  8. XPluginDisable — mirror teardown of everything XPluginEnable armed.
  9. XPluginStop — final teardown (usually empty for migrated scripts; keep as a stub so the entry-point set is complete).
  10. XPluginReceiveMessage — message dispatch. Keep the body small; branch on inMessage and call out to named helper functions for anything non-trivial.
  11. Everything else in the v1 source's relative order — command-handler function bodies, per-subsystem helpers, rate-dependent utilities, the per-frame flight-loop callback, commented-out v1 skeletons, any trailing submodule-loader comments. Keep v1's relative layout so a familiar reader finds each piece of logic in its accustomed place.

Scoping consequence. Because XPluginStart and XPluginEnable reference helper and handler functions defined further down the file, those functions must be callable at call-time from the top-of-file entry points. The simplest approach is to keep those later definitions as global functions (unqualified function foo()): the v1 source already used this form, and each XLua 2 script runs in its own isolated environment so global functions don't pollute any wider namespace. The alternative is to forward-declare the names as local bindings in step 4 alongside the handles, then assign them later with foo = function(...) end; use this form where keeping helper scopes explicit is wanted.

Key conceptual shifts

Symbol mapping

At-a-glance index; each entry points forward to the section that covers it in detail.

XLua 1 XLua 2 Section
find_dataref(name) XPLMFindDataRef(name) + typed accessors at every use Dataref access
create_dataref(name, type[, notifier]) XPLMRegisterDataAccessor(…) in XPluginEnable; XPLMUnregisterDataAccessor in XPluginDisable Custom datarefs
find_command(name) XPLMFindCommand(name) Command handlers
create_command(name, desc, h) XPLMCreateCommand + XPLMRegisterCommandHandler Command handlers
replace_command / wrap_command / filter_command XPLMRegisterCommandHandler with appropriate beforeFlag and return value Command handlers
cmd:once() / :start() / :stop() XPLMCommandOnce / XPLMCommandBegin / XPLMCommandEnd Command handlers
run_after_time / run_at_interval / run_timer / stop_timer / is_timer_scheduled / get_timer_remaining Unchanged — same helpers available in v2 Timers — no change required
aircraft_load() Body of XPluginEnable() Lifecycle entry points
aircraft_unload() Body of XPluginDisable() Lifecycle entry points
flight_start() Branch on XPLM_MSG_AIRPORT_LOADED inside XPluginReceiveMessage Lifecycle entry points
before_physics() Flight loop at xplm_FlightLoop_Phase_BeforeFlightModel, interval -1 Lifecycle entry points
after_physics() Flight loop at xplm_FlightLoop_Phase_AfterFlightModel, interval -1 Lifecycle entry points
after_replay() Flight-loop callback that branches on sim/time/is_in_replay; see notes Lifecycle entry points
flight_crash() Branch on XPLM_MSG_PLANE_CRASHED inside XPluginReceiveMessage Lifecycle entry points
receive_message(fromWho, msg, param) XPluginReceiveMessage(fromWho, inMessage, param) Lifecycle entry points
SIM_PERIOD elapsedSinceLastLoop flight-loop argument; sim/operation/misc/frame_rate_period dataref outside a loop Implicit globals
IN_REPLAY sim/time/is_in_replay dataref Implicit globals
dofile, real_table, raw_table, make_prop, create_namespace See escape-hatch translations v1 escape-hatch helpers
plugins/xlua/init.lua (v1 or modified copy) Must be the stock XLua 2 init.lua before migration proceeds The init.lua file

Lifecycle entry points

aircraft_load()XPluginEnable()

aircraft_load fires once when the aircraft and all its scripts have finished loading. In v2 that work goes into the body of XPluginEnable — the entry point that arms state around an active session. If the work is truly once-per-script-lifetime (e.g. computing derived constants that never change) prefer XPluginStart; if it needs to be re-run on a user disable/re-enable cycle, keep it in XPluginEnable. XPluginEnable returns true to proceed; a missing function counts as true.

aircraft_unload()XPluginDisable()

aircraft_unload fires just before the aircraft is torn down. The v2 mirror is XPluginDisable, which should release everything XPluginEnable armed: destroy flight loops, unregister command handlers, unregister custom datarefs, and clear any sim/operation/override/* datarefs the script set. The script may be re-enabled later, so leave it in a clean "not running" state, not a half-torn-down one.

flight_start()XPLM_MSG_AIRPORT_LOADED branch

flight_start fires at the start of every new flight. In v2 the equivalent is a branch inside XPluginReceiveMessage:

function XPluginReceiveMessage(fromWho, inMessage, param)
    if fromWho == XPLM_PLUGIN_XPLANE and inMessage == XPLM_MSG_AIRPORT_LOADED then
        -- per-flight initialisation
    end
end

before_physics() / after_physics() → flight-loop callbacks

Per-frame v1 callbacks become XPLMCreateFlightLoop calls in XPluginEnable, with the appropriate phase and an interval of -1 (fire every frame). Example translation for after_physics:

-- v1:
function after_physics()
    -- per-frame work
end

-- v2:
local fl_handle

local function after_physics_cb(elapsed, elapsedSinceLast, counter, refcon)
    -- per-frame work (was the body of after_physics)
    return -1
end

function XPluginEnable()
    fl_handle = XPLMCreateFlightLoop({
        phase        = XPLMFlightLoopPhaseType.xplm_FlightLoop_Phase_AfterFlightModel,
        callbackFunc = after_physics_cb,
        refcon       = nil })
    XPLMScheduleFlightLoop(fl_handle, -1, true)
    return true
end

function XPluginDisable()
    if fl_handle then XPLMDestroyFlightLoop(fl_handle); fl_handle = nil end
end

before_physics is the same shape with xplm_FlightLoop_Phase_BeforeFlightModel.

after_replay()

Flight-loop callbacks fire in both normal flight and replay. Replay state in v2 is queryable from the dataref sim/time/is_in_replay (0 when flying normally, non-zero in replay). There is currently no XPLM message for replay start/stop — a script that needs to react to the transition does it by monitoring the dataref and comparing against the previous frame's value.

The v2 translation of after_replay is therefore a flight-loop callback that branches on the dataref:

local function per_frame(elapsed, elapsedSinceLast, counter, refcon)
    if XPLMGetDatai(is_in_replay) ~= 0 then
        -- what after_replay used to do
    else
        -- what after_physics used to do
    end
    return -1
end

If the script defined both after_physics and after_replay (a common pattern in shipped aircraft), collapse them into a single flight-loop callback. When the two bodies are identical (once any comments are ignored), collapse to a single callback with no is_in_replay branch at all — the per-frame function just runs once per frame in both modes. When the bodies differ, use the branching shape shown above.

To react specifically to replay starting or stopping, track the previous frame's value:

local prev_replay = 0

local function per_frame(elapsed, elapsedSinceLast, counter, refcon)
    local replay = XPLMGetDatai(is_in_replay)
    if replay ~= 0 and prev_replay == 0 then
        -- replay just started
    elseif replay == 0 and prev_replay ~= 0 then
        -- replay just stopped
    end
    prev_replay = replay
    -- ... rest of per-frame work
    return -1
end

flight_crash()XPLM_MSG_PLANE_CRASHED branch

Maps to a branch inside XPluginReceiveMessage, alongside the other message-driven events:

if inMessage == XPLM_MSG_PLANE_CRASHED then
    -- what flight_crash used to do
end

receive_message(inFromWho, inMessage, inParam)XPluginReceiveMessage(fromWho, inMessage, param)

Direct mapping — same three arguments, same semantics.

Dataref access

find_dataref(name) returns a proxy in v1; XPLMFindDataRef(name) returns a plain handle in v2. Reads and writes that used the proxy transparently become explicit typed calls at every use:

-- v1
battery_on = find_dataref("sim/cockpit2/electrical/battery_on[0]")

function after_physics()
    if battery_on == 1 then
        battery_on = 0
    end
end

-- v2
battery_on = XPLMFindDataRef("sim/cockpit2/electrical/battery_on[0]")

local function tick(...)
    if XPLMGetDatai(battery_on) == 1 then
        XPLMSetDatai(battery_on, 0)
    end
    return -1
end

Arrays carry over the 0-based indexing from v1. The element-access and bulk-assignment forms translate as:

-- v1
first = battery_array[0]         -- element 0
battery_array[1] = 1             -- set element 1
battery_array = { 1, 0, 0 }      -- bulk set 0..2

-- v2
local tmp = {}
XPLMGetDatavi(battery_array, tmp, 0, 1); first = tmp[1]   -- element 0 into tmp[1]
XPLMSetDatavi(battery_array, { 1 }, 1, 1)                  -- set element 1
XPLMSetDatavi(battery_array, { 1, 0, 0 }, 0, 3)            -- bulk set 0..2

XPLMFindDataRef returns nil for missing datarefs; guard the handle if the dataref is not guaranteed to exist.

Dataref type inference

XPLMGet/SetData* are typed — the translator must commit to f, i, or d at every call site. Apply the following rules in order:

  1. Declared-by-this-script. If the dataref was created by create_dataref in the source being translated, use its declared type ("number" → Dataf, "string" → Datab, "array[N]" → Datavf).
  2. In DataRefs.txt. X-Plane ships the built-in dataref catalogue at the sim-root-relative path Resources/plugins/DataRefs.txt. It is a plain text file (tab-separated; columns: name, type, writable, units, description; type values include int, float, double, byte[N] and their array variants). The translator should read this file at translation time, match the dataref name against it, then pick f / i / d / vf / vi / b from the recorded type.

Locating the file from an aircraft path. An aircraft under migration is itself at a sim-root-relative path (Aircraft/<publisher>/<aircraft>/plugins/xlua/scripts/<name>/), so a tool can walk up from the script folder until it finds a sibling Resources/ directory; DataRefs.txt is then at Resources/plugins/DataRefs.txt under that root. No absolute path ever needs to be hard-coded, and there is no other stable way to refer to the file since installations sit at different locations on disk. 3. Neither of the above. This typically means a dataref published by a third-party plugin. Emit TODO(XLUA2-TYPE): <dataref name> — type unverified; defaulted to Dataf both as an inline comment at the call site and as a header note (see Recording doubts and assumptions above). Default to XPLMGetDataf / XPLMSetDataf as a safe fallback.

Usage context (comparisons against integer literals, arithmetic, etc.) is not a reliable type signal — the literal 0 or 1 fits both int and float datarefs and leads to wrong choices. Do not infer from context.

Custom datarefs

create_dataref is the largest structural expansion in the migration. The helper is replaced by explicit storage, accessor closures, an XPLMRegisterDataAccessor call in XPluginEnable, and a matching XPLMUnregisterDataAccessor in XPluginDisable:

-- v1
my_dr = create_dataref("laminar/example/my_dr", "number")

-- v2
local g_my_dr = 0
local function read_my_dr(refcon)      return g_my_dr end
local function write_my_dr(refcon, v)  g_my_dr = v    end

local my_dr  -- accessor handle, for teardown

function XPluginEnable()
    my_dr = XPLMRegisterDataAccessor(
        "laminar/example/my_dr", XPLMDataTypeID.xplmType_Float, true,
        nil,         nil,                -- Int
        read_my_dr,  write_my_dr,        -- Float
        nil,         nil,                -- Double
        nil,         nil,                -- IntArray
        nil,         nil,                -- FloatArray
        nil,         nil,                -- ByteArray
        nil,         nil)                -- refcons
    return true
end

function XPluginDisable()
    if my_dr then XPLMUnregisterDataAccessor(my_dr); my_dr = nil end
end

Type → accessor-pair mapping when translating the second argument of create_dataref:

v1 type v2 type constant Accessor pair
"number" xplmType_Float read/write via XPLMGetDataf / XPLMSetDataf contract
"string" xplmType_Data Byte-block pair (read returns length if outValues == nil, else fills it)
"array[N]" xplmType_FloatArray Float-array pair implementing the size-query contract — reader returns N when called with outvalues == nil

Writing to an owned dataref: direct vs through the setter

Within the script that owns a custom dataref, writes should update the storage variable directly rather than routing through XPLMSetData*:

-- v1
sr22_fuel_selector = 2            -- proxy write

-- v2
g_sr22_fuel_selector = 2          -- direct storage update; observers see the new
                                  -- value via the read accessor on their next get

There is no benefit to calling XPLMSetDataf(sr22_fuel_selector, 2) from inside the script that owns sr22_fuel_selector; it just round-trips through the setter closure to produce the same storage-variable assignment.

The rule does not apply to foreign datarefs (anything returned by XPLMFindDataRef — sim-owned or produced by another plugin). Those writes must go through XPLMSetData* because the script does not own the storage.

Writability and the optional notifier

v1's create_dataref distinguishes three cases by the presence and content of the third (notifier) argument. Each translates to a different shape of XPLMRegisterDataAccessor call:

v1 shape v2 writable v2 writer slot
No notifier argument false Pass nil in the type-correct writer slot
Notifier with a real body true Writer closure that stores the new value and runs the ported notifier logic
Empty / no-op notifier true Writer closure that stores the new value. Add a migration-header note flagging the dataref for review

Why the third case gets a flag. A writable dataref whose setter does nothing beyond storing the value is unusual. The empty notifier in the v1 source may indicate a forgotten stub (the author intended to fill in the body later, or intended the dataref to be read-only but got the create_dataref signature wrong) rather than a deliberate "anything can write anything" design. This is not an error — the translation is correct — but a reviewer should decide whether the v2 dataref should stay writable (leave as translated) or become read-only (change writable=true → false, drop the writer).

Example for the "notifier with body" case:

-- v1
function on_knob_change()
    if knob_oat < 2 then cmd_thermo_units_toggle:once() end
end
knob_oat = create_dataref("laminar/c172/knob_OAT", "number", on_knob_change)

-- v2
local g_knob_oat = 0
local function write_knob_oat(refcon, v)
    g_knob_oat = v
    if g_knob_oat < 2 then XPLMCommandOnce(cmd_thermo_units_toggle) end
end
-- read_knob_oat and the XPLMRegisterDataAccessor call follow the shape above,
-- with writable=true and write_knob_oat in the Float writer slot.

If the notifier reads the new value from the v1 proxy (as in the example above — knob_oat is being read inside on_knob_change), rewire it to read the incoming v argument instead. A purely mechanical rename would read the storage variable before it has been updated; use v, or update the storage variable first and then read it.

Command handlers

All four of v1's command-handler helpers collapse into patterns of XPLMCreateCommand + XPLMRegisterCommandHandler. The beforeFlag argument and the handler's boolean return value together cover everything the v1 helpers did separately:

XLua 1 idiom XLua 2 equivalent
create_command(name, desc, h) XPLMCreateCommand(name, desc) + register with beforeFlag=true; wrapper does the work and returns true.
replace_command(name, h) Register with beforeFlag=true; wrapper returns false to suppress the default.
wrap_command(name, before, after) Two registrations — one with beforeFlag=true, one with beforeFlag=false; both return true.
filter_command(name, predicate) Register with beforeFlag=true; wrapper returns the predicate's boolean.

Additional translation points:

Timers — no change required

The six timer helpers — run_after_time, run_at_interval, run_timer, stop_timer, is_timer_scheduled, get_timer_remaining — all work in XLua 2 with identical semantics to v1. A v1→v2 migration does not rewrite timer calls. Leave them as they are.

See Timer helpers in the XLua 1 doc for the API; the same section is the reference for XLua 2. The one-timer-per-function identity rule and the "do not use anonymous functions" constraint both carry across unchanged.

Modernisation option (optional, not part of the translation): a timer that needs phase control, elapsed-time arguments, or multiple instances backed by the same function can be rewritten as an XPLMCreateFlightLoop in v2. Do this only when an author genuinely wants the extra flexibility — not as a routine part of the migration.

Implicit globals

SIM_PERIOD

Primary rule: read the dataref sim/operation/misc/frame_rate_period with XPLMGetDataf. Look up the handle once at load time, cache it, and read it anywhere v1 would have referenced the global — inside flight-loop callbacks, inside timer callbacks, inside helper functions, at chunk scope. The dataref's semantics are guaranteed to match v1's SIM_PERIOD, and the same rule applies to every call site, so there is no branching on context.

A flight-loop callback also receives elapsedSinceLastLoop as an argument. This looks like a zero-cost replacement (no dataref lookup needed) but its exact semantics have not been verified to match SIM_PERIOD in every case, and a mismatch produces subtle drift in rate-dependent logic (animation smoothing, integration helpers). Use elapsedSinceLastLoop only where you have verified the semantics match for the logic being translated; the dataref read is the safe default.

IN_REPLAY

Maps to the dataref sim/time/is_in_replay — an integer, 0 while flying normally and non-zero in replay mode. Look up the handle once at load time and read it with XPLMGetDatai wherever v1 would have referenced the global.

The init.lua file

init.lua is part of the XLua binary distribution. Every XLua plugin instance — per-aircraft or system-level — has exactly one init.lua and one scripts/ directory, and the init.lua is paired with the binary it ships alongside:

The two files are not interchangeable. An XLua 1 init.lua under an XLua 2 binary, or an XLua 2 init.lua under an XLua 1 binary, will not work.

The file is not author-editable territory. The only sanctioned modifications are the LuaJIT knobs documented in its header comment (see LuaJIT tuning) — those are system-level tweaks the file explicitly invites. Any other edit is unsupported. Future XLua point releases make no compatibility promise to a modified init.lua, and can break it at any time; the LuaJIT-knob area is the one exception.

Historically, some aircraft shipped customised copies of the v1 init.lua: often verbatim, occasionally with LuaJIT-knob tweaks, rarely with genuinely custom additions. That practice was already unsupported ground before migration was on the table; migration just brings it to a head.

Migration precondition

Migration only makes sense in an XLua 2 environment. Before translating any master script, a migration tool (or a human) must confirm the environment is stock XLua 2:

If either check fails, migration is blocked. Common cases and their remediation:

Once the environment is stock XLua 2, per-master migration can proceed as described earlier, one master at a time.

Hazards and manual-review triggers

Optional optimisations

These transformations are not part of the translation — they change nothing observable about the script's behaviour. They can improve runtime performance or readability, but they also widen the diff between v1 and v2 copies, which makes review harder. A migration tool should not apply them by default; offer them as a user choice per script. Manual translation can use discretion.

Hoist repeated foreign-dataref reads into per-frame locals

A v1 per-frame function that reads the same sim-owned or foreign-plugin dataref many times keeps the same number of sim calls per frame after a literal translation — every XPLMGetDataf(simDR_x) is its own round-trip:

-- v2, literal translation (30+ sim calls per frame just for these two datarefs)
g_sr22_annun_o2_full = o2_full * XPLMGetDataf(simDR_indicator_light_level) * XPLMGetDatai(simDR_oxy_on)
g_sr22_annun_o2_1600 = o2_1600 * XPLMGetDataf(simDR_indicator_light_level) * XPLMGetDatai(simDR_oxy_on)
-- …

Hoisting each read into a local once at the top of the callback eliminates the repetition:

-- v2 with hoist
local light_level = XPLMGetDataf(simDR_indicator_light_level)
local oxy_on      = XPLMGetDatai(simDR_oxy_on)

g_sr22_annun_o2_full = o2_full * light_level * oxy_on
g_sr22_annun_o2_1600 = o2_1600 * light_level * oxy_on
-- …

The v1 code already had the same inefficiency (every proxy read was a sim call), so a literal translation faithfully preserves both behaviour and performance. Applying the hoist is a polish step the translator can offer.

Compress custom-dataref boilerplate via a local helper

A script with many create_dataref("…", "number") calls produces equally many XPLMRegisterDataAccessor calls of identical shape. A short local helper captures the common form:

local function register_float_dr(name, read_fn, write_fn)
    return XPLMRegisterDataAccessor(
        name, XPLMDataTypeID.xplmType_Float, true,
        nil,     nil,                    -- Int
        read_fn, write_fn,                -- Float
        nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
end

Either form is correct. Emit the full 14-argument call at every site for an easier diff against the doc's rule; collapse into the helper for a shorter and more readable file.

v1 escape-hatch helpers

A handful of v1 helpers are escape hatches into parts of the helper layer itself. They are rare in practice but a migration tool or author should know what each one does and how to translate it. Expected handling:

Pending items