sdl_runtime_compiler git · main
SDL3 game for running and compiling code at runtime
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.gzStart dl level With help of Claude
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; +};