Skip to content

infinity-MSFS/rescript-msfs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rescript-msfs

The conventional wisdom is that React-based MSFS gauges can't keep up with the avionics framework — that you have to drop down to the MSFS Avionics Framework (ew TS) or raw JS for anything serious without going to WASM. That's true but not the entire story. React gauges run fine; what falls over is the hook layer we have been using: per-frame setState spreads, every consumer notified on every tick, fresh closures thrashing useSyncExternalStore. Fix those and a 200-subscription gauge sits comfortably under 5 µs/frame (see Benchmarks).

This library is that fix. Wraps SimVar, GlobalVar, GameVar, Coherent, and CommBus behind a small, change-gated, dedup-on-key store. The API mirrors the FlyByWire react-msfs shape so existing JS/TS gauges port over easily, but we use ReScript to add those nice zero cost abstractions and type safety:

let (alt, _)  = useSimVar("INDICATED ALTITUDE", "feet", ())
let (qnh, set) = useSimVar("L:DC10_BARO_INHG", "number", ())

Caveats

This library is not a silver bullet. It simply allows me to write React without annihilating performance. React still will fall short of both raw JS and the avionics framework in raw performance. React really shines for gauges with lots of interactivity like an EFB. For displays that are querying a ton of sim data and rerendering nearly every tick (PFD, ND) as well as system computation, Rust or the avionics framework will still be the way to go.

ReScript Over TypeScript

Typescript is a great language, the choice of many MSFS gauge authors, and the ecosystem has some solid MSFS-specific libraries. So why ReScript? Mainly developer preference and our desire for a more type safe stack (Rust for systems, non-interactive displays, and ReScript for interactive displays). ReScript has first class bindings for React so the only missing piece was a React-friendly MSFS API wrapper.

Pair with infinity-msfs (Rust CLI) to bundle, transform, and emit a runnable MSFS gauge package — no esbuild, no Vite, no JS toolchain config.


Requirements

  • ReScript ≥ 12 (@rescript/core, @rescript/react ≥ 0.15)
  • React 19.2+ (the hooks rely on useSyncExternalStore and the React Compiler for closure memoization)
  • Rust 1.85+ (only if you use infinity-msfs to build/package)
  • MSFS 2020 or 2024

The React Compiler is required. Without it, useStoreValue's subscribe/getSnapshot closures are fresh every render and useSyncExternalStore thrashes the underlying store. ReScript ≥ 12 emits compiler-friendly output by default; if you opt out, wrap the hook callsites in useMemo/useCallback yourself.


Install

bun add @infinity-msfs/rescript-msfs
# peers
bun add react react-dom @rescript/react @rescript/core @rescript/runtime rescript

rescript.json:

{
  "name": "your-gauge",
  "sources": [{ "dir": "src", "subdirs": true }],
  "package-specs": [{ "module": "es6", "in-source": false }],
  "suffix": ".res.mjs",
  "bs-dependencies": [
    "@rescript/core",
    "@rescript/react",
    "@infinity-msfs/rescript-msfs"
  ],
  "jsx": { "version": 4, "mode": "automatic" }
}

Concepts

One global store, one poll per (name, unit)

All hooks share a single Store.global. If ten components subscribe to "INDICATED ALTITUDE" in feet, the sim is polled exactly once per tick — every subscriber reads the same cached value. The smallest maxStaleness across subscribers wins, so a slow subscriber piggybacking on a fast one is free.

Frame loop is driven by the Provider

InfinityMSFS.Provider mounts the per-frame tick. By default it listens for the update event dispatched by BaseInstrument (standard MSFS coherent-gauge pattern). Other modes:

source Use when
BaseInstrument (default) Normal MSFS gauge with the generated harness
Document FBW-compatible gauges that dispatch update on document
RAF Dev/preview outside MSFS
Manual You drive Provider.tick(deltaTime) from your own loop
<InfinityMSFS.Provider source=BaseInstrument>
  <YourPanel />
</InfinityMSFS.Provider>

A-vars vs L-vars


Hook reference

useSimVar(name, unit, ~maxStaleness=0.0, ()) → (value, setter)

Read + write a SimVar. Setter goes through SimVar.SetSimVarValue. For L-vars this is a direct write; for A-vars it depends on whether the variable is settable.

let (lights, setLights) = useSimVar("L:LIGHTS_TEST", "bool", ())

useSimVarValue(name, unit, ~maxStaleness=0.0, ()) → value

Read-only variant — half the surface, same store.

