Interop Hovercraft — library mode

Hovercraft — library mode

By default the compiler produces a one-shot binary: it runs the transient from start to stop, writes simulation_output.csv, and exits. Hovercraft mode compiles the same source into a reusable shared library instead, so a host program can drive the simulation interactively.

What it is for

A host loads the library and steers the run: advance it a little, read a result, change an input based on what it read, advance again. That closed loop is the thing a one-shot binary structurally cannot do, and it's the reason the mode exists — hardware-in-the-loop style controllers, parameter sweeps without recompiling, optimisation, and co-simulation with a host-side model.

You get libhovercraft.so on Linux or hovercraft.dll on Windows. Every exported symbol is prefixed HVR_ and declared extern "C"; no C++ type crosses the boundary. The full ABI is declared in runtime/hovercraft.h, which is the authoritative reference — this page describes the model.

Compiler options

--hovercraftEmit the shared library instead of a simulation binary. Nothing is executed after the build.
-o <path>Write the artifact to <path> instead of the default name (-o=<path> also accepted). Names the final artifact only — the generated sim.cpp always lands in the working directory, where it stays inspectable.
--dump-astPrint the entry file's AST and exit.

No platform extension is appended to -o's argument; the path is used verbatim. Flag order doesn't matter, and an unrecognised flag is an error rather than being silently ignored.

What becomes part of the ABI

The interface is derived from main's own signature and the .save() directives — three groups of symbols, named after the things they expose:

Paramsfrom main's <> static args → int HVR_set_param_<name>(double)
Inputsfrom main's () logic args → void HVR_set_input_<name>(...)
Outputsfrom every .save()d column → double HVR_get_output_<name>(void)

…exports HVR_set_param_gain, HVR_set_input_vin, HVR_set_input_taps, HVR_get_output_main_vout, HVR_get_output_main_drive and HVR_get_output_I_main_vsense.

Non-identifier characters in a column name become underscores, which is why the branch-current column I(main.vsense) yields HVR_get_output_I_main_vsense. If two columns ever mangle to the same symbol, the collision is resolved by omitting the per-signal getter; read those through HVR_get_output() by exact name instead.

Params vs. inputs — an important distinction

<> params () inputs
Settable only while time == 0 at any time, mid-simulation
Otherwise returns HVR_ERR_TIME
To change call HVR_reset_sim() first just call the setter
Types scalar double scalar or array

A <> param is a structural quantity: it's folded into the design during elaboration, so it can only be re-read by starting the run over. A () input is a signal, read fresh by the logic on every timestep — that's the one you drive in a control loop.

Array inputs take an explicit length, since a C pointer carries none:

Passing fewer elements than the array holds writes only the leading ones and leaves the rest untouched; the setter never reads past the array's declared size.

Driving the simulation

int HVR_step(long n) Advance n minimum timesteps (n · dt seconds)
int HVR_run(double secs) Advance an arbitrary duration in simulation seconds
double HVR_get_time(void) Current simulation time. Reading it never starts the run
int HVR_reset_sim(void) State → initial values, time → 0, OP re-solved if enabled. The log is not cleared
void HVR_reset_log(void) Clear the log. Simulation state untouched

The simulation is initialised lazily, on the first call that actually needs state to exist. That's deliberate: a host may call every setter it likes before the first solve happens, so the very first timestep already sees the host's values rather than the compiled-in defaults.

The stop time given to .tran does not bound a hovercraft run — the host decides when to stop by not calling HVR_run again. It survives in the manifest as end_time, a suggested default.

Reading results

Two channels, for two access patterns. Per-signal reads allocate nothing and suit a host polling one or two signals every step:

These return the signal's current value — the live node voltage, branch current or logic value — not a logged row. HVR_get_output() returns HVR_ERR_UNKNOWN and leaves *out untouched for an unknown name, so "no such signal" stays distinguishable from a signal whose value genuinely is 0.

Bulk log queries, for waveforms:

Every HVRLogResult must be released with HVR_free_log_result(). The arrays are allocated on the C++ side and a caller in another language has no way to free them correctly itself.

CSV export is opt-in here: a hovercraft build writes no file unless the host asks for one, unlike the standalone binary which always produces simulation_output.csv.

Return codes

HVR_OK 0 Success
HVR_ERR_TIME -1 HVR_set_param_* called with time != 0
HVR_ERR_IO -2 HVR_save_log could not open the file
HVR_ERR_UNKNOWN -3 Invalid argument — negative duration, unknown signal name

Self-description — HVR_manifest()

The per-project symbols above depend on the .hvr source, so their names can't be declared in a shared header. Rather than requiring the host to keep the original source alongside the library, every hovercraft build embeds a JSON description of its own ABI:

This makes a .so genuinely self-contained, and it matters most for inputs: an exported symbol name carries no signature, and HVR_set_input_<name> is a one-argument scalar setter for a scalar input but a two-argument (const T *values, long n) setter for an array one. Guessing wrong doesn't fail cleanly — it segfaults — so a host should always dispatch on the manifest's kind field rather than on the symbol's existence.

Instance model

A hovercraft library holds its entire simulation — state variables, the MNA system, the log — in file-scope statics. One loaded library is therefore exactly one simulation, and the HVR_* functions are not reentrant.

The consequence is worth stating plainly, because the failure is silent: loading the same library file twice in one process does not give two independent simulations. dlopen returns the same handle for a path already loaded, so both references share one set of statics and writes through one show up in the other. To run several instances concurrently, give each a distinct copy of the library file — a distinct path gets its own mapping with its own statics.

Caveat — what a <> param can reach

A <> param is runtime-settable only where it's read by equations in main's own body. If main instead passes it to a submodule (Src<amp, freq>()) or uses it as an element value (R<rload>()), elaboration folds it to a constant and stamps it into the netlist once — the setter returns HVR_OK and changes nothing.

Practically, a design with tunable params wants main to be an analog module, since a structural module main can't contain equations at all and therefore can't read a <> param anywhere: