Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 6fd7f6b

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

sdl3 c++ compiler dlopen cmake · first commit 2026-04-19 · last commit 2026-07-03 (3 months ago) · synced 3 days ago · upstream: git.ide3.de/hsnr/sdl-runtime-compiler

C++ 72.3% C 26.2%
git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gz

Start dl level With help of Claude

Christian Immanuel · 2026-05-30 18:37 · 6fd7f6b388552a9a20ff3ee9d71e1e5a61f5f447

 CMakeLists.txt              |   7 ++
 src/levels/level.hpp        |   7 +-
 src/levels/level_list.cpp   |  47 ++++++++++
 src/levels/level_runner.hpp |   1 +
 src/states/game_state.cpp   |  55 +++++++++++-
 src/states/game_state.hpp   |   5 ++
 src/systems/game_mod_abi.h  |  47 ++++++++++
 src/systems/mod_loader.cpp  | 212 ++++++++++++++++++++++++++++++++++++++++++++
 src/systems/mod_loader.hpp  |  39 ++++++++
 9 files changed, 415 insertions(+), 5 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 05003d0..7b138ec 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -58,6 +58,7 @@ function(apply_common_settings target)
         Freetype::Freetype
         OpenGL::GL
         m
+        ${CMAKE_DL_LIBS}
     )
 
     # Profiler flag.
@@ -134,6 +135,12 @@ add_custom_target(compile_commands_link ALL
     DEPENDS SDL3_RuntimeCompiler
 )
 
+add_custom_command(TARGET SDL3_RuntimeCompiler POST_BUILD
+    COMMAND ${CMAKE_COMMAND} -E make_directory /tmp/game_mod_include
+    COMMAND ${CMAKE_COMMAND} -E copy
+        ${CMAKE_SOURCE_DIR}/src/systems/game_mod_abi.h
+        /tmp/game_mod_include/game_mod_abi.h)
+
 # ─────────────────────────────────────────────────────────────────────────────
 # Benchmarks
 # ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/levels/level.hpp b/src/levels/level.hpp
index 5bb7f4e..79fb7b1 100644
--- a/src/levels/level.hpp
+++ b/src/levels/level.hpp
@@ -31,7 +31,8 @@ struct LevelStep {
 
 
 struct Level {
-    std::string              name;
-    std::string              desc;
-    std::vector<LevelStep>   steps;
+    std::string name;
+    std::string desc;
+    bool        dynamic_lib = false;
+    std::vector<LevelStep> steps;
 };
diff --git a/src/levels/level_list.cpp b/src/levels/level_list.cpp
index c93e80a..f2fbf86 100644
--- a/src/levels/level_list.cpp
+++ b/src/levels/level_list.cpp
@@ -425,6 +425,53 @@ Place all three lines BEFORE the existing return.)",
             },
         },
     },
+    {
+        .name = "Sandbox",
+        .desc = "Build the world yourself: compile a library that spawns objects.",
+        .dynamic_lib = true,
+        .steps = {
+            {
+                .hint    = "Spawn a box with box(x,y,z, hx,hy,hz);",
+                .details = R"(This level IS the code you write.
+
+The editor wall is a C library. When you Compile, the game
+rebuilds it and spawns whatever your code describes.
+
+Two helpers are available:
+  box(x, y, z,  hx, hy, hz);   - a solid box at (x,y,z), half-size (hx,hy,hz)
+  text(x, y, z, "word");       - floating text at (x,y,z)
+
+Build something like:
+  box(0, 5, -3,  1, 1, 1);
+
+Then press the Compile pult. Edit and recompile freely.)",
+                .grants = {
+                    TokenGrant::give("box"),
+                    TokenGrant::give("text"),
+                    TokenGrant::give("("),
+                    TokenGrant::give(")"),
+                    TokenGrant::give(","),
+                    TokenGrant::give(","),
+                    TokenGrant::give(","),
+                    TokenGrant::give(","),
+                    TokenGrant::give(";"),
+                    TokenGrant::give("0.0"),
+                    TokenGrant::give("1.0"),
+                    TokenGrant::give("2.0"),
+                    TokenGrant::give("3.0"),
+                    TokenGrant::give("5.0"),
+                    TokenGrant::give("-3.0"),
+                    TokenGrant::give("box(0,-16,-3,1,1,1);"),
+                    TokenGrant::give("\"hi\""),
+                    TokenGrant::give("\"world\""),
+                },
+                .scene        = {},
+                .reset_carry  = true,
+                .goal         = AllOf{},
+                .max_ai_words = 0,
+            },
+        },
+    },
 };
 
 const Level& get_Level(size_t i) { return levels[i]; }