useSimVarSetter(name, unit, ~proxy=?, ()) → setter

Setter-only variant. ~proxy writes through a different name (e.g. a K-event) while the cache key stays at name.

useSimVarUpdate(name, unit, ...) → (value, updater)

useState-style functional updater. Reads current at call time, applies the function, writes back.

useInteractionSimVar(name, unit, events, ~maxStaleness=500.0, ())

For SimVars whose value is fully driven by H-events. Polls infrequently, but force-refreshes from sim when any of events fires.

let (toggle, setToggle) = useInteractionSimVar(
  "L:RMP_LEFT_TOGGLE",
  "bool",
  ["H:RMP_LEFT_TOGGLE"],
  (),
)

useSplitSimVar(readName, readUnit, writeName, ~writeUnit=?, ...)

Read one SimVar, write another. Classic K-event pattern.

let (freq, setFreq) = useSplitSimVar(
  "COM STANDBY FREQUENCY:2", "Hz",
  "K:COM_2_RADIO_SET_HZ",
  ~writeUnit="number",
  (),
)

K-events take a number argument with event-specific scaling. useSplitSimVar does not perform conversion — encode in your own setter wrapper before calling.

useGlobalVar(name, unit, ...) / useGameVar(name, unit, ...)

Read-only access to GlobalVars / GameVars.

Coherent — Coherent.useCoherentEvent(name, handler) / useCoherentEventValue(name, ~initial) / useCoherentTrigger(name) / useCoherentCall(name)

Wraps the JS↔C++ Coherent bridge. Subscriptions are deduplicated: many components subscribing to the same event share one underlying Coherent.on slot.

CommBus — CommBus.Channel.t<'a> + useChannel / useChannelValue / useRequest

Typed message bus over JS_LISTENER_COMM_BUS with a JSON envelope codec by default. request does request/response with a rid and a 5 s timeout; the peer (typically WASM) replies on <channel>::reply.

let posCh = CommBus.Channel.makeJson("aircraft.position")
let pos = CommBus.useChannelValue(posCh, ~initial={lat: 0.0, lon: 0.0})

Bootstrapping a gauge

open InfinityMSFS

module Panel = {
  @react.component
  let make = () => {
    let (alt, _) = useSimVar("INDICATED ALTITUDE", "feet", ())
    <div className="alt">{React.string(Float.toString(alt))}</div>
  }
}

