Linux shared libraries for Space Engineers (version 1) to load the native Windows DLLs the game client and its Dedicated Server require.
These are thin C++17 shims built around a custom PE (Portable Executable) loader. They load the original, unmodified Windows native DLLs shipped with the game and thunk calls across the ABI boundary so the game runs on Linux.
The wrappers do not reimplement any of the underlying libraries. Implementations of the Win32 API calls used by the native libraries are provided based on Linux primitives, including optional ntsync support.
| Output | Wraps the Windows DLL |
|---|---|
libHavok.so |
Havok.dll (physics) |
libRecastDetour.so |
RecastDetour.dll (navmesh) |
libVRageNative.so |
VRage.Native.dll (voxels) |
libD3DCompiler.so |
d3dcompiler_47.dll (shader compiler) |
Havok.dll hands the engine raw C function pointers for its callbacks (contact
listeners, entity listeners, phantom handlers, …) and passes no user-data /
context pointer when it later invokes them. To route each callback back to the
correct managed target we therefore need a distinct code address per live
callback. src/HavokThunk_*.cpp provide these: for every callback signature
family there is a pool of CALLBACK_SLOTS template-instantiated thunks
(<family>_thunk<Index>), each of which reads its target from a per-slot table.
Invoking a callback is a single lock-free atomic load; registering and releasing
one (bridge_* / release_*) is O(1) via an index map plus a free-list of slots.
The full per-family mapping, sizing rationale, limits and margins are documented in
docs/HavokCallbackBridge.md.
Those thunk addresses are baked into the binary at compile time, so the pool size is fixed per build and cannot be grown at run time (doing so would require emitting machine code / JIT trampolines, which is architecture-specific and out of scope for a generated wrapper).
src/Havok.cpp (one extern "C" shim per Havok.dll export) and the thunk sources
are regenerated together by
tools/generate_havok_wrapper.py:
python3 tools/generate_havok_wrapper.py # rewrites src/Havok.cpp, src/HavokThunk_*.cpp, HavokThunkRegistry.hGenerating Havok.cpp parses the game's decompiled [DllImport] declarations from
../dotnet-game-local/HavokWrapper/Havok; that dependency is only needed to
regenerate, not to build, since the generated sources are committed.
Each pool holds a fixed number of slots (one templated thunk each), baked in at
compile time, so binary size and compile time grow roughly linearly with the
total slot count. Rather than one flat limit for every family, CALLBACK_SLOTS
in the generator sizes each family to its worst case. The size a family needs
depends entirely on how the game's Havok wrapper marshals that callback:
- Shared
staticdelegates — entity/contact/sound listeners, constraint and activation listeners, wheel modifiers, break-off handlers and the log sink all hold their native delegate in astatic readonlyfield and dispatch per-instance via the listener handle passed as the first argument. These marshal to a single pointer no matter how many grids, blocks or constraints exist, so each is sized to its tiny fixed peak: a family with exactly one such pointer collapses to a single-slot bridge (no map/free-list), and a family with a few gets the smallest power of two ≥ 16× that peak. (Note: astaticmethod passed as a bare method-group argument is not in this class — the game does not cache that conversion, so it marshals a fresh pointer each time; see the doc.) - Per-instance delegates —
HkPhantomCallbackShapeis the only wrapper that marshals a fresh native delegate per instance: every live phantom shape burns 2void_ptr_ptrslots (enter/leave). The game creates one phantom shape per trigger/detector volume — ship connectors and ejectors, collectors, gravity generators, merge blocks, safe zones, and opt-in detector entities — sovoid_ptr_ptrscales with block count and gets 32768 slots. Loading "Many Lifters Slowness" (699 grids) exhausted the previous flat limit of 4096 and aborted. These slots are reclaimed when the shape is destroyed (see below), so 32768 bounds the concurrent number of live phantoms, not the cumulative total ever created. - Per-call synchronous delegates —
HkShapeLoaderbuffer cleanup marshals a fresh callback per call that Havok invokes only during the call, so the wrapper releases its slot right after (no leak), sizing it for loader concurrency (128).HkConstraint.FindConnectedConstraintsis the same: itsreaderis a bare method-group (ConstraintReader) that marshals a fresh pointer per world load, so bridging it without release pinned the first load's thunk and aborted on the second world load. It is now released after each (synchronous) call and given a 128-slot pool. The dormantHkTaskProfilercallbacks are a latent copy of the same pattern. Seedocs/HavokCallbackBridge.md.
This keeps libHavok.so at ~17 MB (versus ~180 MB if every family used 32768).
If a pool is ever exhausted anyway, the bridge prints a diagnostic naming the
offending family and pointing back at the generator, then aborts; raise that
family's entry in CALLBACK_SLOTS, regenerate, and rebuild. Run-time growth is
not possible — the thunk addresses are compile-time template instantiations.
Havok's callback bridge frees a slot only when its owner tells it to. For the
static-delegate listeners that happens through the owner-release path
(register_callback_owner / release_callback_owner), but those pointers are
shared and pinned for the process lifetime anyway. The pointers that actually
accumulate are HkPhantomCallbackShape's per-instance enter/leave delegates —
and Havok only signals that a shape is gone by invoking its delete callback.
So instead of bridging a per-instance delete callback, every phantom shape is
handed one shared native dispatcher (phantom_delete_dispatch). When Havok
destroys a shape it calls that dispatcher with the shape handle, which forwards to
the managed delete handler and then releases the shape's enter/leave bridge slots
for reuse. Without this, a long session that repeatedly builds and destroys
phantom blocks (or streams grids in and out) would leak void_ptr_ptr slots and
eventually exhaust the pool even with few phantoms alive at once.
Requirements: cmake (>= 3.10), make, g++ (C++17).
make # cmake --preset default + cmake --build --preset default
ls build/*.so # libHavok.so libRecastDetour.so libVRageNative.so libD3DCompiler.so
make clean # wipe the build/ directoryCI (.github/workflows/build.yml) builds on
every push and publishes the four libraries in two configurations, as two
separate assets on the same release:
| Asset | Configuration |
|---|---|
linux-native-wrappers.tar.gz |
Release (-O3 -DNDEBUG) |
linux-native-wrappers.debug.tar.gz |
Debug (-O0 -g) |
- Push to
main→ a public release taggedv1.0.<run>and marked latest. - PR → a draft release (not public, not latest, no git tag until published).
- Draft PRs are not built.
The build process of Pulsar for Linux and Magnetar download the
linux-native-wrappers.tar.gz (Release) asset from the latest release;
each archive contains the four .so files at its root. The .debug variant
carries unoptimized, symbol-rich builds for debugging.
MIT — see LICENSE.