diff --git a/src/levels/level_runner.hpp b/src/levels/level_runner.hpp
index c011f06..be8d454 100644
--- a/src/levels/level_runner.hpp
+++ b/src/levels/level_runner.hpp
@@ -12,6 +12,7 @@ public:
     bool             complete() const { return current_step >= level->steps.size(); }
     size_t           step()     const { return current_step; }
     const LevelStep& current()  const { return level->steps[current_step]; }
+    bool             dynamic_lib() const { return level->dynamic_lib; }
 
     // All tokens to spawn for the current step (resolved — no choices yet)
     // Choices are returned as multiple Text3d objects placed near each other
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 63b7343..daf8fcf 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -10,6 +10,7 @@
 #include "levels/level_check.hpp"
 #include "levels/level_list.hpp"
 #include "rendering/gl_renderer.hpp"
+#include "systems/game_mod_abi.h"
 #include "systems/input_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/pick_system.hpp"
@@ -35,6 +36,7 @@ GameState::GameState(size_t starting_level)
     word_anim_sys   = cpputils::make_unique<WordAnimSystem>();
     freelook_sys    = cpputils::make_unique<FreelookSystem>();
     particle_sys    = cpputils::make_unique<ParticleSystem>();
+    mod_loader      = cpputils::make_unique<ModLoader>();
 
     floor_sys       = cpputils::make_unique<FloorMandelbrot>();
     floor_sys->generate(ctx.world->scale(), -ctx.world->scale());
@@ -302,7 +304,12 @@ void
 GameState::on_Compile()
 {
     std::string src = TextWall::extract_Text(ctx.wall_texts, Wall::Role::Editor);
-    compiler_sys->compile(ctx.wall_texts, src);
+
+    if (level_runner && !level_runner->complete() && level_runner->dynamic_lib()) {
+        mod_loader->build(ctx.wall_texts, src, ctx.world->scale());
+    } else {
+        compiler_sys->compile(ctx.wall_texts, src);
+    }
 }
 
 
@@ -535,10 +542,16 @@ GameState::update(double dt)
         compiler_sys->update(ctx.wall_texts, ctx.world->scale());
         if (compiler_sys->last_compile_ok) {
             compiler_sys->last_compile_ok = false;
-            step_ledger.compile_ok    = true;
+            step_ledger.compile_ok = true;
         }
 
+        mod_loader->update(ctx.wall_texts, ctx.world->scale());
+        if (mod_loader->last_build_ok) {
+            mod_loader->last_build_ok = false;
+            mod_Apply(mod_loader->load_Objects());
+        }
     }
+
     { FP_ZONE("update.goals");
         celebration_Tick(dt);
         level_TryAdvance();
@@ -1086,3 +1099,41 @@ GameState::celebration_Finish()
     celebration.banner.reset();
     particle_sys->clear();
 }
+
+
+void
+GameState::mod_Apply(const std::vector<ModObject>& objs)
+{
+    // Remove previously-spawned lib objects from the scene.
+    if (!mod_scenery.empty()) {
+        std::unordered_set<GameObject*> set(mod_scenery.begin(), mod_scenery.end());
+        ctx.scenery.erase(
+            std::remove_if(ctx.scenery.begin(), ctx.scenery.end(),
+                [&set](const GameObjectPtr& sp) { return set.count(sp.get()) > 0; }),
+            ctx.scenery.end());
+        ctx.words.erase(
+            std::remove_if(ctx.words.begin(), ctx.words.end(),
+                [&set](const Text3dPtr& wp) { return set.count(wp.get()) > 0; }),
+            ctx.words.end());
+    }
+    mod_scenery.clear();
+
+    // Build fresh objects from the descriptions.
+    for (const ModObject& o : objs) {
+        if (o.kind == MOD_BOX) {
+            auto box = cpputils::make_unique<SolidBox>(
+                Vec3{ o.x, o.y, o.z },
+                Vec3{ o.hx, o.hy, o.hz },
+                SDL_FColor{ o.r, o.g, o.b, 1.0f });
+            mod_scenery.push_back(box.get());
+            ctx.scenery.push_back(std::move(box));
+        } else { // MOD_TEXT
+            Text3dPtr t = make_Text3d(o.text, Text3d::depth_floor, 0.003f,
+                                      Vec3{ o.x, o.y, o.z },
+                                      SDL_FColor{ o.r, o.g, o.b, 1.0f });
+            t->body.mode = PhysicsMode::Static;   // lib text just sits where placed
+            mod_scenery.push_back(t.get());
+            ctx.words.push_back(std::move(t));
+        }
+    }
+}
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 5d6c421..5fab661 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -17,6 +17,7 @@
 #include "systems/freelook_system.hpp"
 #include "systems/hint_system.hpp"
 #include "systems/input_system.hpp"
