A lightweight, cross-platform rebuild of AngeTheGreat's Engine Sim. The
original physics solver, dynamic audio synthesis pipeline, and .mr engine-scripting loader are carried over
unmodified (see include/, src/, scripting/) — only the Windows/DirectX "Delta Studio" rendering layer has been
stripped out and replaced with a minimal SDL2 + Dear ImGui shell (app/).
- Window is freely resizable (drag any edge/corner) with a 420x480 minimum — the single ImGui
panel re-reads the actual OS window size every frame and rebuilds its layout to fill it, so
scaling happens live while dragging, not just on next launch. Runs without
SDL_WINDOW_ALLOW_HIGHDPIon purpose: with it set, the window's point-size and the renderer's actual pixel-size can diverge on a Retina display, and any mismatch between when each gets queried renders the whole UI at the wrong scale (content stuck in a quarter of the window, rest black) — leaving HighDPI off keeps the two always exactly 1:1, at the cost of a slightly soft (not crisp-Retina) render for this small utility GUI. AnSDL_AddEventWatchcallback also keeps rendering during an active resize drag itself (macOS otherwise blocks the whole event loop until you let go of the window edge) - Live engine RPM
- Throttle slider, a "GAS" pedal button (hold to accelerate, let off to return to idle), plus the
original
Q/W/E/Rthrottle-preset keys, and holdSto crank the starter. Every input source sets the throttle directly with no app-level ramp/interpolation — GAS held snaps straight to full, released snaps straight back to idle - Idle RPM slider (500-2500). Idle isn't app-managed anywhere in the original engine-sim
(confirmed by grepping the upstream app source — it never references "idle"); it's entirely
emergent from
Intake::process()'s constant idle-circuit flow (idle_flow_rate), independent of the main throttle plate and tuned per engine script — there's no direct "set idle RPM" knob in the physics core to bind a slider to. So this works by closed-loop control instead: the app's own idle-throttle floor is nudged up or down every ~2 seconds based on which way actual RPM is off from the slider's target, the same way a real idle-air-control valve hunts for a setpoint. It reacts slowly on purpose (matching how long this engine's own RPM takes to settle after a throttle change; correcting harder/faster just fights that lag and can overshoot into a stall), plus a separate, much faster-reacting safety net that pulls throttle back up the instant RPM actually drops near stalling, regardless of the target or the normal correction cadence — verified by testing an earlier, untethered version of this controller straight into a dead stall on N/A V12 (chasing a target below what it can sustain) and confirming it just sat there raising throttle uselessly forever afterward, same as a real stalled engine not restarting itself. Some engines can't sustain every value on the slider — they'll settle short of an unreachably low target rather than actually stalling chasing it. A screaming F1 V10 redlining at 18,300 RPM may still legitimately struggle to idle even so — same as the real car historically did - Gear readout with Shift Up/Shift Down buttons (or the ↑/↓ arrow keys — one shift per press, not
continuous while held), driving the original (unmodified)
Transmissionclass. Clutch is fully locked whenever in gear (matching the original's own default — it only ever slips there if you hold its manual clutch-pedal keys), except for a brief ~250ms cut-and- ramp right after each shift to simulate the mechanical time a real shift takes rather than slamming straight to full lockup at the new gear ratio. Locking the rest of the time (instead of the throttle-proportional slip an earlier version used) keeps the engine and vehicle coupled through the gear ratio while coasting off-throttle, so lifting off at speed shows real engine braking instead of the crank snapping back to idle as if the vehicle had no inertia - Selected engine readout
- "Select Engine" button — lists every self-contained
.mrscene underassets/engines/by its own in-script engine name (parsed from the scene'sengine(name: "...", ...)call, not the asset file path): four naturally-aspirated engines ship by default (N/A V12, Formula 1 V10, N/A I6, N/A V8) - Simulation frequency slider (400 Hz – 400,000 Hz)
- Engine load slider — a continuous float from
0.00to1.00(not a toggle), applied as a proportional drag via the (unmodified)Dynamometer, same class the original app's dyno/load- simulation cluster used. It targets a fraction below the engine's own current speed each instant rather than always braking toward a dead stop (the constraint solver saturates at the torque ceiling whenever it can't fully satisfy its velocity target, so a fixed zero-speed target made any nonzero load either do nothing or stall the engine outright, never anything smooth in between) - 5-band graphic equalizer (60 Hz / 250 Hz / 1 kHz / 4 kHz / 12 kHz, ±24/+12 dB) — a new post-processing
stage (
app/equalizer.h/.cpp) applied to the final PCM output in the audio callback, not part of the original engine-sim DSP - External Control panel (button in the main window): three tabs driving the engine directly,
in-process — no second app, no network round-trip. Simulated (a spring-loaded accelerator
slider driving a small simulated engine + 6-speed automatic transmission, smooth ~0.45s shifts,
9000 RPM shift point, harder to accelerate each gear up), Manual (direct RPM/load sliders,
sent as-is), and OBD2 (reads live RPM and throttle position from a real BMW F3x over its
factory ENET diagnostic port — protocol verified live against a B48 LCI, see
app/obd2_client.cpp's header comment for the full reverse-engineering trail — and forwards them, RPM scaled 1.3x so the B48's 7000 RPM redline lines up with the V12's 9100 RPM redline). RPM is forced via the dynamometer's own hold mode (minimal/zero smoothing), load drives the throttle plate; closing the panel hands control back to the local GUI/keyboard immediately. A Blend In slider controls how long (0.1-5s) the engine's audio takes to fade in once RPM/throttle clear a threshold (OBD2: real RPM > 2400 and throttle > 60%; Simulated: RPM > 3000 and load > 60%; Manual isn't gated) — the engine itself is always driven continuously either way, only whether you actually hear it is gated. The app also still listens on UDP127.0.0.1:50505(app/external_control.h) for any third-party sender that wants to drive it the old way, independent of this panel. - Batch Renderer panel (button in the main window) plus an Optimized playback mode: the live
physics/audio core pegs a full CPU core, which is the whole reason this exists — a "Generate
.RKF" pipeline (
app/rkf/offline_renderer.h/.cpp) drives the same core faster than real time to record real acceleration sweeps (1000 RPM to the engine's own redline, at(redline-1000)/1500seconds each) across a throttle x load grid, plus fixed-RPM hold recordings for steady cruise, at one of four quality presets (LOW 4x4 up to HQ 32x32 — seeapp/rkf/rkf_format.h). Everything is packed into a single.rkfcontainer file (raw PCM, metadata, and the original.mrscript text, all in one place) viaapp/rkf/rkf_writer.cpp. Loading a.rkfand switching to Optimized mode plays it back throughapp/rkf/rkf_player.cpp(nearest-grid-cell selection with a short crossfade, plus its own virtual-RPM model driven by throttle) at near-zero CPU, driven by the same gas pedal/load slider as Real-Time mode; flipping back to Real-Time mode recompiles the embedded script and runs the normal physics core instead — same file, either way. External Control's Simulated/Manual/OBD2 tabs (they force a literal target RPM onto a real physics sim) stay Real-Time-only. This is a v1: cell selection isn't a continuous bilinear blend and crossfades aren't phase-aligned to the outgoing combustion cycle yet — see the comments atopapp/rkf/rkf_player.hfor exactly what's simplified and why.
- CMake 3.16+
- A C++17 compiler
- SDL2 development headers
- Flex 2.6+ and Bison 3.2+ (needed to build the Piranha
.mrscript compiler)
macOS:
brew install sdl2 flex bisonLinux (Debian/Ubuntu):
sudo apt install libsdl2-dev flex bison cmake build-essentialmacOS note: Apple ships an ancient GPLv2 bison (2.3) and its own patched flex build that are both incompatible with Piranha's grammar/scanner.
dependencies/CMakeLists.txtautomatically prefers Homebrew'sbison/flex(brew install bison flex) over the system ones when present.
cmake -B build -S . -DCMAKE_POLICY_VERSION_MINIMUM=3.5
cmake --build build -j(CMAKE_POLICY_VERSION_MINIMUM=3.5 works around a very old cmake_minimum_required pinned inside the vendored
GoogleTest snapshot used for unit tests; it has no effect on this project's own code.)
./build/engine-sim-liteExternal control (Simulated/Manual/OBD2 telemetry) is built into engine-sim-lite itself — see
the "External Control" button in its own window, no separate binary to run alongside it.
Everything else (simple-2d-constraint-solver, csv-io, piranha, Dear ImGui) is vendored under
dependencies/submodules/ and built from source — no other dependencies to install.
./build/engine-sim-testRuns the original (unmodified) gas_system/function/synthesizer unit tests against the ported physics/audio
core.
engine-sim-lite also builds as an Android APK, reusing this same top-level
CMakeLists.txt (see its if (ANDROID) branches) via Gradle's CMake integration under
android/. SDL2's own Android support handles the JNI/window/audio glue; the only
app code that differs is app/main.cpp's startup, which on Android extracts the assets/+es/
trees bundled into the APK (android/app/src/main/assets/) out to the app's private storage on
first launch, then chdirs there -- after that every relative-path file access in the untouched
engine-sim/piranha code works exactly as it does on desktop, since the process's cwd is a real
filesystem in both cases.
- A JDK (17+)
- Android SDK cmdline-tools/platform-tools, NDK (28+ -- tested with 28.2.13676358), and the SDK's
own CMake package, all installable via
sdkmanager.minSdkVersionis 28 (Android 9): piranha'sstandard_allocator.hcallsstd::aligned_alloc, only present in bionic libc from API 28 on. - Everything the desktop build needs too (flex/bison, since piranha's grammar is generated by the same host-run flex/bison regardless of target platform)
cd android
./gradlew assembleDebugProduces android/app/build/outputs/apk/debug/app-debug.apk. Install with
adb install -r android/app/build/outputs/apk/debug/app-debug.apk, or open android/ in Android
Studio.
- No forced orientation -- the UI is a single vertical stack of controls (throttle, idle-RPM,
gear buttons, sim-frequency, engine load, equalizer), which already suits a phone's portrait
aspect better than a forced landscape lock. Whatever orientation the OS gives the app, the
existing per-frame
SetNextWindowSize(ImGui::GetIO().DisplaySize)layout (the same mechanism driving desktop's live-resize support) fills it. - Porting to Android surfaced (and fixed) two real, pre-existing, platform-independent bugs
in the vendored dependencies that desktop's Apple-clang build happened not to trigger:
simple-2d-constraint-solver/piranha's__forceinline/__int64MSVC-only macros were only ever given a fallback for__APPLE__, not other non-MSVC compilers (fixed by keying the fallback on_MSC_VERinstead); andPistonEngineSimulator::writeToSynthesizer()wrote a per-cylinder value into a hardcoded 8-element array regardless of actual cylinder count -- a real global-buffer-overflow for any engine with more than 8 cylinders (e.g. the bundled V12), caught with AddressSanitizer and fixed by deleting the array (it was write-only, never read).