From f4ee0e6904251b4060a5da7bcc07b45d85b0d171 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 28 Jun 2026 22:49:26 +0900 Subject: import template from raylib-jamstack --- .agents/knowledge/agent-bridge.md | 73 ++ .agents/knowledge/build-system.md | 54 ++ .agents/knowledge/console.md | 65 ++ .agents/knowledge/environment.md | 24 + .agents/knowledge/flecs-binding.md | 74 ++ .agents/knowledge/flecs-observability.md | 80 ++ .agents/knowledge/fx-pipeline.md | 213 +++++ .agents/knowledge/hot-reload.md | 84 ++ .agents/knowledge/jolt-binding.md | 193 ++++ .agents/knowledge/linting.md | 115 +++ .agents/knowledge/live-mount.md | 129 +++ .agents/knowledge/logging.md | 62 ++ .agents/knowledge/raylib-binding.md | 87 ++ .agents/knowledge/rmlui-binding.md | 99 +++ .agents/knowledge/ruby-lsp.md | 130 +++ .agents/knowledge/steep.md | 89 ++ .agents/knowledge/testing.md | 37 + .agents/knowledge/web-target.md | 182 ++++ .agents/rules/dont-edit-generated.md | 10 + .agents/rules/link-order.md | 10 + .agents/rules/lld-no-gcc-lto.md | 12 + .agents/rules/main-thread-eval.md | 11 + .agents/rules/mruby-rebuild.md | 11 + .agents/rules/raylib-platform-objs.md | 7 + .agents/rules/wsl-toolchain.md | 11 + .agents/settings.json | 27 + .agents/skills/add-binding-fn/SKILL.md | 43 + .agents/skills/add-flecs-system/SKILL.md | 43 + .agents/skills/add-knowledge/SKILL.md | 39 + .agents/skills/build-and-verify/SKILL.md | 43 + .agents/skills/new-demo/SKILL.md | 44 + .agents/skills/ruby-to-native/SKILL.md | 130 +++ .clang-format | 78 ++ .clangd | 20 + .claude | 1 + .dispatch/build-agent.md | 57 -- .dispatch/package-agent.md | 53 -- .dispatch/rules/contracts-are-h.md | 17 - .dispatch/rules/one-owner.md | 8 - .dispatch/rules/zero-warnings.md | 11 - .gitignore | 44 +- .opencode/agent/backend-engineer.md | 81 ++ .opencode/agent/gameplay-ruby.md | 84 ++ .opencode/agent/reviewer.md | 40 + .rubocop.yml | 361 ++++++++ .rules/ideas/wasm-web-port.md | 60 -- .rules/plan/phase1.md | 40 - .rules/plan/phase2.md | 34 - .rules/plan/phase3.md | 44 - .rules/plan/plan.md | 138 --- AGENTS.md | 265 ++++-- BUILDING.md | 182 ++++ CLAUDE.md | 1 + GLOSSARY.md | 151 ++-- Makefile | 127 --- ORCHESTRATOR.md | 407 --------- README.md | 262 +++--- SHADER_PIPELINE_RESEARCH.md | 722 +++++++++++++++ Steepfile | 30 + bin/bootstrap.sh | 102 +++ bin/build | 21 - bin/build-web | 85 -- bin/clean | 15 - bin/lint | 132 +++ bin/screenshot | 245 ++++++ bin/serve | 26 - build.zig | 164 ++++ build_config.rb | 65 ++ build_web.sh | 101 +++ docs/AI_REFERENCE.md | 1075 +++++++++++++++++++++++ docs/API_SPEC.md | 382 ++++++++ docs/API_SPEC_FLECS.md | 218 +++++ docs/API_SPEC_JOLT.md | 296 +++++++ docs/API_SPEC_RMLUI.md | 290 +++++++ docs/BUILD_SYSTEM.md | 271 ++++++ docs/DEPLOY_CLOUDFLARE.md | 63 ++ game/ballpit_demo.rb | 296 +++++++ game/console_demo.rb | 54 ++ game/fx_demo.rb | 192 ++++ game/main.rb | 46 + game/physics_demo.rb | 117 +++ game/physics_playground.rb | 529 +++++++++++ game/ragdoll_demo.rb | 258 ++++++ game/touch_demo.rb | 61 ++ game/ui/LatoLatin-Bold.ttf | Bin 0 -> 146156 bytes game/ui/LatoLatin-Regular.ttf | Bin 0 -> 148540 bytes game/ui/MononokiNerdFontMono-Regular.ttf | Bin 0 -> 2324132 bytes game/ui/console.rcss | 70 ++ game/ui/console.rml | 13 + game/ui/fx_game.rcss | 31 + game/ui/fx_game.rml | 13 + game/ui/fx_overlay.rcss | 83 ++ game/ui/fx_overlay.rml | 26 + game/ui/hud.rcss | 42 + game/ui/hud.rml | 17 + mrbgems/flecs/mrbgem.rake | 14 + mrbgems/flecs/mrblib/flecs.rb | 126 +++ mrbgems/flecs/mrblib/hot.rb | 89 ++ mrbgems/flecs/src/flecs_bindings.c | 433 +++++++++ mrbgems/jolt/mrbgem.rake | 13 + mrbgems/jolt/mrblib/jolt.rb | 363 ++++++++ mrbgems/jolt/src/jolt_bindings.c | 1030 ++++++++++++++++++++++ mrbgems/raylib/mrbgem.rake | 47 + mrbgems/raylib/mrblib/bridge.rb | 332 +++++++ mrbgems/raylib/mrblib/fx.rb | 569 ++++++++++++ mrbgems/raylib/mrblib/html.rb | 77 ++ mrbgems/raylib/mrblib/jamstack_json.rb | 65 ++ mrbgems/raylib/mrblib/live.rb | 193 ++++ mrbgems/raylib/mrblib/log.rb | 125 +++ mrbgems/raylib/mrblib/raylib.rb | 173 ++++ mrbgems/raylib/mrblib/smaa.rb | 419 +++++++++ mrbgems/raylib/mrblib/touch_controls.rb | 141 +++ mrbgems/raylib/src/raylib_bindings.c | 126 +++ mrbgems/raylib/tools/gen_ai_reference.rb | 429 +++++++++ mrbgems/raylib/tools/gen_raylib.rb | 402 +++++++++ mrbgems/raylib/tools/gen_rbs.rb | 189 ++++ mrbgems/raylib/tools/gen_smaa_tex.rb | 204 +++++ mrbgems/raylib/tools/smaa_canonical.glsl | 254 ++++++ mrbgems/rmlui/mrbgem.rake | 16 + mrbgems/rmlui/mrblib/console.rb | 203 +++++ mrbgems/rmlui/mrblib/rmlui.rb | 244 ++++++ mrbgems/rmlui/src/rml_bindings.cpp | 882 +++++++++++++++++++ notes/restructure-plan.md | 344 -------- opencode.json | 42 + opencode.lsp-steep.disabled.json | 13 + package.json | 13 + patches/README.md | 47 + patches/raylib-6.0-web-cursorhidden.patch | 19 + rebuild.sh | 20 + roadmap.md | 575 ++++++++++++ sig/flecs.rbs | 191 ++++ sig/jamstack.rbs | 19 + sig/jolt.rbs | 352 ++++++++ sig/raylib.rbs | 1353 +++++++++++++++++++++++++++++ sig/rmlui.rbs | 110 +++ src/config.c | 167 ---- src/config.h | 15 - src/layout_editor.c | 279 ------ src/layout_editor.h | 5 - src/main.c | 370 ++++---- src/player.c | 115 --- src/player.h | 30 - src/study.c | 196 ----- src/study.h | 45 - src/types.h | 77 -- src/ui.c | 469 ---------- src/ui.h | 67 -- study-player.cfg | 16 - tasks.md | 44 - tools/SCREENSHOT.md | 163 ++++ tools/agent-bridge/server.js | 170 ++++ tools/check-types.sh | 27 + tools/gen_compile_commands.rb | 58 ++ tools/screenshot_mode.rb | 70 ++ tools/web_screenshot.js | 95 ++ web/_headers | 5 + web/agent-bridge.js | 61 ++ web/shell.html | 373 +++++--- 158 files changed, 20541 insertions(+), 3580 deletions(-) create mode 100644 .agents/knowledge/agent-bridge.md create mode 100644 .agents/knowledge/build-system.md create mode 100644 .agents/knowledge/console.md create mode 100644 .agents/knowledge/environment.md create mode 100644 .agents/knowledge/flecs-binding.md create mode 100644 .agents/knowledge/flecs-observability.md create mode 100644 .agents/knowledge/fx-pipeline.md create mode 100644 .agents/knowledge/hot-reload.md create mode 100644 .agents/knowledge/jolt-binding.md create mode 100644 .agents/knowledge/linting.md create mode 100644 .agents/knowledge/live-mount.md create mode 100644 .agents/knowledge/logging.md create mode 100644 .agents/knowledge/raylib-binding.md create mode 100644 .agents/knowledge/rmlui-binding.md create mode 100644 .agents/knowledge/ruby-lsp.md create mode 100644 .agents/knowledge/steep.md create mode 100644 .agents/knowledge/testing.md create mode 100644 .agents/knowledge/web-target.md create mode 100644 .agents/rules/dont-edit-generated.md create mode 100644 .agents/rules/link-order.md create mode 100644 .agents/rules/lld-no-gcc-lto.md create mode 100644 .agents/rules/main-thread-eval.md create mode 100644 .agents/rules/mruby-rebuild.md create mode 100644 .agents/rules/raylib-platform-objs.md create mode 100644 .agents/rules/wsl-toolchain.md create mode 100644 .agents/settings.json create mode 100644 .agents/skills/add-binding-fn/SKILL.md create mode 100644 .agents/skills/add-flecs-system/SKILL.md create mode 100644 .agents/skills/add-knowledge/SKILL.md create mode 100644 .agents/skills/build-and-verify/SKILL.md create mode 100644 .agents/skills/new-demo/SKILL.md create mode 100644 .agents/skills/ruby-to-native/SKILL.md create mode 100644 .clang-format create mode 100644 .clangd create mode 120000 .claude delete mode 100644 .dispatch/build-agent.md delete mode 100644 .dispatch/package-agent.md delete mode 100644 .dispatch/rules/contracts-are-h.md delete mode 100644 .dispatch/rules/one-owner.md delete mode 100644 .dispatch/rules/zero-warnings.md create mode 100644 .opencode/agent/backend-engineer.md create mode 100644 .opencode/agent/gameplay-ruby.md create mode 100644 .opencode/agent/reviewer.md create mode 100644 .rubocop.yml delete mode 100644 .rules/ideas/wasm-web-port.md delete mode 100644 .rules/plan/phase1.md delete mode 100644 .rules/plan/phase2.md delete mode 100644 .rules/plan/phase3.md delete mode 100644 .rules/plan/plan.md create mode 100644 BUILDING.md create mode 120000 CLAUDE.md delete mode 100644 Makefile delete mode 100644 ORCHESTRATOR.md create mode 100644 SHADER_PIPELINE_RESEARCH.md create mode 100644 Steepfile create mode 100755 bin/bootstrap.sh delete mode 100755 bin/build delete mode 100755 bin/build-web delete mode 100755 bin/clean create mode 100755 bin/lint create mode 100755 bin/screenshot delete mode 100755 bin/serve create mode 100644 build.zig create mode 100644 build_config.rb create mode 100755 build_web.sh create mode 100644 docs/AI_REFERENCE.md create mode 100644 docs/API_SPEC.md create mode 100644 docs/API_SPEC_FLECS.md create mode 100644 docs/API_SPEC_JOLT.md create mode 100644 docs/API_SPEC_RMLUI.md create mode 100644 docs/BUILD_SYSTEM.md create mode 100644 docs/DEPLOY_CLOUDFLARE.md create mode 100644 game/ballpit_demo.rb create mode 100644 game/console_demo.rb create mode 100644 game/fx_demo.rb create mode 100644 game/main.rb create mode 100644 game/physics_demo.rb create mode 100644 game/physics_playground.rb create mode 100644 game/ragdoll_demo.rb create mode 100644 game/touch_demo.rb create mode 100644 game/ui/LatoLatin-Bold.ttf create mode 100644 game/ui/LatoLatin-Regular.ttf create mode 100644 game/ui/MononokiNerdFontMono-Regular.ttf create mode 100644 game/ui/console.rcss create mode 100644 game/ui/console.rml create mode 100644 game/ui/fx_game.rcss create mode 100644 game/ui/fx_game.rml create mode 100644 game/ui/fx_overlay.rcss create mode 100644 game/ui/fx_overlay.rml create mode 100644 game/ui/hud.rcss create mode 100644 game/ui/hud.rml create mode 100644 mrbgems/flecs/mrbgem.rake create mode 100644 mrbgems/flecs/mrblib/flecs.rb create mode 100644 mrbgems/flecs/mrblib/hot.rb create mode 100644 mrbgems/flecs/src/flecs_bindings.c create mode 100644 mrbgems/jolt/mrbgem.rake create mode 100644 mrbgems/jolt/mrblib/jolt.rb create mode 100644 mrbgems/jolt/src/jolt_bindings.c create mode 100644 mrbgems/raylib/mrbgem.rake create mode 100644 mrbgems/raylib/mrblib/bridge.rb create mode 100644 mrbgems/raylib/mrblib/fx.rb create mode 100644 mrbgems/raylib/mrblib/html.rb create mode 100644 mrbgems/raylib/mrblib/jamstack_json.rb create mode 100644 mrbgems/raylib/mrblib/live.rb create mode 100644 mrbgems/raylib/mrblib/log.rb create mode 100644 mrbgems/raylib/mrblib/raylib.rb create mode 100644 mrbgems/raylib/mrblib/smaa.rb create mode 100644 mrbgems/raylib/mrblib/touch_controls.rb create mode 100644 mrbgems/raylib/src/raylib_bindings.c create mode 100644 mrbgems/raylib/tools/gen_ai_reference.rb create mode 100644 mrbgems/raylib/tools/gen_raylib.rb create mode 100644 mrbgems/raylib/tools/gen_rbs.rb create mode 100644 mrbgems/raylib/tools/gen_smaa_tex.rb create mode 100644 mrbgems/raylib/tools/smaa_canonical.glsl create mode 100644 mrbgems/rmlui/mrbgem.rake create mode 100644 mrbgems/rmlui/mrblib/console.rb create mode 100644 mrbgems/rmlui/mrblib/rmlui.rb create mode 100644 mrbgems/rmlui/src/rml_bindings.cpp delete mode 100644 notes/restructure-plan.md create mode 100644 opencode.json create mode 100644 opencode.lsp-steep.disabled.json create mode 100644 package.json create mode 100644 patches/README.md create mode 100644 patches/raylib-6.0-web-cursorhidden.patch create mode 100755 rebuild.sh create mode 100644 roadmap.md create mode 100644 sig/flecs.rbs create mode 100644 sig/jamstack.rbs create mode 100644 sig/jolt.rbs create mode 100644 sig/raylib.rbs create mode 100644 sig/rmlui.rbs delete mode 100644 src/config.c delete mode 100644 src/config.h delete mode 100644 src/layout_editor.c delete mode 100644 src/layout_editor.h delete mode 100644 src/player.c delete mode 100644 src/player.h delete mode 100644 src/study.c delete mode 100644 src/study.h delete mode 100644 src/types.h delete mode 100644 src/ui.c delete mode 100644 src/ui.h delete mode 100644 study-player.cfg delete mode 100644 tasks.md create mode 100644 tools/SCREENSHOT.md create mode 100644 tools/agent-bridge/server.js create mode 100755 tools/check-types.sh create mode 100644 tools/gen_compile_commands.rb create mode 100644 tools/screenshot_mode.rb create mode 100644 tools/web_screenshot.js create mode 100644 web/_headers create mode 100644 web/agent-bridge.js diff --git a/.agents/knowledge/agent-bridge.md b/.agents/knowledge/agent-bridge.md new file mode 100644 index 0000000..3791b3c --- /dev/null +++ b/.agents/knowledge/agent-bridge.md @@ -0,0 +1,73 @@ +# Tribal knowledge: agent bridge (`Jamstack::Bridge`) — eval-in-the-live-game + +## At a glance +- **What:** run Ruby in the **running** game (desktop now; web in R4), on the main + thread, via a frame-polled command queue. The substrate for hot-reload (R3), + logging (R2), `.live`/WS (R4), and the in-game console (R6). +- **Key files:** `src/main.c` (C: `js_cap_begin`/`js_cap_end` stdout capture, + `js_getenv`, `jamstack_bridge_init`); `mrbgems/raylib/mrblib/bridge.rb` (the + Bridge: TCP poll, queue, `eval_code`, JSON envelope); seam + `mrbgems/raylib/mrblib/raylib.rb` `while_window_open` (drains once per frame + before the game block). +- **Gate / transport:** env `JAMSTACK_BRIDGE=1` (localhost only, P7); + `JAMSTACK_BRIDGE_PORT` overrides the default **7621**. Desktop = TCP. +- **Cross-refs:** roadmap R1/R4, principles P6 (single-thread/frame-drain) & P7 + (dev-only); GLOSSARY "command queue", "the bridge". + +## How it works +`mrb_state` from `main.c` stays alive for the whole run. `while_window_open` calls +`Jamstack::Bridge.start` (if enabled) then `Bridge.drain` each frame **before** the +game block — so every command runs on the main thread (P6), never on a socket +callback. `drain` accepts new clients (`accept_nonblock`), reads pending data +(`recv_nonblock`), parses complete lines, and runs up to `MAX_PER_FRAME` (16) evals +to bound frame time. + +## Wire protocol (R1; R4 unifies to JSON both ways via the relay) +- **request:** one line `" "`. `` is escaped: `\` → `\\`, newline → + `\n`, tab → `\t`. `` is a space-free token. (Asymmetric on purpose — there is + no JSON *parser* in mruby, so the request side avoids needing one.) +- **response:** one line of JSON + `{"id","ok","result","stdout","error","backtrace"}`. `result` is the value's + `inspect`; `error` is `"Class: message"`; `backtrace` is an array or null. + +## Scar tissue (mruby ≠ CRuby; verified on this binary) +- **stdout capture must be done in C.** mruby's `puts`/`print`/`p` write straight + to C **fd 1** — NOT through `$stdout`. There is no `StringIO`, no `__printstr__`, + and overriding `STDOUT#write` does nothing. The ONLY working capture is the C + fd-redirect bracket: `js_cap_begin` (`dup`+`dup2` fd 1 → `tmpfile()`), + `js_cap_end` (restore, read back). `eval_code` wraps the eval in it. +- **No `JSON`, no `require`, no `ENV`** in the default gembox. → JSON response is + hand-encoded (byte-wise; bytes ≥0x20 pass through, so UTF-8 survives); the gate is + read via C `Jamstack.getenv` (not `ENV`). +- **`recv_nonblock`:** returns a String with data, raises `Errno::EAGAIN` when + empty, returns `""` on peer close (that's how we reap dead clients). +- **`-std=c11` hides POSIX** `fileno`/`dup`/`dup2`. `main.c` must + `#define _POSIX_C_SOURCE 200809L` before the includes. +- **eval context:** `eval(code)` runs with `self == Jamstack::Bridge`. Constants + (`Rl`, `Flecs`, …) and globals resolve fine; top-level **local** variables of the + game's `main.rb` are NOT visible. R3/R6 may want a dedicated top-level binding. +- **`window_should_close?`** is a sugar alias, not a generated binding — see + `raylib-binding.md`. The desktop seam needs it. +- **Reach live objects via `ObjectSpace`, don't rebuild for a global.** eval is *live* + but a top-level **local** (e.g. `player = world.character(...)`) isn't reachable from + an eval binding. Find the live object instead — `ObjectSpace.each_object(Jolt::Character){|c| ...}` + (`mruby-objectspace` is compiled in) — rather than editing the game to expose a global + and rebuilding. Game state already in globals (`$jolt`, `$flecs`, …) is directly + evaluable. + +## Run / verify +```sh +JAMSTACK_BRIDGE=1 ./zig-out/bin/game game/some_loop.rb # game must call while_window_open +# then, from another process, connect TCP 127.0.0.1:7621 and send " \n" +``` +A throwaway Python client lives in the R1 verification notes; sending `Rl.get_fps`, +a multi-line script, and a deliberate `raise` returns value / captured stdout / a +clean error+backtrace while the loop keeps running. + +## Not yet (later phases) +- ~~**Web** path (`EMSCRIPTEN_KEEPALIVE jamstack_eval` + `Module.ccall`): R4.~~ — **DONE.** + The web relay (`tools/agent-bridge/server.js`) bridges the browser game to the + host filesystem via a file-based poll loop. Same `bin/*` scripts (eval, snapshot, + query, tail-log, hot-reload) work on both targets. See `.agents/knowledge/live-mount.md`. +- ~~**`.live/` files, WS relay, line-JSON both ways:** R4.~~ — **DONE.** +- **Result routing to the log pipeline / NDJSON:** R2 — done (Log ring buffer). diff --git a/.agents/knowledge/build-system.md b/.agents/knowledge/build-system.md new file mode 100644 index 0000000..e1863d4 --- /dev/null +++ b/.agents/knowledge/build-system.md @@ -0,0 +1,54 @@ +# Tribal knowledge: build system + +## At a glance +- **Key files:** `build.zig` (desktop orchestrator), `build_config.rb` (mruby + the + 4 mrbgems), `rebuild.sh` (incremental), `build_web.sh` (web), each `mrbgems/*/mrbgem.rake`. +- **Commands:** `zig build` / `zig build run`; `./rebuild.sh`; + `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`; run `./zig-out/bin/game path.rb`. +- **Cross-refs:** rules `link-order`, `mruby-rebuild`, `lld-no-gcc-lto`, + `raylib-platform-objs`, `wsl-toolchain`; design `docs/BUILD_SYSTEM.md`; human steps + `BUILDING.md`; skill `build-and-verify`. + +## Topology +`Ruby game code → mruby VM → Rl::/Rml::/Flecs:: bindings → raylib/RmlUi/flecs`, +linked by **Zig** (desktop) or **emscripten** (web). `src/main.c` boots mruby and +runs a script (`argv[1]`, default `game/main.rb`). + +Four native libs are built separately and linked at the end: +- `build/desktop/libraylib.a` — raylib via `make` (guarded; built once). +- `vendor/rmlui/build-static/librmlui.a` — RmlUi via `cmake`, target `rmlui_core` ONLY. +- `build/desktop/libflecs.a` — flecs amalgamation, one `cc` object. +- `vendor/mruby/build/host/lib/libmruby.a` — mruby + our 3 mrbgems via `rake` + (rebuilt every `zig build`; rake is incremental). + +## Commands +- Desktop: `zig build` / `zig build run` (orchestrates all of the above). +- Incremental binding work: `./rebuild.sh` (rake libmruby + zig link). +- Web: `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh` → `build/web/game.{html,js,wasm,data}`. +- Always export the cleaned PATH first (see rules/wsl-toolchain.md). + +## Why zig links GNU libstdc++ directly (build.zig) +zig 0.16's `linkSystemLibrary("stdc++")` hijacks to its own LLVM **libc++**, which +lacks the GNU libstdc++ ABI symbols RmlUi needs. So we `addObjectFile` +`/usr/lib/libstdc++.so` and `/usr/lib/libgcc_s.so.1` (the latter for +`_Unwind_Resume`: mruby is built with `MRB_USE_CXX_EXCEPTION` because a C++ +mrbgem, rmlui, is present). + +## mruby config (build_config.rb) +- `conf.disable_presym` — lets us add new binding method names without + regenerating the presym table (avoids stale-symbol errors on rebuild). +- One `MRuby::Build` (host) + one `MRuby::CrossBuild('web')` guarded by + `JAMSTACK_WEB`. Both list the same 3 gems (raylib, rmlui, flecs). +- `JAMSTACK_ROOT` is exported by the build scripts so mrbgem.rake/build_config + resolve paths. + +## Per-target raylib objects +raylib shares `.o` in `vendor/raylib/src` across platforms → see +rules/raylib-platform-objs.md. Output dirs are `build/desktop` and `build/web`. + +## Gotcha index (when something breaks) +- "multiple definition" at link → stale mruby objects: `rm -rf vendor/mruby/build`. +- "undefined reference" to ecs_/Rml/raylib syms → link order or a missing native lib. +- rake tries to build `mruby`/`mirb` and fails → you ran plain `rake`; target the + `libmruby.a` path instead. +- raylib symbols are wasm/desktop-mismatched → forgot `make clean` between targets. diff --git a/.agents/knowledge/console.md b/.agents/knowledge/console.md new file mode 100644 index 0000000..357b6d1 --- /dev/null +++ b/.agents/knowledge/console.md @@ -0,0 +1,65 @@ +# Tribal knowledge: in-game REPL console (`Jamstack::Console`) + +## At a glance +- **What:** a user-facing RmlUi panel that evaluates Ruby in the game script's + binding — full access to local variables and game state. Toggle with backslash + (`\`, `KEY_BACKSLASH` = 92; JIS-friendly). Enter to eval, Up/Down for history, + Tab for completion. +- **Key files:** `mrbgems/rmlui/mrblib/console.rb` (the class, compiled into the + gem); `game/ui/console.rml` + `console.rcss` (markup/styles); + `mrbgems/rmlui/src/rml_bindings.cpp` (C++: `rml_el_select`, + `rml_el_set_selection_range` for caret control); `game/console_demo.rb` + (usage example). +- **API:** `Jamstack::Console.new(ctx, binding: binding)` — pass the game + script's binding so eval sees local variables. `console.update` (before + `ctx.process_input`) handles the toggle key. `console.open?` lets the game + skip gameplay input when the console is visible. +- **Cross-refs:** `rmlui-binding.md` (keyboard input, element API), `agent-bridge.md` + (the eval queue — the console is a UI front-end for the same eval), + roadmap R6. + +## How it works + +### Toggle key +`console.update` is called **before** `ctx.process_input` each frame. It checks +`Rl.key_pressed?(KEY_BACKSLASH)`. When the toggle fires, it drains both +`GetKeyPressed()` and `GetCharPressed()` queues in Ruby so the backslash isn't +forwarded to RmlUi as text input. Then `show`/`hide` toggles the document. + +### Variable assignment propagation (mruby gotcha) +mruby's `eval` creates a **new local variable scope** — assignments like +`player_color = Rl::RED` do NOT write back to the game loop's closure. The +`eval_line` method detects simple `var = expr` assignments (without Regexp — +mruby has no `Regexp` class) and uses `binding.local_variable_set` which writes +to the binding's env (shared with the closure). Compound assignments (`+=`, etc.) +and non-identifier LHS fall through to plain `eval`. + +**Only variables that existed before the binding was captured can be modified +this way.** New variables created in the console are only visible to subsequent +`eval` calls, not to the game loop's closure. + +### Tab completion +- **No separator** → local vars (`binding.local_variables`) + methods + (`binding.receiver.public_methods`) + `Object.constants` +- **After `.`** → `receiver.public_methods` +- **After `::`** → `receiver.constants` +- Single match: completes inline. Multiple: completes common prefix + lists + candidates in scrollback. `KI_TAB` `stop_propagation` prevents RmlUi's default + focus navigation. + +### Caret control +After tab completion and history navigation, `@input.caret_end` moves the caret +to the end of the text. This calls `SetSelectionRange(len, len)` via the C++ +binding (`dynamic_cast`). + +### Key identifiers (RmlUi) +The keydown event carries `key_identifier` as an int parameter. The Ruby +`Event#[]` accessor reads it as a float (via `GetParameter`), so call +`.to_i` to compare: `KI_RETURN` = 72, `KI_TAB` = 70, `KI_UP` = 91, `KI_DOWN` = +93. See `vendor/rmlui/Include/RmlUi/Core/Input.h` for the full enum. + +### HTML escaping in scrollback +`inner_rml=` parses the string as RML, so `<`, `>`, `&` must be escaped. The +`escape_html` helper also converts `\n` → `
` for multi-line output. A +sentinel `
` + `scroll_into_view(false)` provides +auto-scroll. diff --git a/.agents/knowledge/environment.md b/.agents/knowledge/environment.md new file mode 100644 index 0000000..f130a40 --- /dev/null +++ b/.agents/knowledge/environment.md @@ -0,0 +1,24 @@ +# Tribal knowledge: environment (WSL / WSLg) + +This repo is developed under **WSL** (Linux on Windows). Two hard-won facts: + +## PATH: Windows shadows Linux +A Windows Ruby/rake on `/mnt/c/...` appears first in PATH and shadows the Linux +toolchain, producing baffling failures. Strip it before every build / ruby / +rake / generator invocation (see rules/wsl-toolchain.md). The build scripts +(`rebuild.sh`, `build_web.sh`) already do this and also prepend the user gem bin +(`$(ruby -e 'puts Gem.user_dir')/bin`) so the Linux `rake` gem resolves. + +## Graphics: use Wayland, not X11 (WSLg) +WSLg's X11/GLX path **segfaults inside Mesa** (`dri2GalliumConfigQueryb`). raylib +is therefore built with the **Wayland** GLFW backend: +`make ... GLFW_LINUX_ENABLE_WAYLAND=TRUE GLFW_LINUX_ENABLE_X11=FALSE`, and +`build.zig` links the wayland-* libs (`wayland-client/cursor/egl`, `xkbcommon`) +plus `EGL`. For a normal X11 desktop, swap those back to `X11` in both places. + +Expect harmless Mesa/EGL/zink warnings on stderr in this environment +(`libEGL warning: ... zink ...`, `Wayland: The platform does not provide the +window position`); they are not errors. + +## Toolchain versions (pinned) +raylib 5.5, mruby 3.3.0, RmlUi 6.1, flecs v4.1.1, Zig 0.16.0, emcc 6.0.0 (emsdk). diff --git a/.agents/knowledge/flecs-binding.md b/.agents/knowledge/flecs-binding.md new file mode 100644 index 0000000..2a3582b --- /dev/null +++ b/.agents/knowledge/flecs-binding.md @@ -0,0 +1,74 @@ +# Tribal knowledge: flecs / ECS bindings (`Flecs::`) + +Hand-written C (`mrbgems/flecs/src/flecs_bindings.c`) + Ruby sugar +(`mrblib/flecs.rb`), modeled on flecs' Lua binding. Full spec: +`docs/API_SPEC_FLECS.md`. + +## At a glance +- **Key files:** `mrbgems/flecs/src/flecs_bindings.c`; sugar `mrblib/flecs.rb`; + `mrbgem.rake`; amalgamation `vendor/flecs/distr/flecs.{c,h}` → `libflecs.a`. +- **Ruby API:** `Flecs::World` (`entity`/`struct`/`tag`/`query`/`system`/`progress`), + `Entity`/`Component`/`Query`. Phases `ON_LOAD → PRE_UPDATE → ON_UPDATE → ON_START`. + Spec `docs/API_SPEC_FLECS.md`. +- **Cross-refs:** skill `add-flecs-system`; the substrate for the agentic runtime + (roadmap Part B). No game uses it yet; REST is compiled in but not startable from + Ruby (R5). + +## Component model (the key design) +Components are real C structs declared at runtime via the **meta/reflection +addon**: `world.struct("Position", "{float x; float y;}")`. Values are +(de)serialized between the C memory and Ruby **Hashes**. We use flecs' **public** +meta API only — `ecs_meta_cursor` for writes, `EcsStruct`/`EcsPrimitive` +reflection (direct offset reads) for reads — deliberately NOT the semi-private +serialized-ops the Lua binding uses (forward-compat). + +## flecs v4 API specifics (bit me during binding) +- `ecs_ensure_id(world, e, id, size)` — v4 added the trailing `size` arg (pass the + component's `EcsComponent.size`). +- `ecs_entity_desc_t.add` is a **0-terminated `ecs_id_t*` array**, not inline. +- `ecs_ctx_free_t` is `void(*)(void*)` (no world arg); `callback_ctx`/ + `callback_ctx_free` are the per-callback slots on systems and `ecs_iter_t`. +- Field indices are **0-based** in v4 (`ecs_field_w_size(it, size, 0)`). + +## Web (wasm) — the one real gotcha +The full amalgamation (incl. meta) is emscripten-aware and links cleanly. BUT +flecs' init/meta uses more stack than emscripten's 64 KB default → a too-small +stack shows up as a wasm **"memory access out of bounds"** trap. The web link +uses `-sSTACK_SIZE=4MB`. Don't try `-DFLECS_NO_HTTP/REST` — REST depends on HTTP +and it `#error`s; just build the whole amalgamation. + +## API shape / limits +`Flecs::World` (entity/struct/tag/lookup/query/system/progress), `Flecs::Entity` +(set/get/add/remove/has?/...), `Flecs::Component`, `Flecs::Query` (Enumerable). +Systems/queries yield `|entity_id, *component_hashes|` (raw Integer id for speed; +`world.entity_for(id)` to wrap); mutations to the hashes are written back. +Single-threaded `progress` only (also the only mode valid on wasm). Not exposed: +relationships/pairs, prefabs, observers, query operators, multithreading. + +## Entity delete leak (FIXED) +**Symptom (was):** after summon→delete→summon cycles, `world.query(comp)` yielded +entity ids whose `world.entity_for(id).alive?` was false, and `entity_for(id).delete` +did NOT reduce the query count. First-ever (generation-0) entities deleted fine — +only recycled ones leaked. + +**Root cause:** flecs recycles entity ids with a bumped **generation** in the high 32 +bits of the 64-bit `ecs_entity_t` (`ECS_ENTITY_MASK` is `0xFFFFFFFF`; generation lives +above it). On the **web/wasm32** build, `mrbconf.h` auto-detected `MRB_32BIT` (because +`SIZE_MAX` is 32-bit on wasm32) → `MRB_INT32` → `mrb_int` is `int32_t`, which truncated +the generation bits when ids round-tripped through `fl_yield_iter`/`fl_w_delete`/ +`fl_w_alive` via `mrb_get_args("i",...)` / `mrb_int_value`. `ecs_delete`/`ecs_is_alive` +then saw a stale generation (0) and no-op'd. Desktop (x86-64) auto-detected `MRB_INT64` +so it was unaffected, but the same binding code would break on any 32-bit target. + +**Fix:** force `-DMRB_INT64` in both mruby build configs (`build_config.rb`) AND on +`src/main.c` compilation (`build.zig` flags + `build_web.sh` emcc line) so `mrb_int` is +`int64_t` on **every** target — the full 64-bit entity id (generation + index) now +round-trips through the mruby boundary unchanged. This is an ABI change: `rm -rf +vendor/mruby/build` is required when first applying it (stale 32-bit objects cause +"multiple definition" / signature-mismatch link errors). Verified with a +summon/delete/summon recycle test on desktop + web (node headless). + +## Build wiring +Amalgamation `vendor/flecs/distr/flecs.{c,h}` → `libflecs.a` (`cc` desktop / +`emcc` web). The gem only needs `vendor/flecs/distr` on its include path; the lib +links at the final step. diff --git a/.agents/knowledge/flecs-observability.md b/.agents/knowledge/flecs-observability.md new file mode 100644 index 0000000..c67efc7 --- /dev/null +++ b/.agents/knowledge/flecs-observability.md @@ -0,0 +1,80 @@ +# Tribal knowledge: flecs observability (REST / Explorer / stats) — R5 + +> Status: **desktop IMPLEMENTED + verified** (R5). Web (R5a) follows in the web +> phase. `world.enable_rest` / `world.enable_stats` in `flecs_bindings.c` + `flecs.rb`. + +## At a glance +- **What:** surface flecs's built-in remote API so a human/agent gets a full ECS + view — entities, components, queries, per-system timing — with **zero UI code on + our side** (the hosted Flecs Explorer connects remotely). +- **Key files:** `mrbgems/flecs/src/flecs_bindings.c` (add `_enable_rest`/ + `_enable_stats`); sugar `mrbgems/flecs/mrblib/flecs.rb` (`enable_rest`, + `enable_stats`). Amalgamation already built **with** `FLECS_REST`/HTTP/STATS. +- **Cross-refs:** `flecs-binding.md`, `agent-bridge.md` (web ships REST JSON over the + bridge), roadmap R5/R5a; principle P5 (observe). + +## Desktop mechanism (the whole thing) +```c +FlecsRestImport(world); // register REST module +ecs_set(world, EcsWorld, EcsRest, {.port = port}); // -> starts HTTP server +FlecsStatsImport(world); // per-system timing/world stats +``` +The REST HTTP server runs on `:27750` (flecs manages its own thread); it's serviced +during `world.progress`, which the game already calls each frame. Then point the +**hosted Explorer** at it remotely — no local UI: +`https://www.flecs.dev/explorer/?host=localhost:27750`. +Quick check without a browser: `curl http://localhost:27750/world` (or +`/entity/`) returns JSON. + +## Ruby API (planned) +```ruby +world.enable_rest(27750) # default ECS_REST_DEFAULT_PORT +world.enable_stats # FLECS_STATS for per-system timing +``` +Gate behind dev/JAMSTACK_BRIDGE in game code (it opens a local port — P7). + +## Web (R5a) — flecs already did most of it +`ecs_http` can't bind a listening socket in the browser, BUT flecs ships, under +`ECS_TARGET_EM`, an `EMSCRIPTEN_KEEPALIVE char* flecs_explorer_request(method, req, +body)` that runs `ecs_http_server_request` against a socketless +`flecs_wasm_rest_server` (a non-static global). Plan for the web phase: +- init it once: `extern ecs_http_server_t *flecs_wasm_rest_server; + flecs_wasm_rest_server = ecs_rest_server_init(world, NULL);` +- `flecs_explorer_request` is already exported — call it from JS / the bridge and + ship the JSON over the agent channel; feed `.live/state.json`. +This is the R5a spike's happy path: the request handler is reachable **without** the +socket server thread. + +## Acceptance +Desktop: `curl :27750/world` returns JSON and the hosted Explorer shows the live +world; per-system timing visible with `enable_stats`. (Web: covered in R5a.) + +## Scar tissue (verified desktop) +- **Works as a 3-liner:** `FlecsRestImport(w)` + `ecs_set(w, EcsWorld, EcsRest, + {.port})` + `FlecsStatsImport(w)`. No `ECS_IMPORT` macro needed — call the import + functions directly (avoids a local-var declaration). `ecs_id(EcsRest)` is a fixed + compile-time id, but you still must import the module so its system/observers run. +- **Verified endpoints** (game looping `progress`, `curl`/urllib on :27750): + `/world` → 200; `/entity/player?values=true` → + `{"components":{"Position":{"x":1,"y":2},"Velocity":{...}}}`; + `/query?expr=Position&values=true` → all matches with values. These are exactly + what the hosted Explorer drives (`flecs.dev/explorer?host=localhost:27750`). +- **Threading:** the REST HTTP server runs on flecs's own thread (accepts + connections), but requests are *processed* during `world.progress` — so the game + must keep ticking for responses (it does, each frame). +- **Gotcha (not REST):** the **base** `world.system(with: [...])` expects component + **ids/objects** — it does `.to_i`, so string names silently become `0` and the + system matches nothing. Use `Flecs::Hot.define_system` (resolves names via lookup) + or pass `Component` objects. +- **In-process REST requests (`rest_request`):** `world.rest_request("GET", "/world")` + calls `ecs_http_server_request` on the `fl_rest_server` handle (set by + `enable_rest`). No socket needed — works on desktop AND web identically. This is + what `bin/snapshot` and `bin/query` use to dump ECS state JSON through the `.live/` + mount. On desktop, `enable_rest` creates TWO server objects: one with a port (HTTP + listener for the hosted Explorer) and one socketless (for in-process requests). + Both share the same world. On web, only the socketless one exists (shared with + `flecs_wasm_rest_server` for the JS C export). Key endpoints: `/world` (full world + state incl. entities + components), `/query?expr=&values=true` + (matching entities with component values), `/entity/::?values=true` (single + entity — note the `::` scope separator; may not find entities created without a + scope path — use `/query` as the reliable fallback). diff --git a/.agents/knowledge/fx-pipeline.md b/.agents/knowledge/fx-pipeline.md new file mode 100644 index 0000000..40c4d16 --- /dev/null +++ b/.agents/knowledge/fx-pipeline.md @@ -0,0 +1,213 @@ +# FX pipeline — `Jamstack::FX` + +> **At a glance** — a layered, two-stage, runtime-toggleable post-processing +> shader pipeline. Pure Ruby over the bound raylib shader API; **no C**. Each +> effect chooses whether it touches only the game world (+ in-world UI) or the +> whole frame (game + overlay HUD). Toggling never recompiles a shader. +> +> **Key files** +> - `mrbgems/raylib/mrblib/fx.rb` — `Jamstack::FX` module: `header`, `Pass`, +> `Pipeline`, `Frame`, and the shader-body constants (`SCANLINES`, `VIGNETTE`, +> `FXAA`, …). +> - `game/fx_demo.rb` — the reference scene: builds the pipeline, wires the +> overlay-HUD checkboxes/slider to the passes. +> - `game/ui/fx_overlay.rml` / `.rcss` — the overlay HUD (toggles + slider). +> +> **API/spec pointer** — `docs/API_SPEC_RAYLIB.md` (shader fns: +> `load_shader_from_memory`, `get_shader_location`, `set_shader_value`, +> `set_texture_filter`, `load_render_texture`, `texture_mode`, `shader_mode`). +> +> **Cross-refs** — the GLSL/header/macro-shim detail lives in +> `raylib-binding.md` ("Custom shaders"); the WebGL2/ES3 web specifics in +> `web-target.md` ("FX shader pipeline on web"); rendering RmlUi into the FBO +> (context dims, the two contexts) in `rmlui-binding.md` ("Rendering RmlUi into +> a RenderTexture"). This doc holds only the FX-architecture scar tissue that +> lives nowhere else. + +## The two-stage model (why it exists) + +Three render layers, two shader chains, one screen blit: + +``` + GAME LAYER 3D world + in-world RmlUi -> RenderTexture G + | + GAME SHADERS ping-pong chain on G (world only; NOT the overlay HUD) + | + OVERLAY LAYER processed-game quad + overlay RmlUi HUD -> RenderTexture C + | + TOP SHADERS ping-pong chain on C (whole frame; over EVERYTHING incl HUD) + | + screen +``` + +**Why two stages:** so a gameplay effect can transform the world without wrecking +the overlay HUD. Warp/scanlines/aberration would smear HUD text into illegibility +→ they belong in the **game** stage (the HUD is composited *after*, so it stays +crisp). Vignette/grayscale/FXAA-of-everything belong in the **top** stage +(intentionally affect the HUD too). The overlay HUD is drawn into `C` *in the same +`texture_mode(C)` block* as the composited game quad, so top shaders filter both. + +`Pipeline#w/h` defines the render-target size; `Frame#game_layer` / `#overlay_layer` +are the per-frame builder methods (see the usage block at the top of `fx.rb`). + +## Runtime toggle — zero recompilation + +`Pass` compiles its shader **once at construction** and caches uniform locations. +`Pass#enabled = false` just removes it from the per-frame chain (`apply_chain` +selects `enabled && !suppress`). Toggling from the eval bridge or the in-game +console is free — never reload/rebuild. An optional `uniform float intensity` +(0..1) lets an effect *fade* rather than snap (bound only if the body declares it). + +## `extra_uniforms` — runtime knobs without recompilation + +A `Pass` takes `extra_uniforms: {name => value}` (floats). Locations are cached at +construction; values are set per frame in `apply`. Use this for multi-knob shaders +(FXAA's `subpix` / `edgeThreshold` / `edgeThresholdMin`): set the values at runtime +from a slider → the running pass picks them up, **no shader recompilation**. Names +are fixed at construction; values are mutable. + +## `Pass#suppress` — in-chain but skipped this frame + +A pass can sit in a shader list yet be skipped per-frame via `suppress` (true). +`apply_chain` selects `enabled && !suppress`. Distinct from `enabled`: +- `enabled` = user toggle ("this FX is on/off"). +- `suppress` = programmatic skip ("redundant this frame because another pass + already covers it"). + +**The use case — two-layer FXAA, no double-blur:** `fx_demo.rb` runs FXAA in *both* +stages (`fxaa_game` world-only, `fxaa_ui` whole-frame). When `fxaa_ui` is on it +already AA's the whole frame *including the world*; letting `fxaa_game` also run +would double-AA the world (extra blur). So `fxaa_game.suppress = fxaa_ui.enabled`: +both-on ⇒ the game pass is suppressed ⇒ exactly the whole-frame behaviour, no +double-blur. (world-on/ui-off ⇒ only the world is AA'd, HUD stays crisp — the mode +that justifies the split.) If you see a pass "not running despite enabled=true," +check `suppress` first — it is *not* a user-facing toggle. + +## FXAA requires BILINEAR on the render textures + +`Pipeline` ctor sets `TEXTURE_FILTER_BILINEAR` on **every** render texture. This is +not cosmetic: **FXAA needs sub-pixel bilinear sampling to blend edges.** With the +default point sampling, the FXAA edge-search samples identical texel values → +detects no contrast gradient → applies no AA (looks like a no-op). It is set once on +all RTs (`@g + @c`) because the top stage may run FXAA on any of them; harmless to +the non-FXAA passes. **Any new AA pass that samples neighbours** (SMAA, SSAA +downscale, CAS) inherits this correctly — do not "fix" the bilinear back to point. + +## FXAA `GREEN_AS_LUMA` + the missing-luma caveat (open option B) + +The FXAA body uses **green as luma** (`FxaaLuma(rgba) = rgba.g`). Reason: our RGBA8 +render targets carry **uniform alpha = 1**, so luma-from-alpha would detect no +edges at all. Green is a reasonable perceptual-luma proxy. + +**Caveat:** pure red/blue edges with **no green component** get little/no AA (their +luma delta is small). The escape hatch is **option B — a luma-pack pre-pass**: a +cheap pass that computes perceptual luma `(0.299R + 0.587G + 0.114B)` and writes it +into the alpha channel, after which FXAA reads alpha-as-luma and catches every +edge. Cost: one extra full-screen pass. **Not implemented** — green-as-luma is +good enough for the current demo; revisit if red/blue aliasing shows. (This was +previously referenced from `fx.rb` as "roadmap option B" — but the roadmap is a +*harness* roadmap with no FX section, so that pointer was dangling; the option +lives here now.) + +## Which shader goes in which stage + +- **Game stage** (world + in-world UI; leaves overlay HUD crisp): `WARP`, + `ABERRATION`/`ABERRATION_CMY`, `SCANLINES`, `CRT` (all-in-one), `fxaa_game`. + Rule of thumb: *anything that would smear text or warp geometry*. +- **Top stage** (whole frame incl HUD): `VIGNETTE`, `GRAYSCALE`, `COLORGRADE`, + `fxaa_ui`. Rule of thumb: *gentle, whole-frame tonal/AA effects*. + +## Wiring a HUD control to a pass (the demo pattern) + +The overlay HUD drives passes via RmlUi. The non-obvious bits (RmlUi-binding has +the context/FBO detail; this is the control-wiring detail): + +- **Checkbox:** `` → + `el = doc.element("chk-x"); el.on(:change) { pass.enabled = el["checked"] }`. + Read `el["checked"]` (truthy/falsy string). +- **Range slider:** `` + → `el.on(:change) { q = el["value"].to_f; pass.extra_uniforms[:knob] = q }`. + **The value is a String — `.to_f` it.** The `:change` event fires on both + checkbox toggles and slider drags. Setting an `extra_uniforms` value mutates the + live pass; no rebuild. +- **Shared control over multiple passes:** just call both in the handler + (`fxaa_quality(fxaa_game, q); fxaa_quality(fxaa_ui, q)`). Whichever actually runs + (per `enabled`/`suppress`) uses the latest value. + +## SMAA 1x — DONE (alongside FXAA, for A/B testing) + +`Jamstack::FX::Smaa` (mrblib/smaa.rb) is a **composite** 3-pass effect (edge +detect → blend weights → neighbourhood blend) that ducks as a `Pass` for +`Pipeline#apply_chain` (responds to `enabled`/`suppress`/`extra_uniforms` + +`apply(src, dst, t, scene)`), running its own internal ping-pong over two +intermediate render textures (`edge_rt`, `blend_rt`). The shaders are the +canonical iryoku/smaa GLSL, preprocessed from `SMAA.hlsl` with `cpp +-DSMAA_GLSL_3 -DSMAA_PRESET_HIGH -DSMAA_DISABLE_DIAG_DETECTION …` (diag compiled +out) — saved at `mrbgems/raylib/tools/smaa_canonical.glsl`. The `fx_demo.rb` +wires two instances (game + ui) + a threshold slider, exactly like FXAA. + +Scar tissue (all non-obvious, hard-won): + +- **Multi-texture binding needs no rlgl.** SMAA passes 2 & 3 sample several + textures in one shader (edges+area+search; image+blend). raylib's + `DrawTexturePro` only auto-binds the *drawn* texture to unit 0 (`texture0`); + the extra samplers are bound via **`Rl.set_shader_value_texture`** — raylib's + `rlSetUniformSampler` registers the id + sets the sampler uniform, and the + actual GL bind is **deferred to the batch flush** (the `DrawTexturePro` draw). + So call `set_shader_value_texture` for each extra sampler inside `shader_mode`, + then `draw_texture_pro` the main texture. (No `rlActiveTexture`/`rlEnableTexture` + is exposed in this binding — and none is needed.) + +- **Lookup textures live in C, not Ruby.** The `areaTex` (160×560 RGBA8, + 358 KB) + `searchTex` (64×16, 4 KB) are baked canonical bytes (`src/ + smaa_tex_data.c`, generated by `tools/gen_smaa_tex.rb` from the real + iryoku/smaa `Scripts/*.py` — ortho region + search R channel byte-exact) and + exposed to Ruby as Strings via `Rl.smaa_area_bytes` / `Rl.smaa_search_bytes` + (`mrb_str_new` at runtime), then uploaded with `Rl.update_texture`. They are + NOT Ruby literals because mruby (a) caps each string literal at + `MRB_PARSER_TOKBUF_MAX` = **65534** bytes and (b) a ~358 KB string constant + **hangs the irep loader at boot**. Runtime generation was also ruled out + (~12 s in mruby for the closed-form area math; even offset-0-only). A C const + array has none of these limits; `mrb_str_new` at runtime has none either. + `areaTex` filter = **BILINEAR** (the shader interpolates the area LUT), + `searchTex` = **POINT** (it's an index — must not interpolate). + +- **SMAA 1x samples only offset-row 0.** `subsampleIndices = 0` for 1x (the SMAA + comment says so), so the areaTex `texcoord.y += SUBTEX_SIZE*offset` stays in the + first 1/7 block. We bake the full canonical area (all 7 rows) anyway; the diag + half is zeroed (`SMAA_DISABLE_DIAG_DETECTION` — the shader never reads it). + +- **The SMAA PS functions take `sampler2D` args + `float4 offset[3]`.** SMAA's + own vertex shader computes `offset[3]`; raylib's default VS can't, so each + pass's `main()` **inlines the VS offset math** from `fragTexCoord` + + `rtMetrics` (`uniform vec4 rtMetrics; #define SMAA_RT_METRICS rtMetrics`). + `SMAA_MAX_SEARCH_STEPS` = 16 (PRESET_HIGH) is used only in the blend pass's + `offset[2]`. The threshold is a `uniform float smaaThreshold` (swapped into + the preprocessed LumaEdgeDetectionPS via `.sub`, only in the edge pass) so the + slider tunes it without recompiling. + +- **Intermediate RTs are BILINEAR-filtered (NOT POINT).** The chain's color RTs + are BILINEAR (Pipeline sets that for FXAA); SMAA samples the color input softly + as a result — acceptable, slightly soft. **`edge_rt`/`blend_rt` SMAA owns MUST + be BILINEAR too** — this is critical and was previously wrong (POINT). SMAA's + `SMAAArea` reads the crossing edges `e1`/`e2` via a sub-texel sample of `edge_rt`; + POINT makes them binary `{0,1}` → `SMAAArea` samples the areaTex's zero corner + regions → **zero weights → no AA**. BILINEAR blends the sub-texel sample → + `e1/e2 ∈ {0,0.25,0.75,1.0}` → reads the real area data → AA works (matches + three.js's LINEAR edgesRT/weightsRT). See `smaa-root-cause.md` for the full + diagnosis. `areaTex` stays BILINEAR; `searchTex` stays POINT (it's an index). + +## Open options (the AA upgrade path) + +- **Option B — luma-pack pre-pass** (above): fixes FXAA's red/blue blind spot, ~1 pass. +- **TAA** — temporal: needs motion vectors + a history RT + a resolve with + neighbourhood clamping. Best on moving cameras; most work. Pair with a *sharpen* + pass (CAS), not with another spatial AA. +- **SSAA** — render the game pass at 2× and downsample (bilinear, already enabled): + trivial to add as one Pass; catches everything but costs fill-rate + memory. +- **SMAA diagonal detection** — currently disabled (`SMAA_DISABLE_DIAG_DETECTION`); + enabling needs the diagonal `areaTex`/`searchTex` (brute-force generated) + + porting `SMAACalculateDiagWeights`/`SMAASearchDiag*` back in. Ortho-only is the + LOW/MEDIUM-preset config. + +See `roadmap.md` only for harness milestones — FX feature options live here. diff --git a/.agents/knowledge/hot-reload.md b/.agents/knowledge/hot-reload.md new file mode 100644 index 0000000..b389327 --- /dev/null +++ b/.agents/knowledge/hot-reload.md @@ -0,0 +1,84 @@ +# Tribal knowledge: hot-reloadable Flecs systems (`Flecs::Hot`) — R3 + +> Status: **IMPLEMENTED + verified** (R3) — `mrbgems/flecs/mrblib/hot.rb`. Verified +> desktop, both inline and live over the bridge. + +## At a glance +- **Goal (the core ask):** edit a system's logic — and add new systems — on the + **running** game without resetting; entities and component data survive, only + behavior swaps. Drives the agentic dev loop (with R1 eval + R2 logs). +- **Key files:** `mrbgems/flecs/mrblib/hot.rb` (the registry; **new**), built on the + existing `Flecs::World#system` (`flecs_bindings.c` `fl_w_system`/`fl_system_cb`). +- **Cross-refs:** `flecs-binding.md` (system dispatch), `agent-bridge.md` (reload + driven live over the bridge), roadmap R3; principle P5 (mutate via reload). + +## The design decision: pure-Ruby system registry (NO C change) +The flecs binding stores the Ruby block in the system's `callback_ctx` +(`fl_cb_t.blk`) and `fl_system_cb` yields the iterator to it. The block is baked in +at `ecs_system_init` time. **So instead of re-registering on reload** (which would +lose the system id and re-match tables), we register **once** with a *stable +dispatcher* block that looks up the *current* proc by name, and on reload we just +**replace the proc** in a Ruby Hash. The C system never changes — no trampoline, +no binding edit. + +``` +define_system("Move", with:, phase:, &blk) + first call : @systems["Move"] = {id, with, phase, proc: blk} + world.system("Move", with:, phase:) { |e,*c| @systems["Move"][:proc].call(e,*c) } + reload : @systems["Move"][:proc] = blk # same id, same tables, same state +``` + +## Reload semantics +- **Same name + same `with:` + same `phase:`** → **swap the proc** (the hot path): + identical system id, matched tables, and all entity/component data untouched. +- **`with:`/`phase:` changed** → delete the old system entity (`world._delete`) and + re-register (new id). **Entity state still survives** — systems don't own entity + data, so even this path keeps positions/components. +- `define_system` is **idempotent**, so re-running a `game/systems/*.rb` file + top-to-bottom just swaps procs. + +## API (planned) +```ruby +Flecs::Hot.world = world # once, after creating the world +Flecs::Hot.define_system("Move", with: ["Position","Velocity"]) { |e,p,v| ... } +Flecs::Hot.reload_file("game/systems/move.rb") # re-eval -> swaps procs +Flecs::Hot.reload_string(code) # for the bridge +Flecs::Hot.id_for("Move"); Flecs::Hot.systems # introspection +``` +`with:` accepts component ids OR string names (resolved via `world.lookup`, so reload +doesn't need to re-create components). + +## What survives vs what needs a reboot +| Change | Hot-reload? | Why | +|---|---|---| +| Edit a system body / add / remove a system | ✅ swap proc | state untouched | +| Add a **new** component (new meta struct) + entities | ✅ additive | new id, no layout change | +| Change an existing component's struct **layout** | ⚠️ reboot (or migrate) | existing entities hold old layout | +| Change a system's `with:`/`phase:` | ✅ delete+recreate that system | cheap; entity data survives | +| Change C/C++ binding, generator, build flags | ❌ reboot | native ABI / relink | +| raylib/native resource re-init (window, GPU) | ❌ reboot | native lifetime | + +## Acceptance +With a running world: change a movement system's speed AND add a brand-new system, +while entities keep their positions (state continues, not reset); the same works +driven live over the bridge (`Flecs::Hot.reload_string`). + +## Scar tissue (verified desktop — inline + live over the bridge) +- **Same system id across a swap** — `id_for("Move")` stayed `544` before and after a + live reload. The dispatcher block (held in flecs `callback_ctx`, kept alive by + `mrb_gc_register` in `fl_w_system`) never changes; only the registry proc does, so + matched tables and all entity data are untouched. +- **State continues, not reset** — live x went `80 → 6442` after swapping Move to + `+=100/frame` (continued from 80, not 0), and a freshly-added `Tagger` set `y=42` + on the same live entities; the loop survived. +- **Swap vs re-register** keys on `name + with + phase`. Changing `with:`/`phase:` + deletes the old system entity and registers a new one (new id) — entity data still + survives because systems don't own it. +- **Driving reload:** over the bridge, call `define_system` directly (it's just Ruby + on the main thread); `reload_string`/`reload_file` are for re-evaling a whole + systems unit. Component **names** in `with:` resolve via `world.lookup` at each + (re)register, so reload needn't re-create components. +- **Timing:** a `define_system` sent over the bridge swaps the proc during the + frame's drain, which runs *before* `world.progress` — so it takes effect from the + next progress onward (same frame). +- **No C change** to the flecs binding was required — the whole point of the design. diff --git a/.agents/knowledge/jolt-binding.md b/.agents/knowledge/jolt-binding.md new file mode 100644 index 0000000..964757b --- /dev/null +++ b/.agents/knowledge/jolt-binding.md @@ -0,0 +1,193 @@ +# Tribal knowledge: Jolt Physics bindings (`Jolt::`) + +3D physics. Hand-written C (`mrbgems/jolt/src/jolt_bindings.c`) + Ruby sugar +(`mrblib/jolt.rb`) over the **joltc** C API (Amer Koleci's C wrapper around +JoltPhysics). Full spec: `docs/API_SPEC_JOLT.md`. + +## At a glance +- **Key files:** `mrbgems/jolt/src/jolt_bindings.c`; sugar `mrblib/jolt.rb`; + `mrbgem.rake`; `vendor/joltc` + `vendor/JoltPhysics` → merged `libjoltphysics.a`. +- **Ruby API:** shapes `Jolt.box/sphere/capsule/...`; `Jolt::World` (`body`, `step`, + `raycast`, constraints, `character`, `ragdoll`), `Body`/`Character`/`Ragdoll`/`Constraint`. + Spec `docs/API_SPEC_JOLT.md`. +- **Cross-refs:** rule `lld-no-gcc-lto`; demos `game/ballpit_demo.rb`, + `ragdoll_demo.rb`, `physics_playground.rb`. (This doc is long for a reason — read it.) + +## Vendoring + build (two repos, CMake) +- `vendor/joltc` (the C API) and `vendor/JoltPhysics` (v5.5.0, side-by-side so + joltc finds it locally — no CMake FetchContent network pull). +- CMake builds `libjoltc.a` + `libJolt.a`, which we **merge into one archive** + `build/{desktop,web}/libjoltphysics.a` (extract both with `ar x`, re-`ar rcs`). + The merge matters: `libmruby -> libjoltc -> libJolt` is a 3-archive chain and a + single merged archive (like libflecs.a) resolves cleanly. +- Build flags: `-DINTERPROCEDURAL_OPTIMIZATION=OFF` (REQUIRED — see below), + `-DJPH_SAMPLES=OFF -DJPH_TESTS=OFF`, profiler + debug-renderer OFF (smaller). + +## THE big gotcha: GCC LTO vs lld (cost ~hours) +Jolt enables `INTERPROCEDURAL_OPTIMIZATION` (GCC `-flto`) by default. zig's lld +**cannot link GCC GIMPLE-LTO objects** → every `JPH_*` symbol shows "undefined" +at link, even though `nm` says they're defined. Tell: `readelf -s joltc.cpp.o` +shows ~5 symbols and `.gnu.lto_*` sections. Fix: `-DINTERPROCEDURAL_OPTIMIZATION=OFF`. +(Captured as `.agents/rules/lld-no-gcc-lto.md`.) GNU `ld` links GCC-LTO fine, which +is why a manual `gcc` link works but `zig build` doesn't — a useful bisection. + +## Single-threaded (wasm-safe) +The job system is created with `JobSystemThreadPoolConfig{ ..., numThreads=0 }` +so jobs run inline on the calling thread. There is no single-threaded job-system +symbol in joltc; `numThreads=0` is the way. This is the only mode valid on the +wasm build (no pthreads) and behaves identically on desktop. Don't expose +multithreaded systems. + +## Web +Same merge, built with `emcmake`/`emar`. Adds ~1.14 MB to `game.wasm` +(uncompressed; ~300-400 KB gzipped). Uses the existing `-sSTACK_SIZE=4MB`. +LLVM/emcc LTO would be fine here, but we keep IPO OFF for consistency. + +## API design notes +- Fixed 2-layer setup: 0=STATIC (non-moving), 1=MOVING. A body's layer is derived + from its motion type (static -> STATIC, else MOVING). No custom layers exposed. + The ObjectLayerPairFilter MUST enable STATIC<->MOVING **and MOVING<->MOVING** — + forgetting MOVING<->MOVING means dynamic bodies fall through each other (they + still hit static floors). STATIC<->STATIC stays disabled. +- C primitives are numeric; `mrblib/jolt.rb` does Array/`Rl::Vector3` coercion + (in) and returns `Rl::Vector3`/`Vector4` when raylib is present (lazily checked + via `const_defined?`, since mruby's `defined?` doesn't parse in endless-method + form). Positions/rotations cross the boundary as plain float arrays. +- `mrb_get_args` format must match exactly: `_add_body` is `"offfffffiffb"` + (shape, 7 floats, **int motion**, 2 floats, bool) — an `i` in the wrong slot + silently corrupts the motion type (body won't simulate). +- Determinism is available but OFF: add `-DCROSS_PLATFORM_DETERMINISTIC=ON` to the + CMake configs when you need cross-platform reproducibility (perf cost). + +## Contact events +`world.contacts` returns collisions that *began* this step (OnContactAdded only — +not Persisted, so the list stays small). Implementation: flecs-style — a GLOBAL +`JPH_ContactListener_Procs` (set once via `JPH_ContactListener_SetProcs`), plus a +per-world `JPH_ContactListener` whose `userData` is the `jolt_world_t*`, so the +proc routes each event to that world's buffer. Safe to write the buffer from the +proc because Update runs single-threaded (numThreads=0). Buffer is reset at the +start of `_step` and capped at 4096/step. Bridge contacts to game objects via +`body.user_data` (a uint64 — store a flecs entity id). + +## Character controller (Jolt::Character) +Wraps `JPH_CharacterVirtual` (kinematic player capsule). `update` calls +`JPH_CharacterVirtual_ExtendedUpdate` (NOT basic `_Update`) — basic Update only +slides; ExtendedUpdate adds stair-stepping + stick-to-floor. There is no +`ExtendedUpdateSettings_Init`, so we fill Jolt's documented defaults by hand +(walkStairsStepUp.y = 0.4 = max step height). Init copies base defaults but we +still set `base.up = (0,1,0)` and `base.supportingVolume = {{0,1,0}, -1e10}` +(accept all contacts geometrically; the slope angle then classifies ground vs +steep). The character holds the `JPH_PhysicsSystem*` for Update; the Ruby wrapper +sets `@world` so the world can't be GC'd out from under it. Gravity is NOT +auto-applied — the game adds it to `velocity` each frame. `mruby/variable.h` is +required for `mrb_iv_set`. + +**Pushing dynamic props:** ExtendedUpdate already pushes the dynamic bodies the +character walks into, BUT Jolt's default `maxStrength` (100 N) is far too weak to +move default-density props — bodies have density 1000 kg/m³, so a 0.5 m sphere is +~520 kg and barely budges. To make the player shove balls/crates around, raise +`char.max_strength = ` (exposed via `_set_max_strength` → +`JPH_CharacterVirtual_SetMaxStrength`; getter `max_strength`, plus `mass=` → +`SetMass`). A kinematic body always pushes the character regardless (penetration +recovery), and the character never pushes a kinematic body back — that asymmetry +(player shoves balls, an orbiting kinematic sphere shoves the player) is exactly +how `game/ballpit_demo.rb` is built. There is still no per-body density/mass +override binding, so tune ball *radius* if you need lighter props without code. + +## Constraints, sensors, queries (high-value batch) +- **Constraints** (`world.weld/ball_joint/distance_joint/hinge/slider/cone`): + joltc's `*Constraint_Create` take `JPH_Body*`, not body ids. Get a stable + pointer with `jolt_body_for(w, id)` — `JPH_PhysicsSystem_GetBodyLockInterfaceNoLock` + + `LockRead`/`UnlockRead`; the body pointer is stable single-threaded (bodies + don't move in memory), so lock→get→unlock→use is safe. Settings use + `JPH_ConstraintSpace_WorldSpace` with `point1==point2==anchor`; hinge/slider need + a `normalAxis` perpendicular to the axis (`jolt_perp`). The Ruby `Jolt::Constraint` + keeps its `@world` alive and `RemoveConstraint`+`Destroy` on GC or `.remove`. +- **Sensors** = `sensor: true` body. The SAME contact listener reports sensor + overlaps, so `world.contacts` (enter) + `world.contacts_ended` (leave) give + trigger-volume enter/leave with no binding-specific work. +- **Contact-removed**: `OnContactRemoved` gives a `JPH_SubShapeIDPair` (has + `Body1ID`/`Body2ID`), buffered into `ended` (reset each step like `contacts`). +- **Raycast normal**: `JPH_Body_GetWorldSpaceSurfaceNormal(body, subShapeID, &pos, + &n)` — needs `jolt_body_for` again. `RayCastResult.subShapeID2` is the sub-shape. +- **Point overlap**: `JPH_NarrowPhaseQuery_CollidePoint` with a float-returning + collector callback (`return 1e30` = keep collecting), collecting body ids. +- **Body props at creation**: mass via `SetOverrideMassProperties(CalculateInertia)` + + `MassPropertiesOverride.mass`; damping via `SetLinear/AngularDamping`; CCD via + `SetMotionQuality(LinearCast)`; sensor via `SetIsSensor`. `_add_body` is now a + 17-arg `"offfffffiffbfffbb"` — keep the format string in lockstep with the call. + +## Ragdolls + character platform-riding +- **Ragdoll** (`world.ragdoll(parts:)`): built in ONE C call from a packed array + (`_ragdoll(packed, user_data)`) — mixed-type per-part arrays (string/shape/floats) + are parsed with `mrb_ary_ref` + `mrb_as_*`. Needs `#include ` + for `mrb_str_to_cstr` (else implicit-decl error → int→ptr). Build order that + matters: Skeleton (AddJoint2 parent-first) → CalculateParentJointIndices → + RagdollSettings SetSkeleton → ResizeParts → per-part Set* + SetPartToParent + (SwingTwist, WorldSpace, position1==position2==joint) → Stabilize → + DisableParentChildCollisions(NULL,0) → CalculateBodyIndexToConstraintIndex → + CreateRagdoll → AddToPhysicsSystem. **Parts must be listed parents-before-children** + or the skeleton is mis-ordered. Part object layer = motion→L_STATIC/L_MOVING. +- **Refcount lifetime**: Skeleton/RagdollSettings/Ragdoll are all RefCounted; + joltc `*_Destroy` = Release (decrement), not free. CreateRagdoll makes the + ragdoll hold refs to settings (which holds the skeleton), so we `Destroy` our + build-time skeleton+settings refs IMMEDIATELY after CreateRagdoll — the ragdoll + keeps them alive; ragdoll free does RemoveFromPhysicsSystem + Destroy. +- Ragdoll part bodies are normal bodies: `JPH_Ragdoll_GetBodyID(i)` works with all + the existing `world._position/_rotation/...` methods. Capsule local axis = Y; + draw endpoints via `Rl.vector3_rotate_by_quaternion([0,hh,0], rotation)`. +- **Platform riding**: `JPH_CharacterBase_GetGroundVelocity` + `GetGroundBodyId` + (note joltc spelling `...BodyId`, lowercase d). CharacterVirtual does NOT add + ground velocity itself — `ch.ride(dt)` adds `ground_velocity` to the character's + velocity before ExtendedUpdate so kinematic platforms carry the player. Ground + body id is invalid when airborne; Ruby `ground_body` returns nil unless `supported?`. + +## Constraints/ragdolls MUST be retained (GC footgun — fixed in the binding) +- A `Jolt::Constraint`/`Jolt::Ragdoll` finalizer calls `RemoveConstraint`/ + `RemoveFromPhysicsSystem` — so if the Ruby handle is GC'd, the joint silently + **detaches mid-simulation** (bodies fall apart nondeterministically, whenever a + GC happens to run). This bit the playground demo: joints created fire-and-forget + (`world.hinge(...)` with no assignment) broke after a few seconds. +- Fix lives in `mrblib/jolt.rb`: `World` keeps `@joints` / `@ragdolls` arrays and + every `world.weld/ball_joint/distance_joint/hinge/slider/cone` + `world.ragdoll` + pushes its result there (`_retain_joint`). `#remove` calls `_forget_joint`/ + `_forget_ragdoll` to drop the ref. So game code never needs to hold joint handles. +- If you add a NEW constraint-returning method, wrap its result in `_retain_joint` + or it will inherit the original bug. Verify with a LONG run (600+ steps) — the + bug only shows once a GC cycle fires, not in the first frames. + +## Character#ride only inherits NON-dynamic ground (else it launches you) +- `ride(dt)` adds `ground_velocity` to the character before ExtendedUpdate so + kinematic platforms carry it. But a DYNAMIC ground body (a ball you stand on, a + constrained/​swinging pendulum) reports its *reaction to your weight* + its own + motion as `ground_velocity`; inheriting that is a positive-feedback launch + (repro: standing on a swinging bob shot the character to y≈92, peak vy≈61). +- Fix: `ride` gates on `ground_body.motion_type != Jolt::DYNAMIC` — only + static/kinematic platforms are inherited; dynamic ground is just stood on. + `ground_velocity` itself still returns the raw value (don't "fix" it there). + +## Finalizer ORDER at shutdown — the "world freed first" crash (fixed) +- `@world`/`@joints` retention fixes *runtime* GC ordering, but **`mrb_close` + frees every object in arbitrary order, ignoring references**. So at process exit + a `Ragdoll`/`Constraint` finalizer can run AFTER its `World`'s + `JPH_PhysicsSystem` is already destroyed. Calling `RemoveFromPhysicsSystem`/ + `RemoveConstraint` then → segfault; calling `JPH_*_Destroy` then → "double free + or corruption" (the system already freed those bodies/constraints). +- Symptom: a heisenbug — adding `puts`/`$stdout.flush` changed allocation and hid + it; only a `gdb` backtrace (`...->jolt_ragdoll_free -> RemoveFromPhysicsSystem` + under `mrb_close -> free_heap`) pinned it. It only shows with enough live + ragdolls/constraints that the world happens to be freed first. +- Fix: a shared refcounted **liveness token** (`jolt_token_t {alive, refs}`, libc + malloc/free, independent of the mruby heap). `jolt_world_t` owns one; every + ragdoll/constraint `jolt_token_acquire`s it. `jolt_world_free` sets `alive = 0` + before destroying the system. Each dependent's free skips **all** Jolt calls + when `!alive` (leak is fine — the process is exiting). Last owner frees the token. +- RULE: any future object that calls into the `JPH_PhysicsSystem` from its + finalizer (vehicles, soft bodies, …) MUST take a token and gate on `alive`. + Test by building a scene with many such objects and letting it exit (EXIT=0). + +## Known limitations / leaks (first cut) +Shapes (`Jolt.box/sphere/...`) are Jolt ref-counted; we don't Release them on GC +(bounded leak; shapes are usually long-lived). The world's layer-filter tables +also aren't freed per world. Not yet exposed: constraints/joints, characters, +mesh/convex-hull shapes, contact callbacks, custom collision layers. diff --git a/.agents/knowledge/linting.md b/.agents/knowledge/linting.md new file mode 100644 index 0000000..8325c1f --- /dev/null +++ b/.agents/knowledge/linting.md @@ -0,0 +1,115 @@ +# Tribal knowledge: linting (RuboCop + clang-format + clang-tidy) + +Style linting is **manual** (`bin/lint`), not automatic. This is deliberate: +syntax errors are caught at **build time** (mruby-compiler gem — `rake` fails on +parse errors); type errors by **Steep** per-edit (RBS type checker, see +`opencode.json`); linting is a separate, end-of-implementation-pass step that +catches style inconsistencies and potential bugs without flooding the editor. + +## At a glance +- **Run:** `bin/lint` (report) / `bin/lint --fix` (safe autocorrect) / `bin/lint --ruby` / `bin/lint --c` +- **Ruby config:** `.rubocop.yml` — `DisabledByDefault: true` + Layout + Lint departments + safe Style cops +- **C/C++ config:** `.clang-format` (Allman, 2-space, 100-col, return-type-on-own-line) +- **C/C++ analysis:** clang-tidy with `bugprone-*,cert-*,clang-analyzer-*` (report only, never auto-fixed) +- **Excludes:** `vendor/**`, `mrbgems/raylib/src/raylib_gen.c` (generated), `build/**`, `zig-out/**`, `.live/**` +- **Cross-refs:** `.agents/knowledge/ruby-lsp.md` (LSP setup), `opencode.json` (LSP config) + +## Why RuboCop works for mruby (the non-obvious part) + +The existing `opencode.json` comment says ruby-lsp's `linters: []` because they'd +"false-positive on mruby." This is true for ruby-lsp's *live* diagnostics, but +**RuboCop itself works fine** — and the upstream **mruby repo uses it** (via +pre-commit, `.github/linters/.rubocop.yml`). + +The key facts (from RuboCop's compat docs + mruby's own config): +1. RuboCop **runs ON MRI** (we have 3.4.8) but **analyzes code targeting any + Ruby version** — `TargetRubyVersion` controls what *syntax* the parser + accepts, not the runtime. +2. The false-positive risk is **narrow**: only cops that suggest MRI-only stdlib + methods (e.g. "use `Array#sum`") would break on mruby. **Layout/Lint cops + are pure syntax** — no runtime assumptions, safe for mruby. +3. mruby upstream uses `DisabledByDefault: true` + only 3 layout cops. We enable + more (whole Layout + Lint departments + safe Style subset) but the principle + is identical: **opt-in, never the full default set**. + +### TargetRubyVersion: 3.4 (critical — without it, 70+ false syntax errors) + +Without `TargetRubyVersion: 3.4`, RuboCop defaults to the **Ruby 2.7 parser**, +which doesn't understand **endless method definitions** (`def foo = expr`, +Ruby 3.0+ syntax). This repo uses them extensively (raylib.rb, jolt.rb, rmlui.rb, +bridge.rb). The 2.7 parser emits `Lint/Syntax: unexpected token tEQL` and +spurious "class definition in method body" / "dynamic constant assignment" +errors — ~70 false positives that disappear with `TargetRubyVersion: 3.4`. + +mruby 3.3's `MRUBY_RUBY_VERSION` is "3.3", but mruby upstream targets 3.4 in +their own RuboCop config (matches the host MRI running RuboCop). We do the same. + +### The "too many lines" cops (explicitly disabled) + +`Metrics/MethodLength`, `Metrics/BlockLength`, `Metrics/ModuleLength`, +`Metrics/ClassLength` complain about methods/blocks/modules/classes being too +long. Game code + mrbgem sugar intentionally has long methods and large files. +These are explicitly `Enabled: false` (DisabledByDefault already leaves them off, +but explicit means they stay off even if that flag is flipped). + +Note: `Metrics/FileLength` does **not exist** in RuboCop 1.88 (it was removed) — +don't add it (RuboCop errors on unrecognized cops). + +### Lint/RescueException (intentionally disabled) + +The game loop and bridge intentionally `rescue Exception` (not `StandardError`) +to keep the game running / log exceptions instead of crashing. This is a +deliberate pattern across `mrbgems/*/mrblib/` (raylib.rb `while_window_open`, +live.rb, hot.rb). `Lint/RescueException` is `Enabled: false`. + +## Why clang-format needs a config (and what it codifies) + +There was **no `.clang-format`** before. The hand-written C/C++ follows a +dominant style I reverse-engineered (Allman braces, 2-space indent, ~100-col, +return type on its own line for top-level defs). With this config, +`raylib_bindings.c` produces **zero diff** — it's the most consistent file. +Other files have **real inconsistencies** (single-line function defs, semicolon- +chained statements, comment alignment) that `clang-format -i` will normalize. + +Key settings and *why*: +- `SortIncludes: Never` — the code groups includes with comments + (`#include /* memcpy... */`); LLVM's default sort would destroy + these. `ReflowComments: false` for the same reason. +- `AlwaysBreakAfterReturnType: TopLevelDefinitions` + + `AlwaysBreakAfterDefinitionReturnType: TopLevel` — return type on its own + line. ⚠️ Note the enum values differ between the two options: + `AlwaysBreakAfterReturnType` takes `TopLevelDefinitions`; the *Definition* + variant takes `TopLevel` (NOT `TopLevelDefinitions` — clang-format errors). +- `BreakBeforeBraces: Allman` — `{` on its own line. +- `PointerAlignment: Right` — `mrb_state *mrb` (not `mrb_state* mrb`). + +## Why clang-tidy is report-only (and scoped) + +clangd's `--clang-tidy` is **OFF** in `opencode.json` (intentionally — it would +flag macro-heavy vendored code live). The manual `bin/lint` path runs clang-tidy +with `--header-filter='^mrbgems/.*/src/.*|^src/.*'` so only **our** headers' +diagnostics are displayed (vendored mruby/raylib/rmlui/flecs/joltc diagnostics +are suppressed — there are ~106,000 of them). Currently produces **zero findings** +in hand-written code. + +clang-tidy is **never auto-fixed** (`bin/lint --fix` only touches RuboCop + clang- +format). clang-tidy's `-fix` can introduce subtle behavior changes; manual review +only. + +## How the pieces fit together (the three layers) + +| Layer | Tool | When | What it catches | +|-------|------|------|-----------------| +| **Syntax** | mruby-compiler gem | build time (`rake`) | parse errors | +| **Types** | Steep (RBS) | per-edit (LSP) | wrong arg type/arity to typed bindings | +| **Style/best-practice** | RuboCop + clang-format + clang-tidy | manual (`bin/lint`) | style inconsistencies, lint bugs | + +## Adding/removing cops + +- **Ruby:** edit `.rubocop.yml`. With `DisabledByDefault: true`, add + `CopName: Enabled: true` to opt in. Verify with `rubocop --show-cops CopName` + that the cop exists in your version (cop names drift across RuboCop versions — + listing ~300 individual cops is fragile, which is why we use department-level + `Layout: Enabled: true` / `Lint: Enabled: true` instead). +- **C/C++:** edit `.clang-format` (formatting) or the `--checks` list in + `bin/lint` (clang-tidy). Verify clang-format with `clang-format --dump-config`. diff --git a/.agents/knowledge/live-mount.md b/.agents/knowledge/live-mount.md new file mode 100644 index 0000000..c709b5a --- /dev/null +++ b/.agents/knowledge/live-mount.md @@ -0,0 +1,129 @@ +# Tribal knowledge: the `.live/` mount (`Jamstack::Live`) — R4 (desktop slice) + +> Status: **IMPLEMENTED + verified** (R4 desktop) — `mrbgems/raylib/mrblib/live.rb`. +> The Node WS relay + web side are **deferred** (R4b); this slice is the file-based +> mount the game writes itself — no relay, no WS, no extra runtime. + +## At a glance +- **What:** a read-mostly `.live//` surface a file-based agent uses without + speaking a socket: `status.json` (heartbeat), `game-console` (NDJSON log), and a + `.agent/cmd-* → result-*.json` command protocol drained **in-frame** (P6). Plus + `bin/*` helper scripts. This is the elegant desktop path (no FUSE — see the + `.live` mechanism in roadmap R4): the native game has `mruby-io`/`mruby-dir`, so it + writes/polls real files directly. +- **Key files (planned):** `mrbgems/raylib/mrblib/live.rb` (`Jamstack::Live`); seam + `raylib.rb` (`Live.start` + `Live.poll` per frame); reuses `Bridge.eval_code` (R1) + and `Jamstack::JSON`/`Log` (R2). +- **Gate:** same `JAMSTACK_BRIDGE=1`. Token via `JAMSTACK_LIVE` (default `dev`), root + via `JAMSTACK_LIVE_ROOT` (default `.live`) → `.live/dev/`. +- **Cross-refs:** `agent-bridge.md` (R1 eval, shares the queue/drain), `logging.md` + (game-console), roadmap R4; principle P5 (observe vs the one write path), P7 (dev). + +## The protocol (no JSON parser needed) +mruby has **no JSON parser**, so the **command** files carry *raw Ruby*; the id is +the filename. Only the **result** is JSON (written via `Jamstack::JSON`). +``` +.live/dev/ + status.json # heartbeat: connected,target,token,frame,fps,ts (throttled, atomic) + state.json # flecs world snapshot (bin/snapshot writes this; atomic) + game-console # Log NDJSON file sink (R2) + .agent/ + cmd-.rb # AGENT WRITES raw Ruby (atomic: write .tmp then rename) + result-.json # GAME WRITES the {id,ok,result,stdout,error,backtrace} envelope + bin/ # tiny shell wrappers (run via `sh bin/eval` if not +x) + eval tail-log hot-reload snapshot query +``` +**Per-frame drain (`Live.poll`, after `Bridge.drain`):** `Dir.entries(.agent)` → +select `cmd-*` → for each: read code, **delete the cmd file**, `Bridge.eval_code`, +write `result-.json` atomically. `status.json` rewritten throttled (~every 30 +frames). The only agent-writable path is `.agent/cmd-*` (P5). + +## mruby FS constraints (probed) +- **No `Dir.glob`/`Dir[]`** → list with `Dir.entries(dir)` and filter + (`start_with?("cmd-")`). +- **No `File.write`** class method → `File.open(path,"w") { |f| f.write(s) }`. +- Have: `Dir.mkdir`/`entries`/`foreach`, `File.read`/`rename`/`delete`/`unlink`/ + `exist?`/`directory?`/`basename`/`join`. **Atomic write = temp + `File.rename`**. +- No recursive mkdir → walk path components with `Dir.mkdir` (ignore "exists"). +- `.live/` is runtime state → **gitignored**, never committed. + +## Acceptance +`sh .live/dev/bin/eval 'Rl.get_fps'` returns the JSON envelope; `bin/tail-log` +streams `game-console`; `status.json` updates while the game runs; the loop +survives. Works against a running desktop game with no relay. + +## Web relay (W2 / R4b) — IMPLEMENTED + browser-verified +Confirmed in a real browser tab: `sh .live/web/bin/eval 'Rl.get_fps'` → live fps; +`Rl.platform` → `:web`; multiline+`puts` returns `result` **and** captured `stdout` +(the C fd-redirect works under emscripten MEMFS); `raise` returns a backtrace into +`game/physics_playground.rb`; forwarded browser console (incl. the Ruby `Log` NDJSON) +lands in `game-console`. The desktop `bin/eval` drives a browser game unchanged. +`tools/agent-bridge/server.js` + `web/agent-bridge.js`. **Use it:** +```sh +EMSDK_ENV=/path/to/emsdk_env.sh ./build_web.sh # if not already built +node tools/agent-bridge/server.js # serves http://localhost:8080 +# open http://localhost:8080 in a browser (the game runs), then from a shell: +sh .live/web/bin/eval 'Rl.get_fps' # -> JSON envelope from the live tab +sh .live/web/bin/tail-log # stream the browser console +``` +Verified headlessly with a simulated-browser node poller: `bin/eval` round-trips +(`EVAL[...]`), `status.json` + `game-console` populate, `agent-bridge.js` is injected +into `game.html` and served (200). The real `Module.jamstack` leg is browser-verified +(W1 already proved `eval_json` works on wasm in node). + +A **dependency-free Node HTTP relay** (`tools/agent-bridge/server.js`) gives the +browser tab the *same* `.live//` interface as desktop, so `bin/eval` etc. +work identically against a browser game: +- The relay **serves `build/web/`** (same-origin → no CORS, no `ws` dep) and injects + `\n')); + } + // no-store: the wasm/js are rebuilt often during dev; without this the + // browser caches game.wasm aggressively (Emscripten's fetch is cached + // separately from the HTML, so a hard-refresh doesn't bust it) and serves a + // stale build -- making code changes appear to have "no effect". + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }); + res.end(body); + }); +} + +const server = http.createServer((req, res) => { + const p = req.url.replace(/\?.*$/, ''); + if (p === '/jamstack/poll') return handlePoll(res); + if (p === '/jamstack/result') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); const r = (o.result == null) ? '{}' : (typeof o.result === 'string' ? o.result : JSON.stringify(o.result)); + writeAtomic(path.join(AGENT, 'result-' + o.id + '.json'), r); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/jamstack/console') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); if (o.line != null) fs.appendFileSync(path.join(LIVE, 'game-console'), String(o.line) + '\n'); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/jamstack/status') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); writeStatus(Object.assign({ connected: true, target: 'web', token: TOKEN }, o)); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/agent-bridge.js') { + return fs.readFile(path.join(ROOT, 'web', 'agent-bridge.js'), (err, data) => { + if (err) { res.writeHead(404); return res.end(); } + res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.end(data); + }); + } + return serveStatic(res, p); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log('jamstack relay: http://localhost:' + PORT + ' -> ' + WEB); + console.log(' .live mount: ' + LIVE); + console.log(' bin: eval snapshot query tail-log hot-reload'); + console.log(' open the URL in a browser, then: sh ' + path.join(BIN, 'eval') + " 'Rl.get_fps'"); +}); diff --git a/tools/check-types.sh b/tools/check-types.sh new file mode 100755 index 0000000..907616c --- /dev/null +++ b/tools/check-types.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Type-check gate for raylib-jamstack: RBS signature consistency + Steep type check. +# +# rbs validate — sig/*.rbs are internally consistent (catches duplicate +# methods, bad types, broken aliases). HARD FAIL. +# steep check — the Steep project loads cleanly + no :error-level issues. +# +# Under the Steepfile's `D::Ruby.lenient` config, game-code type typos are +# :information — visible in the editor (live LSP) and via +# `steep check --severity-level=information`, but NOT a CI failure here. So this +# gate fails only on real signature/structural breakage and is GREEN on correct +# code. (Editor = live type guidance; this script = sig/structural integrity.) +# +# Self-contained: sets the gem env (rbs + steep live in the user gem dir, like +# opencode.json's ruby-lsp/steep env). Run from the repo root. +set -euo pipefail + +GEM_BIN="${GEM_BIN:-$HOME/.local/share/gem/ruby/3.4.0/bin}" +export PATH="$GEM_BIN:$PATH" +command -v rbs >/dev/null 2>&1 || { echo "rbs not found — install: gem install rbs" >&2; exit 1; } +command -v steep >/dev/null 2>&1 || { echo "steep not found — install: gem install steep" >&2; exit 1; } + +echo "== rbs validate ==" +rbs validate +echo "== steep check ==" +steep check +echo "OK: signatures valid + steep project loads clean." diff --git a/tools/gen_compile_commands.rb b/tools/gen_compile_commands.rb new file mode 100644 index 0000000..391e65b --- /dev/null +++ b/tools/gen_compile_commands.rb @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +require "json" + +# Generate compile_commands.json for clangd. +# +# The raylib-jamstack build is Zig-orchestrated (build.zig) + mruby rake +# (build_config.rb), neither of which emits compile_commands.json. clangd needs +# it (or it falls back to the file's own dir as the compile directory, which +# breaks the relative `-Ivendor/...` include roots — they'd resolve against the +# file's dir instead of the project root). This generator emits one entry per +# C/C++ source with `directory` = the project root, so the relative include +# paths resolve correctly. +# +# Run after a fresh clone / when source files are added: +# ruby tools/gen_compile_commands.rb +# (rebuild.sh regenerates it automatically; see .agents/knowledge/ruby-lsp.md.) +# +# Output (compile_commands.json, gitignored — it holds absolute paths) targets +# the DESKTOP (gcc/clang) build. Web-only headers () are guarded +# by #ifdef __EMSCRIPTEN__, which we do NOT define here, so clangd skips them. + +ROOT = File.expand_path("..", __dir__) +Dir.chdir(ROOT) + +INCLUDES = %w[ + vendor/mruby/include + vendor/raylib/src + vendor/rmlui/Include + vendor/flecs/distr + vendor/joltc/include +].freeze + +SOURCES = Dir.glob(["mrbgems/*/src/*.{c,cpp}", "src/*.{c,cpp}"]). + # Skip the generated raylib bindings only if you don't want to index them; + # we DO index raylib_gen.c so definition jumps into the generated surface work. + sort + +entries = SOURCES.map do |src| + cpp = src.end_with?(".cpp") + args = [ + cpp ? "clang++" : "clang", + cpp ? "-std=c++17" : "-std=c11", + "-DMRB_INT64", # forced by build_config.rb on every target + "-fsyntax-only", + *INCLUDES.flat_map { |i| ["-I", i] }, + src, + ] + { + "directory" => ROOT, + "file" => src, + "arguments" => args, + } +end + +out = File.join(ROOT, "compile_commands.json") +File.write(out, JSON.pretty_generate(entries) + "\n") +puts "wrote #{out} (#{entries.size} entries)" diff --git a/tools/screenshot_mode.rb b/tools/screenshot_mode.rb new file mode 100644 index 0000000..b43dcb3 --- /dev/null +++ b/tools/screenshot_mode.rb @@ -0,0 +1,70 @@ +# tools/screenshot_mode.rb — OPTIONAL in-script screenshot helpers. +# +# The PRIMARY, generic capture path is the env hook in Rl.while_window_open +# (gated by JAMSTACK_SCREENSHOT=) + the bin/screenshot wrapper. That works +# on ANY game script with zero changes — see tools/SCREENSHOT.md. +# +# This file is for the case where you want MANUAL control over WHEN the frame is +# captured: e.g. capture at a specific game-state event (after a physics step +# settles, when a shader parameter hits a value, N frames after input). Require +# it from your game script and call Jamstack::Screenshot.capture(path) inside +# your Rl.while_window_open block at the moment you choose: +# +# require_relative "../tools/screenshot_mode" +# ... +# Rl.while_window_open do +# ... draw ... +# Jamstack::Screenshot.capture("/tmp/scene.png") if some_condition +# end +# +# capture() is a no-op unless JAMSTACK_SCREENSHOT_MANUAL=1 is set, so it is safe +# to leave committed in a game script — it only fires when you opt in. This keeps +# the env hook (auto-exit) and manual capture from both running. +# +# NOTE: take_screenshot reads the framebuffer AFTER the current frame's drawing +# is complete, so call capture() at the END of your block (after Rl.draw {}). +# On desktop the PNG is written synchronously by raylib. Do NOT commit PNGs. + +module Jamstack + module Screenshot + class << self + # True only when manual screenshot mode is opted in via the env var. + # Keeps committed `capture` calls inert in normal runs. + def enabled? = !::Jamstack.getenv('JAMSTACK_SCREENSHOT_MANUAL').nil? + + # Write a pixel-exact PNG of the current framebuffer to +path+. + # No-op unless JAMSTACK_SCREENSHOT_MANUAL=1. Returns true if written. + def capture(path) + return false unless enabled? + ::Rl.take_screenshot(path) + ::Jamstack::Log.info("screenshot(manual) -> #{path}") rescue nil + true + end + + # Count frames and capture once +path+ after +frames+ frames have elapsed + # since the first call. Useful inside the loop: pass the same path each + # frame; it fires exactly once then stops. Returns true on the capturing + # frame, nil otherwise. No-op unless enabled?. + def capture_after(frames, path) + return nil unless enabled? + @counters ||= {} + n = (@counters[path] ||= 0) + 1 + @counters[path] = n + if n == frames + capture(path) + else + nil + end + end + + # Reset the per-path frame counter (e.g. to re-capture the same path). + def reset(path = nil) + if path + @counters&.delete(path) + else + @counters = {} + end + end + end + end +end diff --git a/tools/web_screenshot.js b/tools/web_screenshot.js new file mode 100644 index 0000000..488a781 --- /dev/null +++ b/tools/web_screenshot.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/* tools/web_screenshot.js — capture a frame of the web game via headless Chromium. + * + * Used by bin/screenshot --target=web as the headless-browser capture path (no + * system chrome needed: puppeteer downloads a Chromium into the project cache). + * + * Usage: + * node tools/web_screenshot.js [waitMs] [width] [height] + * + * Loads in headless Chromium at x, waits (default + * 2500ms) for the wasm boot + WebGL + a couple of shader frames, then captures a + * full-page PNG to . Prints the output path on success, exits non-zero + * on failure. + * + * The page needs a moment: emscripten boots mruby -> game/fx_demo.rb runs the FX + * pipeline (SMAA/FXAA/CRT shaders) -> at least one frame composites. 2.5s is a + * safe floor; raise via the 3rd arg for heavier scenes. + * + * Chromium in headless mode needs --no-sandbox under many CI/root contexts, and + * --use-gl=swiftshader / --enable-unsafe-swiftshader so WebGL renders without a + * GPU (WSL/headless). Without software GL the canvas stays blank (no GPU). + */ +'use strict'; + +const url = process.argv[2]; +const out = process.argv[3]; +const wait = parseInt(process.argv[4] || '2500', 10); +const width = parseInt(process.argv[5] || '1280', 10); +const height = parseInt(process.argv[6] || '720', 10); + +if (!url || !out) { + console.error('usage: node tools/web_screenshot.js [waitMs] [w] [h]'); + process.exit(2); +} + +(async () => { + let browser; + try { + const puppeteer = require('puppeteer'); + browser = await puppeteer.launch({ + headless: 'new', + args: [ + `--window-size=${width},${height}`, + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--disable-gpu-sandbox', + ], + }); + const page = await browser.newPage(); + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + // Capture console + pageerrors for diagnostics (routed to stderr only). + // Diagnostics -> stderr. The relay's agent-bridge.js polls /jamstack/console + // and /jamstack/status every frame; those requests get ERR_ABORTED when the + // page tears down at screenshot time and are NOT errors -> filtered out. + // favicon.ico 404 is also expected (the relay serves none) -> filtered. + const noise = (u) => + /\/jamstack\/(console|status|poll)/.test(u) || /favicon\.ico/.test(u); + page.on('pageerror', e => console.error('[pageerror]', e.message)); + page.on('response', r => { if (r.status() >= 400 && !noise(r.url())) console.error('[http]', r.status(), r.url()); }); + page.on('requestfailed', r => { if (!noise(r.url())) console.error('[reqfail]', r.url(), r.failure()?.errorText); }); + + // CRITICAL: disable the browser HTTP cache. Puppeteer/headless Chromium will + // otherwise serve a STALE game.wasm/game.js/game.data across rebuilds, which + // makes two captures of DIFFERENT builds come out byte-identical (looks like a + // code change had no effect — a dangerous false negative when A/B-testing + // shader edits). The relay already sends Cache-Control: no-store, but + // puppeteer may still cache — disable explicitly + cache-bust the entry URL. + await page.setCacheEnabled(false); + const bust = url.includes('?') ? `&cb=${Date.now()}` : `?cb=${Date.now()}`; + // NOTE: waitUntil 'load' (not 'networkidle0') — the relay's /jamstack/poll + // long-poll keeps a connection alive, so networkidle never settles. + await page.goto(url + bust, { waitUntil: 'load', timeout: 30000 }); + // Extra settle time so shaders composite (load fires before first rendered + // frame). Wait for the canvas to have non-zero size + a beat. + await page.waitForFunction( + () => { const c = document.querySelector('canvas'); return c && c.width > 0 && c.height > 0; }, + { timeout: 15000 } + ).catch(() => {}); + await new Promise(r => setTimeout(r, wait)); + + await page.screenshot({ path: out, type: 'png' }); + console.log(out); + await browser.close(); + process.exit(0); + } catch (e) { + console.error('web_screenshot failed:', e.message); + try { await browser.close(); } catch (_) {} + process.exit(1); + } +})(); diff --git a/web/_headers b/web/_headers new file mode 100644 index 0000000..49f24c7 --- /dev/null +++ b/web/_headers @@ -0,0 +1,5 @@ +# Cloudflare Pages headers. Emscripten output filenames are stable across builds +# (game.wasm etc.), so force revalidation to avoid serving a stale build. ETags +# make this cheap (304 when unchanged). Copied to the static branch root on deploy. +/* + Cache-Control: no-cache diff --git a/web/agent-bridge.js b/web/agent-bridge.js new file mode 100644 index 0000000..776e78a --- /dev/null +++ b/web/agent-bridge.js @@ -0,0 +1,61 @@ +/* Jamstack web agent bridge (W2): injected into game.html by the relay + * (tools/agent-bridge/server.js). Polls the relay for Ruby commands, runs them in + * the live game via the wasm export (Module.jamstack), and posts results back, so + * the desktop .live/bin/eval interface works against this browser tab. Forwards + * console output to the relay's game-console. No-ops quietly if no relay is present + * (e.g. the page is served by a plain static server). Dev-only. */ +(function () { + 'use strict'; + + function ready() { return window.Module && typeof window.Module.jamstack === 'function'; } + + function post(url, obj) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(obj), + }).catch(function () {}); + } + + // Tee console.* to the relay (this also carries the Ruby Log stream, since + // Log -> puts -> Module.print -> console.log on web). + ['log', 'info', 'warn', 'error'].forEach(function (lvl) { + var orig = console[lvl] ? console[lvl].bind(console) : function () {}; + console[lvl] = function () { + orig.apply(null, arguments); + try { + var line = Array.prototype.map.call(arguments, String).join(' '); + post('/jamstack/console', { line: line }); + } catch (e) {} + }; + }); + + var relayUp = false; + + function loop() { + if (!ready()) { setTimeout(loop, 100); return; } + fetch('/jamstack/poll').then(function (r) { + if (!r.ok) throw new Error('relay ' + r.status); + relayUp = true; + return r.json(); + }).then(function (cmd) { + var had = cmd && cmd.code != null && cmd.code !== ''; + if (had) { + var out; + try { out = window.Module.jamstack(cmd.code); } + catch (e) { out = JSON.stringify({ ok: false, error: 'jamstack: ' + String(e) }); } + post('/jamstack/result', { id: cmd.id, result: out }); + } + setTimeout(loop, had ? 0 : 50); + }).catch(function () { + relayUp = false; // no relay (static server) -> back off quietly + setTimeout(loop, 1000); + }); + } + + setInterval(function () { + if (relayUp && ready()) post('/jamstack/status', { connected: true, ts: Date.now() / 1000 }); + }, 1000); + + loop(); +})(); diff --git a/web/shell.html b/web/shell.html index 199756b..792a90c 100644 --- a/web/shell.html +++ b/web/shell.html @@ -1,110 +1,289 @@ - - + + - - -Study Player - + + + raylib-jamstack + -
- - - +
+
+ +
+
+
+ ruby repl (Enter: eval · Shift+Enter: newline · ↑/↓: hist) + +
+
+
+ > + +
+
-
- -
-
- +
loading…
+ function autoSize() { + input.style.height = '1.6em'; + input.style.height = Math.min(input.scrollHeight, 8 * 20) + 'px'; + } + var Module = { + arguments: ['game/fx_demo.rb'], + canvas: canvas, + print: function (t) { console.log(t); }, + printErr: function (t) { console.error(t); }, + setStatus: function (t) { statusEl.textContent = t; if (!t) statusEl.style.display = 'none'; }, + onRuntimeInitialized: function () { + Module.jamstack = function (code) { + return Module.ccall('jamstack_eval', 'string', ['string'], [code]); + }; + Module.flecsRequest = function (method, path, body) { + return Module.ccall('flecs_explorer_request', 'string', + ['string', 'string', 'string'], [method, path, body || '']); + }; + input.disabled = false; input.placeholder = 'type Ruby, then Enter…'; + replDot.classList.remove('idle'); + replTarget.textContent = 'fx_demo.rb'; + appendInfo('Jamstack HTML REPL ready — evals in the live game via jamstack_eval.'); + appendInfo('Try: score player_x = 100 Rl.get_fps Rl.platform'); + input.focus(); + }, + }; + {{{ SCRIPT }}} - -- cgit v1.2.3