+#include "systems/mod_loader.hpp"
 #include "systems/particle_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/plate_system.hpp"
@@ -84,6 +85,7 @@ class GameState : public IState
     std::vector<WordAnim_Add>  anims_add;
     std::vector<WordAnim_Drop> anims_drop;
 
+    std::vector<GameObject*> mod_scenery;
     std::vector<GameObject*> step_scenery;
     std::vector<StepPlate>   step_plates;
 
@@ -100,6 +102,7 @@ class GameState : public IState
     cpputils::unique_ptr<ParticleSystem>   particle_sys;
     cpputils::unique_ptr<FloorMandelbrot>  floor_sys;
     cpputils::unique_ptr<LevelRunner>      level_runner;
+    cpputils::unique_ptr<ModLoader>        mod_loader;
 
 
     bool on_Pick();
@@ -126,6 +129,8 @@ class GameState : public IState
     void infowall_Clear();
     void infowall_StepdetailsShow();
 
+    void mod_Apply(const std::vector<ModObject>& objs);
+
     bool        level_TryAdvance();
     void        level_Advance();
     GoalContext goal_ContextBuild() const;
diff --git a/src/systems/game_mod_abi.h b/src/systems/game_mod_abi.h
new file mode 100644
index 0000000..24b4b0e
--- /dev/null
+++ b/src/systems/game_mod_abi.h
@@ -0,0 +1,47 @@
+/* game_mod_abi.h
+ *
+ * The contract between the engine and a player-built dynamic library.
+ *
+ * This header is included BOTH by the engine (to read the result) and is
+ * emitted verbatim into the user's generated source (so their game_build()
+ * agrees on the struct layout). Keep it pure C — no C++ — so the layout is
+ * stable across the dlopen boundary regardless of how either side is built.
+ *
+ * IMPORTANT (security): the engine compiles and dlopen()s code the player
+ * typed on the editor wall. This is acceptable ONLY because it is a local,
+ * single-player sandbox where the player can affect nothing but their own
+ * process. Never feed untrusted or networked input through this path.
+ */
+#ifndef GAME_MOD_ABI_H
+#define GAME_MOD_ABI_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum {
+    MOD_TEXT = 0,
+    MOD_BOX  = 1
+} ModObjKind;
+
+/* A flat, POD description of one object to spawn. No pointers, no C++:
+ * the library fills these in, the engine constructs the real Text3d /
+ * SolidBox from them. Nothing the library allocates outlives dlclose(). */
+typedef struct {
+    int    kind;          /* ModObjKind */
+    double x, y, z;       /* world position (centre) */
+    double hx, hy, hz;    /* half-extents — BOX only; ignored for TEXT */
+    float  r, g, b;       /* colour 0..1 */
+    char   text[64];      /* TEXT only, null-terminated; ignored for BOX */
+} ModObject;
+
+/* The user's compiled library MUST export this symbol.
+ * Fills `out` (capacity `max` entries) and returns the number written.
+ * Returning more than `max` is undefined; the engine passes a generous max. */
+int game_build(ModObject* out, int max);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GAME_MOD_ABI_H */
diff --git a/src/systems/mod_loader.cpp b/src/systems/mod_loader.cpp
new file mode 100644
index 0000000..f3e5c59
--- /dev/null
+++ b/src/systems/mod_loader.cpp
@@ -0,0 +1,212 @@
+#include <SDL3/SDL.h>
+#include <algorithm>
+#include <dlfcn.h>
+#include <string>
+#include <sys/select.h>
+
+#include "systems/mod_loader.hpp"
+#include "text_3d/text_3d.hpp"
+
+
+// Where game_mod_abi.h is installed at runtime, and the build scratch dir.
+// The header must be reachable by the runtime gcc via this -I path. Adjust to
+// wherever your CMake installs it (see CMake notes).
+static constexpr const char* MOD_INCLUDE_DIR = "/tmp/game_mod_include";
+static constexpr const char* MOD_SRC_PATH    = "/tmp/game_mod.c";
+
+
+// ── Harness ──────────────────────────────────────────────────────────────
+// Wraps the player's spawn-helper calls in a real translation unit. The
+// player's tokens become the body of game_build(); box()/text() append to
+// the engine-provided output array. Bounds-checked so a player can't
+// overflow `out`.
+std::string
+ModLoader::wrap_Source(const std::string& user_body)
+{
+    std::string s;
+    s += "#include \"game_mod_abi.h\"\n";
+    s += "#include <string.h>\n";
+    s += "\n";
+    s += "static ModObject* g_out;\n";
+    s += "static int        g_max;\n";
+    s += "static int        g_n;\n";
+    s += "\n";
+    s += "static void box(double x,double y,double z,"
+         "double hx,double hy,double hz){\n";
+    s += "    if(g_n>=g_max) return;\n";
+    s += "    ModObject* o=&g_out[g_n++];\n";
+    s += "    o->kind=MOD_BOX; o->x=x;o->y=y;o->z=z;\n";
+    s += "    o->hx=hx;o->hy=hy;o->hz=hz;\n";
+    s += "    o->r=0.55f;o->g=0.55f;o->b=0.75f; o->text[0]=0;\n";
+    s += "}\n";
+    s += "\n";
+    s += "static void text(double x,double y,double z,const char* t){\n";
+    s += "    if(g_n>=g_max) return;\n";
+    s += "    ModObject* o=&g_out[g_n++];\n";
+    s += "    o->kind=MOD_TEXT; o->x=x;o->y=y;o->z=z;\n";
+    s += "    o->hx=o->hy=o->hz=0.0;\n";
+    s += "    o->r=1.0f;o->g=1.0f;o->b=1.0f;\n";
+    s += "    strncpy(o->text, t?t:\"\", sizeof(o->text)-1);\n";
+    s += "    o->text[sizeof(o->text)-1]=0;\n";
+    s += "}\n";
+    s += "\n";
+    s += "int game_build(ModObject* out,int max){\n";
+    s += "    g_out=out; g_max=max; g_n=0;\n";
+    s += "    /* ---- player source begins ---- */\n";
+    s += user_body;
+    s += "\n    /* ---- player source ends ---- */\n";
+    s += "    return g_n;\n";
+    s += "}\n";
+    return s;
+}
+
+
+void
+ModLoader::build(TextWallList& texts, const std::string& user_src, float world_scale)
+{
+    last_build_ok = false;
+
+    // Clear previous output from the Compiler wall.
+    texts.erase(
+        std::remove_if(texts.begin(), texts.end(),
+            [](const TextWallPtr& wt) {
+                return wt->wall_position == Wall::position_of(Wall::Role::Compiler);
+            }),
+        texts.end());
+    count_ = 0;
+
+    // Write wrapped source.
+    FILE* f = fopen(MOD_SRC_PATH, "w");
+    if (!f) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
+                     "ModLoader: cannot write %s", MOD_SRC_PATH);
+        push_Line(texts, "internal error: cannot write source", world_scale);
+        return;
+    }
+    const std::string wrapped = wrap_Source(user_src);
+    fputs(wrapped.c_str(), f);
+    fclose(f);
+
+    // Unique output path each build — dlopen() caches handles by path, so a
+    // fresh path guarantees the next checkpoint actually loads new code.
+    pending_so_ = "/tmp/game_mod_" + std::to_string(build_serial_++) + ".so";
+
+    // Build a shared library. -fPIC + -shared; stderr→stdout for error capture.
+    std::string cmd = "gcc -shared -fPIC -O0 -I";
+    cmd += MOD_INCLUDE_DIR;
+    cmd += " ";
+    cmd += MOD_SRC_PATH;
+    cmd += " -o ";
+    cmd += pending_so_;
+    cmd += " 2>&1";
+
+    if (pipe_) { pclose(pipe_); pipe_ = nullptr; }
+    pipe_ = popen(cmd.c_str(), "r");
+    if (!pipe_)
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "ModLoader: popen failed");
+}
+
+
+void
+ModLoader::update(TextWallList& texts, float world_scale)
+{
+    if (!pipe_) return;
+
+    char buf[512];
+    int  fd = fileno(pipe_);
+    fd_set set; FD_ZERO(&set); FD_SET(fd, &set);
+    struct timeval tv{0, 0};   // poll, never block the frame
+
+    while (select(fd + 1, &set, nullptr, nullptr, &tv) > 0) {
+        if (!fgets(buf, sizeof(buf), pipe_)) {
+            // EOF — build finished. No error lines means success.
+            pclose(pipe_);
+            pipe_ = nullptr;
+            if (count_ == 0) {
+                last_build_ok = true;
+                last_so_path_ = pending_so_;
+                push_Line(texts, "BUILD OK", world_scale);
+            }
+            return;
+        }
+        std::string line(buf);
+        if (!line.empty() && line.back() == '\n') line.pop_back();
+        if (!line.empty())
+            push_Line(texts, line, world_scale);
+
+        FD_ZERO(&set); FD_SET(fd, &set);
+    }
+}
+
+
+void
+ModLoader::push_Line(TextWallList& texts, const std::string& line, float world_scale)
+{
+    // Shift existing compiler lines up, drop the new one at the bottom.
+    for (TextWallPtr& wt : texts)
+        if (wt->role == Wall::Role::Compiler)
+            wt->text3d->pos.y += static_cast<double>(line_height);
+
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Compiler, world_scale);
+    Vec3 pos = tf.position;
+    pos.y = start_y;
+
+    Text3dPtr t = make_Text3d(line, Text3d::depth_compiler, 0.003f,
+                              pos, Text3d::color_compiler);
+    t->yaw = tf.yaw;
+
+    texts.push_back(cpputils::make_unique<TextWall>(TextWall{
+        tf.wall_position,
+        Wall::Role::Compiler,
+        std::move(t)
+    }));
+    count_++;
+}
+
+std::vector<ModObject>
+ModLoader::load_Objects()
+{
+    std::vector<ModObject> result;
+    if (last_so_path_.empty()) return result;
+
+    // Close the previous handle before loading the new one. Safe because the
+    // engine — not the .so — owns every object we spawn from these PODs, so
+    // nothing live points into the unmapped library after dlclose.
+    if (handle_) {
+        dlclose(handle_);
+        handle_ = nullptr;
+    }
+
+    dlerror();   // clear stale error
+    handle_ = dlopen(last_so_path_.c_str(), RTLD_NOW | RTLD_LOCAL);
+    if (!handle_) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
+                     "ModLoader: dlopen failed: %s", dlerror());
+        return result;
+    }
+
+    using BuildFn = int(*)(ModObject*, int);
+    // The cast-through-void* dance silences -Wpedantic on function-pointer casts.
+    BuildFn game_build =
+        reinterpret_cast<BuildFn>(dlsym(handle_, "game_build"));
+    if (!game_build) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
+                     "ModLoader: dlsym(game_build) failed: %s", dlerror());
+        return result;
+    }
+
+    constexpr int MAX_OBJ = 256;
+    result.resize(MAX_OBJ);
+    int n = game_build(result.data(), MAX_OBJ);
+    if (n < 0)        n = 0;
+    if (n > MAX_OBJ)  n = MAX_OBJ;
+    result.resize(static_cast<size_t>(n));
+    return result;
+}
+
+
+ModLoader::~ModLoader()
+{
+    if (handle_) dlclose(handle_);
+    if (pipe_)   pclose(pipe_);
+}
diff --git a/src/systems/mod_loader.hpp b/src/systems/mod_loader.hpp
new file mode 100644
index 0000000..f4aff09
--- /dev/null
+++ b/src/systems/mod_loader.hpp
@@ -0,0 +1,39 @@
+#pragma once
+#include <string>
+#include <cstdio>
+#include <vector>
+
+#include "wall/text_wall.hpp"
+
+#include "systems/game_mod_abi.h"
+
+class ModLoader
+{
+public:
+    void build(TextWallList& texts, const std::string& user_src, float world_scale);
+    void update(TextWallList& texts, float world_scale);
+    bool is_Running() const { return pipe_ != nullptr; }
+    bool last_build_ok { false };
+    const std::string& last_so_path() const { return last_so_path_; }
+
+    // Checkpoint 2: dlopen the last successful .so, call game_build,
+    // return the spawned descriptions. Empty vector on any failure.
+    std::vector<ModObject> load_Objects();
+
+    ~ModLoader();   // dlclose on teardown
+
+private:
+    static std::string wrap_Source(const std::string& user_body);
+    void push_Line(TextWallList& texts, const std::string& line, float world_scale);
+
+    FILE*       pipe_  { nullptr };
+    int         count_ { 0 };
+    int         build_serial_ { 0 };
+    std::string pending_so_;
+    std::string last_so_path_;
+
+    void*       handle_ { nullptr };   // current dlopen handle
+
+    static constexpr float line_height = 0.4f;
+    static constexpr float start_y     = -15.0f;
+};