@module("react-dom/client") external createRoot: Dom.element => 'r = "createRoot"
@send external rootRender: ('r, React.element) => unit = "render"

let mount = (): unit => {
  let target = RenderTarget.getRenderTarget()
  let root = createRoot(target)
  rootRender(root, <InfinityMSFS.Provider> <Panel /> </InfinityMSFS.Provider>)
}

Note: do not call mount() at the bottom of the file when building with infinity-msfs — the bundler injects the call automatically (see below). For other bundlers, add mount() at module scope.


Building with the infinity-msfs Cargo CLI

infinity-msfs is a Rust CLI that runs the ReScript compiler, bundles the output via rolldown, transforms it for Coherent GT (MSFS's HTML UI engine), generates the BaseInstrument harness, and emits the final HTML/JS/CSS into your aircraft package.

Install

cargo install infinity-msfs

Project layout

your-aircraft/
├── infinity-msfs.toml          # build config (this file)
├── package.json                # bun/npm with rescript-msfs deps
├── rescript.json               # ReScript build config
├── src/
│   └── Altimeter.res           # your gauge source
├── lib/                        # rescript output (gitignore)
└── PackageSources/             # MSFS package — bundler writes here
    └── html_ui/Pages/VCockpit/Instruments/<package_name>/<instrument>/

infinity-msfs.toml

[js]
package_name = "dc10-cockpit"      # used as the URL segment under VCockpit/Instruments/
package_dir  = "PackageSources"

[[js.instruments]]
name  = "altimeter"                # output folder under <package_name>/
index = "lib/es6/src/Altimeter.res.mjs"  # bundler entry (rescript output)

[js.instruments.simulator_package]
type            = "rescriptReact"
file_name       = "altimeter"      # output basename: altimeter.html, altimeter.js, ...
template_id     = "Altimeter"      # MSFS template ID; must match panel.cfg htmlgauge
is_interactive  = true             # default true; set false for non-clickable displays
imports         = ["/JS/dataStorage.js"]
build_command   = "bun run build"  # default; runs before bundling

For each instrument the bundler produces:

<package_name>/<instrument.name>/
├── altimeter.html        # template + script tags
├── altimeter.index.js    # generated BaseInstrument harness
├── altimeter.js          # rolldown bundle of your ReScript output
└── altimeter.css         # extracted CSS (empty file if none)

panel.cfg

[VCockpit01]
size_mm     = 1080,1080
pixel_size  = 1080,1080
texture     = $DIS_MD_1
htmlgauge00 = dc10-cockpit/altimeter/altimeter.html,0,0,1080,1080

The htmlgauge00 path is relative to html_ui/Pages/VCockpit/Instruments/. The MSFS panel system loads the HTML, which registers a BaseInstrument whose templateID matches the <script type="text/html" id="..."> block — that's the template_id field in your toml.

Build

infinity-msfs build           # one-shot
infinity-msfs build --watch   # rebuild on .res change

The bundler:

  1. Runs bun run build (or your build_command) so the ReScript compiler emits lib/es6/.../*.res.mjs.
  2. Bundles via rolldown as IIFE, externalising /Images/* and /Fonts/*, lowering syntax to ES2019 (Coherent GT chokes on ?. / ??).
  3. Injects an auto-invocation of the bundle's mount export — your let mount = ... runs on script load.
  4. Renders the BaseInstrument harness from the template, wiring templateID, isInteractive, the bundle path, and any extra imports.
  5. Writes everything into PackageSources/html_ui/....

Custom templates

Override either template per-instrument:

[js.instruments.simulator_package]
type          = "rescriptReact"
template_id   = "Altimeter"
html_template = "templates/instrument.html"   # custom HTML wrapper
js_template   = "templates/harness.cjs"       # custom BaseInstrument shell

Templates use a tiny {{ var }} / {{#list}}...{{/list}} syntax. Available vars: templateId, mountElementId, instrumentName, instrumentPath, cssPath, jsPath, isInteractive, imports.


Benchmarks

bench/run.mjs compares this lib's store against the legacy react-msfs-style provider (per-frame React-state spread, every consumer notified on every tick). Scenario: 50 SimVars × 4 subscribers each = 200 active subscriptions, 10 000 frames @ 16.6 ms, 200-frame warmup, best of two runs. Mocked SimVar.* globals so the cost being measured is purely store + dispatch.

Scenario infinity (this lib) Legacy model Speedup
A. Steady state (no values change) 4.31 µs/frame · 0 notifies 19.63 µs/frame · 2 000 000 notifies 4.6×
B. 1 var changes per frame 4.35 µs/frame · 40 000 notifies 19.52 µs/frame · 2 000 000 notifies 4.5×
C. All vars change every frame 4.70 µs/frame · 2 000 000 notifies 19.64 µs/frame · 2 000 000 notifies 4.2×

Two structural wins drive the gap:

  1. Change-gated notifies. Subscribers fire only when the cached value actually moved. In scenario A the legacy model still notifies 2 M times (200 subs × 10 k frames); this lib notifies zero.
  2. One poll per (name, unit). Adding the 2nd–4th subscriber per var costs nothing on the read side.

Reproduce:

bun run build && node bench/run.mjs

Performance notes

  • Single subscription per (name, unit). Adding more components subscribing to the same key is free.
  • Components only re-render on value change. Polls that return the same value are no-ops at the subscriber layer.
  • maxStaleness is the floor, not the ceiling. The store will poll at the smallest staleness any subscriber requested.
  • Optimistic writes. useSimVar's setter writes the new value to the cache immediately and notifies subscribers, then the next poll reconciles with sim state. If the underlying write was rejected (wrong unit, invalid K-event payload), you'll see the value flicker back to its sim value on the next tick — that's your signal to check unit/scaling, not a lib bug.

Troubleshooting

Gauge loads but stays blank ("If you're seeing this, instrument didn't load"). Either the bundle 404'd (check the <script src> paths in the generated HTML against your deploy folder) or mount was never invoked (using a bundler other than infinity-msfs? add mount() at module scope).

SimVar writes appear to revert. The K-event was rejected. K-events take a number parameter with event-specific scaling (e.g. K:KOHLSMAN_SET wants millibars × 16 as an integer). Convert in a wrapper around the setter, and pass ~writeUnit="number".

useSyncExternalStore warning / store thrashing. React Compiler isn't running on the bundled output. Confirm your build pipeline applies it. With infinity-msfs, the bundle goes through rolldown's JSX/oxc transformer pipeline by default.


License

MIT

About

Rescript + React hooks for the MSFS coherent layer

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors