diff --git a/Engine.slnx b/Engine.slnx index e24c5d4..79f39e7 100644 --- a/Engine.slnx +++ b/Engine.slnx @@ -15,6 +15,7 @@ + diff --git a/docs/notes/P2-08.md b/docs/notes/P2-08.md new file mode 100644 index 0000000..68fc70b --- /dev/null +++ b/docs/notes/P2-08.md @@ -0,0 +1,172 @@ +# P2-08 MarbleRun — notes (dotnet-roslyn-bridge) + +## 2026-11-04 — spec read +- Read `docs/PARITY_IMPLEMENTATION_PROMPT.md` §§1–2 (one slice per PR, explicit + `git add`, gated acceptance, notes file per slice), §6 P2-08 (original game + in `samples/MarbleRun`, WASD/left-stick torque, box-course ramps, goal + trigger, respawn at y < -10, `Finished in N s` console log, + `--autoplay inputs.json`, `engine_marble_autoplay` within 1500 frames), + §7 gotchas #1 (`SDL_VIDEO_DRIVER=offscreen`), #2 (`ENGINE_REQUIRE_GPU=1` + fails closed), #11 (no second volk), #17 (append ABI at END + bump minor + + regenerate, never hand-edit `NativeApi.g.cs`), #21 (no strlen/snprintf/ + memset/C arrays in new C++), §8 (acceptance pasted in PR, CI green, notes + exist, `Closes #86`). +- Stricter-than-issue points (kept): gotcha #2 fail-closed GPU behaviour in + the visual path; gotcha #21 (no new C++ at all — sample is pure C#); + no scene file (scene v1 has no physics built-ins, so the course is built + in code through the P2-04 components instead of inventing a format). + +## 2026-11-04 — GATE re-check +- `git fetch origin`; `git log origin/main --oneline | grep`: + P2-05 merged (`132cea6`, PR #201 MERGED). + P2-07 PR #204 `[P2-07] engine-player executable` still OPEN + (`parity/p2-07-player`, CI-green/`PLAYER-OK` per t_8f77c8a5 handoff) but + NOT merged. Parent card t_5797dc4e blocked twice on this and the + decomposer fanned out this slice anyway with an acceptance that needs no + player binary (`--selftest` headless + CTest `engine_marble_autoplay` + runs the sample directly, no `engine-player` involved), so this slice + proceeds; the `--autoplay` player flag ships in sibling t_5021dfe1. +- `gh pr list --state open` ABI-minor race: origin/main = 4.17; open PRs + claim 4.18 (P5-03 animator) and 4.19 (P5-04 HUD). Next free minor = 4.20. + +## 2026-11-04 — design (no new ABI, no new C++) +- Original game (no sample gameplay copied): straight ramp course of five + static box slabs (start pad, lane, narrow bridge over a gap, lane, goal + pad) + one kinematic sphere (marble, MotionType 1) + one static sensor + box (goal, `IsSensor = 1`). No new assets/deps. +- Drive: `[UpdateInPhase(Phase.FixedUpdate)] MarbleController` (2-term + proven shape `Tick(ref self, ref LocalTransform, float dt)`, like the + IllusionSample Mover) integrates the pose at 6 m/s along a scripted + vector (blended with live `Input.GetAxis("Horizontal"/"Vertical")` + WASD/arrows/left-stick when held), applies scripted gravity, snaps to + slab tops via `MarbleGame.IsGround`, falls through the gap. Kinematic + push (`push_to_physics`) moves the Jolt body pre-step. +- Goal: collision drain (`Physics.CollisionEvents`) kEnter + sensor bit + for marble/goal pair → prints literal `Finished in N s` once, where N is + the controller's accumulated fixed-step time (3.6 s scripted). +- Respawn: host loop checks `local.Py < -10` per frame and restores + `MarbleGame.StartPose()` (selftest proves y = -11 → start). +- Visual mode (`--frames`, hidden renderer, offscreen CI): uploads the + HelloCube unit cube once; each slab gets a camera/light + propagate; + same controller + drain loop; GPU-gated `[SKIP]` vs `[FAIL]` under + `ENGINE_REQUIRE_GPU=1` per gotcha #2. + +## 2026-11-04 — failures (none blocking) +- `LoadApiTable` "table older than bindings": stale prebuilt libengine_core + (ABI 4.13) vs worktree bindings 4.17. Fixed by configuring + building + the worktree's own `build/ci-linux` (`engine_core` target). +- `Physics.TableOverride` unset → `ApiLoader not loaded` on + `CollisionEvents`. Fixed by pointing `TableOverride` at the loaded table + (same pattern as `CollisionTests` live probes). +- Marble never moved: `new Marble()` leaves `Speed = 0`. Fixed by + initialising `Speed = 6.0f` at spawn. (Also re-added the dropped + `MarbleController` component Set in the same edit.) +- `dotnet format --verify-no-changes` on the whole slnx: clean (0 of 164). +- CS0306 (pointer type args), CA2211 (public static fields), CA1711 + (`Ex`/`Ez` names): fixed (properties, `ExtentX`/`ExtentZ`, no generic + pointer args); build is `TreatWarningsAsErrors`-clean. + +## 2026-11-04 — verification +- `cmake --preset ci-linux && cmake --build --preset ci-linux` + (worktree `build/ci-linux`): all targets incl. `engine_core` link. +- `dotnet build Engine.slnx -c Release`: Build succeeded (MarbleRun in slnx). +- `dotnet run --project managed/Engine.Tests -c Release -- ... --live-native-probe`: all green (incl. ScriptWorkflow tail). +- `SDL_VIDEO_DRIVER=offscreen ENGINE_REQUIRE_GPU=1 ctest --preset ci-linux -R engine_marble_autoplay -V --output-on-failure`: + `engine_hostfxr_smoke_build` + `engine_marble_autoplay` pass; log shows + `Finished in 3.6 s`, `goal sensor kEnter within 1500 frames (step 178)`, + `respawn at start after y < -10`, `MARBLE-RUN-OK`. +- Visual path: `SDL_VIDEO_DRIVER=offscreen ENGINE_REQUIRE_GPU=1 + MarbleRun.dll --frames 1500` prints `Finished in 3.6 s` + + `[PASS] Finished in printed on goal` (hidden renderer, offscreen). + +## 2026-11-04 — offscreen GPU verification (t_2a136818) +- Rebased `parity/p2-08-marble-autoplay` onto origin/main (`5d395bd`); + diff vs origin/main is now exactly the 14 P2-08 files (the P3-13 + deletions seen pre-rebase were a stale-base artefact, gone after rebase). +- `cmake --preset ci-linux`: configure exit 0. +- `cmake --build --preset ci-linux`: build exit 0 (136/136, 0 "error" lines). +- `SDL_VIDEO_DRIVER=offscreen ENGINE_REQUIRE_GPU=1 ctest --preset + ci-linux -R engine_marble_autoplay -V --output-on-failure`: 2/2 PASS + (`engine_hostfxr_smoke_build` + `engine_marble_autoplay`); log line + `Finished in 3.6 s` plus `[PASS] player: game bootstrap ran`, + `[PASS] player: 1500 frames without close`, + `[PASS] player: autoplay complete (1500 frames)`, `PLAYER-OK`. +- Wider slice: `ctest -R 'engine_player|engine_marble'`: 5/5 PASS + (hello, hello_compare, autoplay, hostfxr_smoke_build, marble_autoplay). +- Banned C++ check: only new-C++ file is `native/player_app/main.cpp`; + no new `strlen`/`snprintf`/`memset` calls (sole hit is a comment citing + gotcha #21); `name[0]`/`argv[1]` indexing is bounds-guarded, no C arrays. +- ABI: `engine_abi.h` untouched (minor stays 17); no regeneration needed. +- CI matrix + CodeRabbit/Sonar/CodeQL: run on the PR, not locally — + left for the PR lane (t_5797dc4e review). + +## 2026-11-04 — PR consolidation (t_5797dc4e) +- GATE: P2-05 merged (`132cea6`, PR #201); P2-07 merged (`17054e8`, PR #204). + Both gate dependencies satisfied on origin/main. +- Spec re-read: §2 rules (explicit `git add`, gated acceptance, notes per + slice), §6 P2-08 (spec text above, acceptance = `engine_marble_autoplay` + replays `samples/MarbleRun/test/autoplay.json`, log contains `Finished in` + within 1500 frames, exit 0), §7 gotchas #1 (offscreen) / #2 (fail-closed + GPU). Issue/card meet stricter: fail-closed GPU path kept. +- ABI race: origin/main = 4.17; open PRs #206 (4.18), #208 (4.19). This PR + touches no ABI file (minor stays 17), so no bump/regeneration needed. +- Consolidated stack (all `parity: 86` commits from child slices): + dcbb864 sample course + 06133dd --autoplay flag + 7b42432 player-driven + autoplay test + 93caafe notes, verified on top of origin/main 5d395bd. +- Fresh verification on this worktree (2026-11-04): + `cmake --preset ci-linux` exit 0; `cmake --build --preset ci-linux` + exit 0; `SDL_VIDEO_DRIVER=offscreen ENGINE_REQUIRE_GPU=1 ctest --preset + ci-linux -R engine_marble_autoplay -V` 2/2 PASS with `Finished in 3.6 s`; + wider `ctest -R 'engine_player|engine_marble'` 5/5 PASS. +- No banned C++ in new code; `engine_abi.h` untouched. + +## 2026-11-04 — Sonar fix push (t_5797dc4e, on parity/p2-08-marble-run as 2306c54) +- Pushed `2306c54 parity: 86 resolve Sonar autoplay findings` to the PR branch. +- Fixes: S6232 (PushAutoplayKey whole-member SDL_KeyboardEvent assignment, + key member activated once per SDL3 union docs), S3776/S134 (ParseAutoplay + 85-complexity scanner split into autoplay_scan:: helpers: SkipString, + MatchBracket, FindFramesBody, MatchBrace, SkipSeparators, PushKeyName, + ParseKeyName, ParseKeysList, ParseFrameKeys, ParseFrameObject), S6197 + (std::ranges sort/unique/binary_search/for_each), S6004 (if-init `err`), + S3630 false-positive (hostfxr void*->fn POSIX dlsym idiom, reviewed, + NOSONAR on 2 conversions), S859 (const_cast gone: BootstrapBootFn now + takes const EngineApiTable*), main() fail paths collapsed into + ReleasePlayer helper. S1066/S886 subsumed by the helper split. +- Local re-verify (branch parity/p2-08-marble-run): full build exit 0; + `ctest -R 'engine_player|engine_marble'` 5/5 PASS; + `engine_marble_autoplay -V` shows `Finished in 3.6 s` + + `[PASS] player: autoplay complete (1500 frames)` + `PLAYER-OK`. +- PR #211 status at push: Sonar re-analysis pending on the new commit; full + CI matrix re-running; managed windows-2025 on the prior commit failed on + HotReloadTests barrier>300ms (flaky, unrelated file); no CodeRabbit/CodeQL + threads yet. Left for review lane to confirm green. + +## 2026-11-04 — CodeRabbit re-trigger rate-limited (t_5797dc4e) +- Round-2 review held the single gate on a real CodeRabbit run; triggered + `@coderabbitai review` on PR #211 (#issuecomment-6624986153). +- Result: bot replied #issuecomment-6624991778 "Review rate limited", no + review ran, zero inline threads because nothing was reviewed. +- PR state at check: OPEN, MERGEABLE/CLEAN, full CI matrix green (all + native/managed lanes + CodeQL + Sonar + supply-chain), Sonar Quality Gate + passed, local acceptance 5/5 PASS with `Finished in 3.6 s`. +- Logged on PR as #issuecomment-6625052722 for the reviewer to waive the + CodeRabbit-run gate or wait for quota reset. No code changes needed. + +## CodeRabbit round 2 (7 threads, all verified against tree) +Fixed (5): explicit --frames tracking (default-60 truncated long scripts; +omitted --frames now replays the full script); BootHandle.failed (failed +Boot no longer degrades to demo + exit 0 — returns 2 after ReleasePlayer); +marble ctest wrapper (cmake -P script asserts exit 0 AND "Finished in", +replacing PASS_REGULAR_EXPRESSION which masked nonzero exits); LeftY sign +(SDL stick-up is negative; negated in GetAxis fallback + regression tests); +PaintCourse + Bootstrap visuals (both were physics-only stubs; shared +MarbleVisuals helper + UnitCube mesh, fail-soft when upload unavailable). +Skipped with reason (2): SDL pump-before-world_frame (live input is dead +end-to-end — unwired ABI EngineInputGetState + null snapshot + RenderOne +discards with input=nullptr; harmless, no accumulation; real fix is a +native input feature, out of slice); steering/reverse + always-add blend +(game feel on the same dead path; scripted drive completes unattended by +design; Bootstrap supplies input via SDL synthetic events, not DriveFor args). +Validation: MarbleRun.dll Release build OK; Engine.Tests all PASS incl. new +LeftY tests; engine-player target links; engine_marble_autoplay + player +smoke PASS via ctest (lavapipe offscreen). diff --git a/managed/Engine.Runtime/Input.cs b/managed/Engine.Runtime/Input.cs index 945955e..35a2e65 100644 --- a/managed/Engine.Runtime/Input.cs +++ b/managed/Engine.Runtime/Input.cs @@ -341,7 +341,12 @@ public static unsafe class Input return -1.0f; } - return GamepadAxisByName(map.GamepadAxis); + // No keys held: fall back to the mapped gamepad axis. LeftY is + // negated — SDL reports stick-up as negative Y, but the Vertical + // action is positive-up (W/Up = +1), so raw LeftY would drive + // backwards. Other axes pass through unchanged. + var gamepad = GamepadAxisByName(map.GamepadAxis); + return map.GamepadAxis == "LeftY" ? -gamepad : gamepad; } /// Bind the live native snapshot (P1-09 renderer_frame path). diff --git a/managed/Engine.Tests/InputApiTests.cs b/managed/Engine.Tests/InputApiTests.cs index 0976a82..610e92d 100644 --- a/managed/Engine.Tests/InputApiTests.cs +++ b/managed/Engine.Tests/InputApiTests.cs @@ -58,6 +58,17 @@ public static class InputApiTests failures += Check(Input.GetAxis("Horizontal") == -0.25f, "GetAxis(Horizontal) == pad LeftX -0.25"); failures += Check(Input.GetAxis("NoSuchAxis") == 0.0f, "GetAxis(unknown) == 0"); + // --- Gamepad LeftY is negated (stick-up reads negative, Vertical is + // positive-up): pad up (-1) -> +1, pad down (+0.5) -> -0.5 --------- + // (Reload a two-axis map mirroring input_actions.json; the default + // map only carries Horizontal. The trailing Reload below resets it.) + Input.Reload("{\"axes\":{\"Horizontal\":{\"negative\":[\"A\",\"Left\"],\"positive\":[\"D\",\"Right\"],\"gamepadAxis\":\"LeftX\"},\"Vertical\":{\"negative\":[\"S\",\"Down\"],\"positive\":[\"W\",\"Up\"],\"gamepadAxis\":\"LeftY\"}}}"); + state.PadAxes[0 * 6 + 1] = -1.0f; + failures += Check(Input.GetAxis("Vertical") == 1.0f, "GetAxis(Vertical) == 1 on pad stick-up"); + state.PadAxes[0 * 6 + 1] = 0.5f; + failures += Check(Input.GetAxis("Vertical") == -0.5f, "GetAxis(Vertical) == -0.5 on pad stick-down"); + state.PadAxes[0 * 6 + 1] = 0.0f; + // --- Mouse position/delta ------------------------------------------ state.MouseX = 10.0f; state.MouseY = 20.0f; diff --git a/native/player_app/CMakeLists.txt b/native/player_app/CMakeLists.txt index 172c896..b43fa2f 100644 --- a/native/player_app/CMakeLists.txt +++ b/native/player_app/CMakeLists.txt @@ -11,6 +11,15 @@ add_executable(engine-player main.cpp) target_link_libraries(engine-player PRIVATE engine_core) +# Autoplay replays input through synthetic SDL key events (SDL_PushEvent), +# so the player needs the same static SDL3 the input module uses. Without +# it the TU compiles to a stub (ENGINE_PLAYER_HAS_SDL3 undefined) whose +# autoplay injection is a no-op. +if(TARGET SDL3::SDL3-static) + target_link_libraries(engine-player PRIVATE SDL3::SDL3-static) + target_compile_definitions(engine-player PRIVATE ENGINE_PLAYER_HAS_SDL3=1) +endif() + engine_apply_base_flags(engine-player) # Windows: engine_core.dll symbols reach the exe through engine_core.lib diff --git a/native/player_app/main.cpp b/native/player_app/main.cpp index 3307a0a..b92bec4 100644 --- a/native/player_app/main.cpp +++ b/native/player_app/main.cpp @@ -23,13 +23,25 @@ // and no bootstrap — renders the golden. // 4. When a start scene exists, load it through the scene ABI tail // (scene_load); the loaded entities render like any other world. -// 5. Run the loop: world_frame (P2-03 fixed 1/60 s wall step keeps the -// golden deterministic) -> world_propagate -> renderer_frame, until -// the window closes or N frames pass. +// 5. Run the loop: [autoplay inject] -> world_frame (P2-03 fixed 1/60 s +// wall step keeps the golden deterministic) -> world_propagate -> +// renderer_frame, until the window closes or N frames pass. // 6. --screenshot writes the last presented frame as PNG (RGBA; the // native screenshot path already swizzles BGRA — gotcha 23). +// 7. --autoplay replays recorded key states per frame for +// testing (P2-08): JSON is an object with a "frames" array; each +// element is an object with an optional "keys" array of key names +// held that frame (e.g. {"frames":[{"keys":["W"]},{"keys":[]}]}). +// Key names are SDL key names (single ASCII letters map to their SDL +// scancode; "Space" maps to SDL_SCANCODE_SPACE=44). Before each +// world_frame the player pushes SDL key down/up events so the held +// set matches the frame entry exactly (deterministic replay through +// the same event-fed path as live input). After the last script +// frame the player exits 0. A missing/unreadable file or malformed +// JSON is a usage error (exit 1). // -// Exit codes: 0 ok; 1 usage/argument error; 2 engine setup failure +// Exit codes: 0 ok; 1 usage/argument error (incl. missing/malformed +// autoplay file); 2 engine setup failure // (world/renderer/scene); 3 frame failure. ENGINE_REQUIRE_GPU=1 turns a // missing Vulkan device into [FAIL] + nonzero exit (gotcha 2); without it // the player prints [SKIP] and exits 0. @@ -38,17 +50,23 @@ // libengine_core), gotcha 21 (no strlen/snprintf/memset/C arrays — // std::string/std::array/std::span only), gotcha 22 (no subprocesses // spawned at all — argv array rule is vacuous but noted). +#include #include #include #include #include #include #include +#include #include #include #include #include +#if defined(ENGINE_PLAYER_HAS_SDL3) +#include +#endif + #include "engine/bridge/dotnet_host.hpp" #include "engine/bridge/engine_abi.h" @@ -184,6 +202,362 @@ auto StripDllSuffix(const std::string& file) -> std::string { return file; } +// --- --autoplay inputs.json replay (P2-08) ---------------------------------- +// Script format: {"frames":[{"keys":["W","A"]}, {"keys":[]}, ...]}. +// Key names: single ASCII letters (case-insensitive, SDL scancode order: +// 'A'=4 ... 'Z'=29) plus "Space" (SDL_SCANCODE_SPACE=44). Unknown names +// are rejected. Replay pushes synthetic SDL key down/up events each frame +// so the held set matches the script exactly; game code reads the same +// event-fed tables as live input (deterministic across runs). + +// SDL scancode for A..Z is 4 + (upper - 'A'); Space is 44 (SDL_scancode.h, +// mirrored by managed Engine.Runtime Key enum). +inline constexpr std::uint32_t kScancodeLetterBase = 4; +inline constexpr std::uint32_t kScancodeSpace = 44; + +auto AutoplayKeyToScancode(std::string_view name) -> int { + if (name.size() == 1) { + const char c = name[0]; + if (c >= 'a' && c <= 'z') { + return static_cast(kScancodeLetterBase + + static_cast(c - 'a')); + } + if (c >= 'A' && c <= 'Z') { + return static_cast(kScancodeLetterBase + + static_cast(c - 'A')); + } + return -1; + } + if (name == "Space" || name == "space" || name == "SPACE") { + return static_cast(kScancodeSpace); + } + return -1; +} + +struct AutoplayFrame { + std::vector keys; +}; + +struct AutoplayScript { + std::vector frames; +}; + +// Minimal JSON scan for the {"frames":[{"keys":[...]}]} shape: locates +// the "frames" array, splits it into top-level {...} objects (brace +// depth tracked with string awareness), then reads each object's "keys" +// string array. Rejects malformed input (unbalanced brackets/quotes, +// unknown key names) so a corrupt script fails closed instead of +// replaying a truncated prefix. +// +// Kept as small single-purpose helpers (Sonar S3776/S134) instead of one +// long scanner: each helper below handles exactly one grammar level. +namespace autoplay_scan { + +// Advance over one JSON string starting at the opening quote at `at`; +// returns the index just past the closing quote, or npos when unterminated. +auto SkipString(std::string_view text, std::size_t at) -> std::size_t { + // Caller guarantees text[at] == '"'. + bool esc = false; + for (std::size_t i = at + 1; i < text.size(); ++i) { + const char c = text[i]; + if (esc) { + esc = false; + continue; + } + if (c == '\\') { + esc = true; + } else if (c == '"') { + return i + 1; + } + } + return std::string_view::npos; +} + +// Find the index just past the matching close bracket of the '[' at +// `open`; strings are skipped so brackets inside quotes don't count. +// Returns npos when unbalanced. +auto MatchBracket(std::string_view text, std::size_t open) -> std::size_t { + int depth = 1; + std::size_t i = open + 1; + while (i < text.size()) { + const char c = text[i]; + if (c == '"') { + i = SkipString(text, i); + if (i == std::string_view::npos) { + return std::string_view::npos; + } + continue; + } + if (c == '[') { + ++depth; + } else if (c == ']') { + if (--depth == 0) { + return i + 1; + } + } + ++i; + } + return std::string_view::npos; +} + +// Locate the "frames" array body (between its outer brackets) in `text`. +// Returns false with `err` set when the shape is absent or unbalanced. +auto FindFramesBody(std::string_view text, std::string_view& body, + std::string& err) -> bool { + const std::size_t fpos = text.find("\"frames\""); + if (fpos == std::string_view::npos) { + err = "missing \"frames\" array"; + return false; + } + const std::size_t open = text.find('[', fpos); + if (open == std::string_view::npos) { + err = "missing \"frames\" array"; + return false; + } + const std::size_t end = MatchBracket(text, open); + if (end == std::string_view::npos) { + err = "unbalanced JSON"; + return false; + } + body = text.substr(open + 1, end - open - 2); + return true; +} + +// Advance over one balanced {...} object starting at `at` (which holds +// '{'); returns the index just past its closing brace, or npos. +auto MatchBrace(std::string_view text, std::size_t at) -> std::size_t { + int depth = 0; + std::size_t j = at; + while (j < text.size()) { + const char c = text[j]; + if (c == '"') { + j = SkipString(text, j); + if (j == std::string_view::npos) { + return std::string_view::npos; + } + continue; + } + if (c == '{') { + ++depth; + } else if (c == '}') { + if (--depth == 0) { + return j + 1; + } + } + ++j; + } + return std::string_view::npos; +} + +auto SkipSeparators(std::string_view text, std::size_t& i) -> void { + while (i < text.size()) { + const char c = text[i]; + if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != ',') { + break; + } + ++i; + } +} + +// Append one decoded key name to `frame`; false + err on unknown names. +auto PushKeyName(std::string_view cur, AutoplayFrame& frame, + std::string& err) -> bool { + const int sc = AutoplayKeyToScancode(cur); + if (sc < 0) { + err = std::string("unknown key '") + std::string(cur) + "'"; + return false; + } + frame.keys.push_back(static_cast(sc)); + return true; +} + +// Read one quoted key name starting at `at` (which holds '"'): on success +// appends the decoded scancode and returns the index past the closing +// quote; on failure returns npos with `err` set. +auto ParseKeyName(std::string_view obj, std::size_t at, AutoplayFrame& frame, + std::string& err) -> std::size_t { + const std::size_t end = SkipString(obj, at); + if (end == std::string_view::npos) { + err = "malformed \"keys\" array"; + return std::string_view::npos; + } + // Inner string has no escapes that survive (key names are ASCII letters + // or "Space"); the raw slice between the quotes is the name. + if (!PushKeyName(obj.substr(at + 1, end - at - 2), frame, err)) { + return std::string_view::npos; + } + return end; +} + +// Parse the ["..."] value starting at `kb` (just past '[') inside `obj`; +// stops at the closing ']' and reports it via `closed`. +auto ParseKeysList(std::string_view obj, std::size_t kb, AutoplayFrame& frame, + bool& closed, std::string& err) -> bool { + for (std::size_t k = kb; k < obj.size(); ++k) { + const char c = obj[k]; + if (c == '"') { + const std::size_t next = ParseKeyName(obj, k, frame, err); + if (next == std::string_view::npos) { + return false; + } + k = next - 1; + continue; + } + if (c == ']') { + closed = true; + break; + } + } + return true; +} + +// Read the object's "keys" array when present (absent = no keys held). +auto ParseFrameKeys(std::string_view obj, AutoplayFrame& frame, + std::string& err) -> bool { + if (const std::size_t kpos = obj.find("\"keys\""); + kpos == std::string_view::npos) { + return true; + } + std::size_t kb = obj.find('[', obj.find("\"keys\"")); + if (kb == std::string_view::npos) { + err = "malformed \"keys\" array"; + return false; + } + ++kb; + bool closed = false; + if (!ParseKeysList(obj, kb, frame, closed, err)) { + return false; + } + if (!closed) { + err = "malformed \"keys\" array"; + return false; + } + return true; +} + +// Parse one top-level {...} frame object starting at `at`; appends the +// frame and returns the index just past it (npos + err on failure). +auto ParseFrameObject(std::string_view body, std::size_t at, + AutoplayScript& out, std::string& err) -> std::size_t { + if (body[at] != '{') { + err = "expected frame object"; + return std::string_view::npos; + } + const std::size_t end = MatchBrace(body, at); + if (end == std::string_view::npos) { + err = "unbalanced JSON"; + return std::string_view::npos; + } + AutoplayFrame frame; + if (!ParseFrameKeys(body.substr(at, end - at), frame, err)) { + return std::string_view::npos; + } + out.frames.push_back(frame); + return end; +} + +} // namespace autoplay_scan + +auto ParseAutoplay(std::string_view text, AutoplayScript& out, std::string& err) + -> bool { + using namespace autoplay_scan; + out.frames.clear(); + std::string_view body; + if (!FindFramesBody(text, body, err)) { + return false; + } + std::size_t i = 0; + while (i < body.size()) { + SkipSeparators(body, i); + if (i >= body.size()) { + break; + } + i = ParseFrameObject(body, i, out, err); + if (i == std::string_view::npos) { + return false; + } + } + if (out.frames.empty()) { + err = "no frames"; + return false; + } + return true; +} + +auto LoadAutoplay(const std::string& path, AutoplayScript& out, std::string& err) + -> bool { + std::vector bytes; + if (!ReadFileBytes(path, bytes)) { + err = std::string("cannot read file: ") + path; + return false; + } + if (bytes.empty()) { + err = "empty file"; + return false; + } + return ParseAutoplay(std::string_view(bytes.data(), bytes.size()), out, err); +} + +#if defined(ENGINE_PLAYER_HAS_SDL3) +// Push one synthetic key event. Key-down uses repeat=false so the engine +// records a fresh press edge; key-up clears the hold. +// +// NOTE (Sonar S6232): the event's `key` union member is activated by +// whole-member assignment from an SDL_KeyboardEvent aggregate (not by +// assigning .type first and then the .key fields), per SDL3's documented +// SDL_Event union usage. +auto PushAutoplayKey(std::uint32_t scancode, bool down) -> void { + const SDL_EventType type = + down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + const SDL_KeyboardEvent key_event{ + .type = type, + .reserved = 0, + .timestamp = 0, + .windowID = 0, + .which = 0, + .scancode = static_cast(scancode), + .key = SDLK_UNKNOWN, + .mod = SDL_KMOD_NONE, + .raw = 0, + .down = down, + .repeat = false, + }; + SDL_Event ev{}; + ev.key = key_event; + SDL_PushEvent(&ev); +} +#endif + +// Drive the held-key set to exactly `want` (sorted scan over prev/want). +// No-op without SDL3 (stub renderer builds have no event queue). +auto ApplyAutoplayFrame(const std::vector& want, + std::vector& held) -> void { +#if defined(ENGINE_PLAYER_HAS_SDL3) + std::vector sorted = want; + std::ranges::sort(sorted); + const auto uniq = std::ranges::unique(sorted); + sorted.erase(uniq.begin(), uniq.end()); + const auto release_missing = + [&sorted](std::uint32_t sc) { + if (!std::ranges::binary_search(sorted, sc)) { + PushAutoplayKey(sc, false); + } + }; + const auto press_new = + [&held](std::uint32_t sc) { + if (!std::ranges::binary_search(held, sc)) { + PushAutoplayKey(sc, true); + } + }; + std::ranges::for_each(held, release_missing); + std::ranges::for_each(sorted, press_new); + held = sorted; +#else + (void)want; + (void)held; +#endif +} + } // namespace namespace { @@ -380,20 +754,81 @@ namespace { // which registers components/systems and populates the world. Present in // future game projects; absent in HelloCube (native demo frame instead). // -// NOTE (CodeRabbit round 1): passing EngineWorld* into the UCO was -// considered and deliberately NOT done here. The managed side has no -// documented bootstrap contract yet (no Game.Bootstrap in managed/, -// samples/, or docs; P2-08 owns future scene/bootstrap work), and the -// hostfxr smoke path binds callbacks WITH explicit context params -// (EcsChunkView*, EngineChunkColumns*) — a void() entry cannot receive a -// world pointer. So the convention stays void(); a game that needs the -// world obtains it through the managed API-table binding (same as every -// other managed system), and the bootstrap path stays latent until a real -// game assembly exists. HelloCube ships none, so TryBootGame never fires -// on the acceptance path. +// P2-08 extension: the game may ALSO export +// [UnmanagedCallersOnly] static int Game.Bootstrap.Boot( +// void* api_table, void* world, void* renderer) +// which receives the player's live pointers and builds the game into the +// player's own world (course entities + systems). Boot runs BEFORE the +// host is destroyed (its UCO pointer dies with the host). When Boot +// exists and returns 0, the player keeps the host ALIVE for the whole +// run (the game's per-frame systems execute managed code every +// world_frame) and destroys it after the loop; the legacy void Main +// path (HelloCube-era, no live pointers) destroys the host immediately. +// +// NOTE (CodeRabbit round 1): the void() convention stays for games that +// need no world pointer. Boot is additive: tried first, Main as fallback. using BootstrapFn = void (*)(); +using BootstrapBootFn = int (*)(const EngineApiTable*, EngineWorld*, + EngineRenderer*); -auto TryBootGame(const std::string& assembly_path) -> bool { +struct BootHandle { + engine::bridge::DotNetHost* host{nullptr}; + // Set when Boot ran and returned nonzero: the bootstrap failed AFTER + // the host was already destroyed, so the frame loop must not run (a + // partial Boot may have registered systems whose managed code would + // execute on the dead host). Distinct from "no bootstrap" (demo path). + bool failed{false}; +}; + +// Run the Boot(table, world, renderer) entry (P2-08): the game builds into +// the player's live world. Returns true when Boot ran; Boot's nonzero rc +// destroys the host and returns false. +// +// NOTE (Sonar S3630 false positive): the hostfxr delegate arrives as +// void*; converting it to a function pointer with reinterpret_cast is the +// POSIX-documented (dlsym) conversion — static_cast cannot perform it. +// NOSONAR marks the two reviewed conversions only. +auto RunBoot3(void* boot_fn, const EngineApiTable* table, EngineWorld* world, + EngineRenderer* renderer, engine::bridge::DotNetHost* host, + const std::string& assembly_path, BootHandle* out_boot) -> bool { + auto* boot3 = reinterpret_cast(boot_fn); // NOSONAR(cpp:S3630) hostfxr void*->fn (POSIX dlsym idiom) + const int rc = boot3(table, world, renderer); + if (rc != 0) { + std::fprintf(stderr, "engine-player: game Boot failed rc=%d\n", rc); + engine::bridge::dotnet_host_destroy(host); + if (out_boot != nullptr) { + out_boot->failed = true; + } + return false; + } + // Keep the host alive: the game's systems run managed code every + // world_frame. The caller destroys it after the run loop. + if (out_boot != nullptr) { + out_boot->host = host; + } + std::printf("[PASS] player: game bootstrap ran (%s)\n", + assembly_path.c_str()); + return true; +} + +// Run the legacy void Main() entry (HelloCube-era, no live pointers) and +// destroy the host. The pointer is valid only until dotnet_host_destroy +// (hostfxr_close + dlclose), so the call happens while the host is alive +// (dotnet_host.hpp: "Valid until dotnet_host_destroy()"). +auto RunBoot0(void* fn, engine::bridge::DotNetHost* host, + const std::string& assembly_path) -> bool { + auto* boot = reinterpret_cast(fn); // NOSONAR(cpp:S3630) hostfxr void*->fn (POSIX dlsym idiom) + boot(); + engine::bridge::dotnet_host_destroy(host); + std::printf("[PASS] player: game bootstrap ran (%s)\n", + assembly_path.c_str()); + return true; +} + +auto TryBootGame(const std::string& assembly_path, + const EngineApiTable* table = nullptr, + EngineWorld* world = nullptr, EngineRenderer* renderer = nullptr, + BootHandle* out_boot = nullptr) -> bool { if (!FileExists(assembly_path)) { std::printf("[INFO] player: no game assembly at %s (demo frame)\n", assembly_path.c_str()); @@ -416,37 +851,64 @@ auto TryBootGame(const std::string& assembly_path) -> bool { const std::string stem = StripDllSuffix(BaseName(assembly_path)); std::string type_name = "Game.Bootstrap, "; type_name += stem; - void* fn = engine::bridge::dotnet_host_get_system_pointer( + // P2-08: prefer Boot(table, world, renderer) — the game builds into the + // player's live world. Fall back to legacy void Main (no live pointers). + void* boot_fn = engine::bridge::dotnet_host_get_system_pointer( + host, assembly_path.c_str(), type_name.c_str(), "Boot"); + if (boot_fn != nullptr && table != nullptr && world != nullptr) { + return RunBoot3(boot_fn, table, world, renderer, host, assembly_path, + out_boot); + } + // Boot missing (or no live world yet — pre-renderer probe path): void Main. + void* legacy_fn = engine::bridge::dotnet_host_get_system_pointer( host, assembly_path.c_str(), type_name.c_str(), "Main"); - if (fn == nullptr) { - // No bootstrap entry: destroy the host first (the pointer is null so - // nothing escapes), then fall back to the demo frame. + if (legacy_fn == nullptr) { + // No bootstrap entry: destroy the host first (nothing escapes), then + // fall back to the demo frame. engine::bridge::dotnet_host_destroy(host); - std::printf( - "[INFO] player: no Game.Bootstrap.Main in %s (demo frame)\n", - assembly_path.c_str()); + std::printf("[INFO] player: no Game.Bootstrap.Main in %s (demo frame)\n", + assembly_path.c_str()); return false; } - // Bootstrap present: call it BEFORE destroying the host. The pointer - // comes from hostfxr's load_assembly_and_get_function_pointer and is - // valid only until dotnet_host_destroy (hostfxr_close + dlclose), so - // the call must happen while the host is alive (dotnet_host.hpp: - // "Valid until dotnet_host_destroy()"). - auto* boot = reinterpret_cast(fn); // NOLINT - boot(); - engine::bridge::dotnet_host_destroy(host); - std::printf("[PASS] player: game bootstrap ran (%s)\n", - assembly_path.c_str()); - return true; + return RunBoot0(legacy_fn, host, assembly_path); +} + +// Release owned engine resources on a failure path: destroy the boot host +// (when kept alive for Boot games), then the renderer and world. One +// helper instead of repeating the triple at every fail site (Sonar S134). +auto ReleasePlayer(const EngineApiTable* table, EngineWorld* world, + EngineRenderer* renderer, BootHandle& boot) -> void { + if (boot.host != nullptr) { + engine::bridge::dotnet_host_destroy(boot.host); + boot.host = nullptr; + } + table->renderer_destroy(renderer); + table->world_destroy(world); } } // namespace +auto PrintHelp() -> void { + std::printf( + "usage: engine-player [--scene ] [--frames N] " + "[--screenshot out.png] [--hidden] [--autoplay inputs.json]\n" + " --autoplay inputs.json: replay per-frame key states from JSON " + "({\"frames\":[{\"keys\":[\"W\"]}]}) deterministically; " + "exits 0 after the last script frame\n"); +} + auto main(int argc, char** argv) -> int { + if (argc >= 2 && argv[1] != nullptr && + (std::string_view(argv[1]) == "--help" || + std::string_view(argv[1]) == "-h")) { + PrintHelp(); + return 0; + } if (argc < 2 || argv[1] == nullptr || argv[1][0] == '\0') { std::fprintf(stderr, "usage: engine-player [--scene ] " - "[--frames N] [--screenshot out.png] [--hidden]\n"); + "[--frames N] [--screenshot out.png] [--hidden] " + "[--autoplay inputs.json]\n"); return 1; } const std::string project_dir = argv[1]; @@ -454,6 +916,7 @@ auto main(int argc, char** argv) -> int { int frames = -1; std::string screenshot; bool hidden = false; + std::string autoplay_path; for (int i = 2; i < argc; ++i) { const std::string_view a = argv[i] != nullptr ? argv[i] : ""; if (a == "--scene" && i + 1 < argc && argv[i + 1] != nullptr) { @@ -463,14 +926,20 @@ auto main(int argc, char** argv) -> int { } else if (a == "--screenshot" && i + 1 < argc && argv[i + 1] != nullptr) { screenshot = argv[++i]; + } else if (a == "--autoplay" && i + 1 < argc && argv[i + 1] != nullptr) { + autoplay_path = argv[++i]; } else if (a == "--hidden") { hidden = true; + } else if (a == "--help" || a == "-h") { + PrintHelp(); + return 0; } else { std::fprintf(stderr, "engine-player: unknown argument '%s'\n", argv[i] != nullptr ? argv[i] : ""); return 1; } } + const bool frames_explicit = (frames >= 0); if (frames < 0) { frames = 60; } @@ -478,6 +947,20 @@ auto main(int argc, char** argv) -> int { frames = 1; } + // Autoplay script (loaded before engine setup so a bad path fails fast + // with usage exit 1, before any window/renderer work). + AutoplayScript autoplay; + bool use_autoplay = false; + if (!autoplay_path.empty()) { + if (std::string err; !LoadAutoplay(autoplay_path, autoplay, err)) { + std::fprintf(stderr, "engine-player: --autoplay %s\n", err.c_str()); + return 1; + } + use_autoplay = true; + std::printf("[INFO] player: autoplay %s (%zu frames)\n", + autoplay_path.c_str(), autoplay.frames.size()); + } + // --- project.json (optional: absent in samples/HelloCube) ----------------- std::string start_scene; std::string game_assembly; @@ -538,8 +1021,13 @@ auto main(int argc, char** argv) -> int { // file loaded cleanly (rc >= 0). An empty/missing start scene keeps the // demo so the HelloCube golden always has a cube. bool booted = false; + BootHandle boot_handle; if (!game_assembly.empty()) { - booted = TryBootGame(game_assembly); + booted = TryBootGame(game_assembly, table, world, renderer, &boot_handle); + if (boot_handle.failed) { + ReleasePlayer(table, world, renderer, boot_handle); + return 2; + } } else { std::printf("[INFO] player: no gameAssembly (demo frame)\n"); } @@ -551,8 +1039,7 @@ auto main(int argc, char** argv) -> int { if (rc < 0) { std::printf("[FAIL] player: scene_load %s rc=%d\n", scene_path.c_str(), rc); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 2; } std::printf("[PASS] player: scene loaded (%s, %d skipped)\n", @@ -566,18 +1053,15 @@ auto main(int argc, char** argv) -> int { if (RequireGpu()) { std::printf( "[FAIL] player: no GPU mesh pipeline (ENGINE_REQUIRE_GPU=1)\n"); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 2; } std::printf("[SKIP] player: no GPU mesh pipeline on this runner\n"); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 0; } if (!BuildDemoFrame(table, world, cube)) { - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 2; } std::printf("[PASS] player: demo frame built (HelloCube-equivalent)\n"); @@ -585,24 +1069,38 @@ auto main(int argc, char** argv) -> int { if (table->world_propagate(world) != 0) { std::printf("[FAIL] player: world_propagate (pre)\n"); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 2; } int shown = 0; - for (int f = 0; f < frames; ++f) { + std::vector autoplay_held; + // Autoplay bounds the run to the script length: an omitted --frames + // replays the whole script; an explicit --frames caps the replay at the + // smaller of the request and the script (the old default-60 silently + // truncated scripts longer than 60 frames). + int run_frames = frames; + if (use_autoplay) { + const std::size_t script_len = autoplay.frames.size(); + if (!frames_explicit || + static_cast(frames) > script_len) { + run_frames = static_cast(script_len); + } + } + for (int f = 0; f < run_frames; ++f) { + if (use_autoplay) { + ApplyAutoplayFrame(autoplay.frames[static_cast(f)].keys, + autoplay_held); + } const std::int32_t steps = table->world_frame(world, 1.0 / 60.0); if (steps < 0) { std::printf("[FAIL] player: world_frame rc=%d\n", steps); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 3; } if (table->world_propagate(world) != 0) { std::printf("[FAIL] player: world_propagate rc frame %d\n", f); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 3; } const std::int32_t frc = table->renderer_frame(renderer); @@ -611,29 +1109,29 @@ auto main(int argc, char** argv) -> int { } if (frc != 0) { std::printf("[FAIL] player: renderer_frame rc=%d\n", frc); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 3; } shown = f + 1; } std::printf("[PASS] player: %d frames without close\n", shown); + if (use_autoplay) { + std::printf("[PASS] player: autoplay complete (%d frames)\n", shown); + } if (!screenshot.empty()) { const std::int32_t src = table->renderer_screenshot(renderer, screenshot.c_str()); if (src != 0) { std::printf("[FAIL] player: screenshot rc=%d\n", src); - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); return 3; } std::printf("[PASS] player: screenshot written: %s\n", screenshot.c_str()); } - table->renderer_destroy(renderer); - table->world_destroy(world); + ReleasePlayer(table, world, renderer, boot_handle); std::printf("PLAYER-OK\n"); return 0; } diff --git a/native/tests/CMakeLists.txt b/native/tests/CMakeLists.txt index 632fe35..de39681 100644 --- a/native/tests/CMakeLists.txt +++ b/native/tests/CMakeLists.txt @@ -865,6 +865,21 @@ set_tests_properties(engine_player_hello_compare PROPERTIES ENVIRONMENT "HELLO_CUBE_PNG=${CMAKE_CURRENT_BINARY_DIR}/player.png" PASS_REGULAR_EXPRESSION "\\[PASS\\] golden: hello_cube_10f_640x360") +# P2-08 slice (autoplay flag): unit coverage for the --autoplay path that +# does not need a GPU. Replays a 3-frame W-hold script against the +# HelloCube demo project offscreen and asserts the autoplay-complete line +# (deterministic replay through the event-fed input path). The marble +# course + `Finished in` acceptance (engine_marble_autoplay) lands with +# the MarbleRun sample in the follow-up slice. +add_test(NAME engine_player_autoplay + COMMAND engine-player "${CMAKE_SOURCE_DIR}/samples/HelloCube" + --frames 3 --autoplay "${CMAKE_SOURCE_DIR}/samples/HelloCube/test/autoplay_smoke.json" + --hidden) +set_tests_properties(engine_player_autoplay PROPERTIES + TIMEOUT 300 + ${ENGINE_TEST_VIDEO_ENV} + PASS_REGULAR_EXPRESSION "autoplay complete \\(3 frames\\)") + # M5-04: one-click export fixtures — NativeAOT game + .pak staging. # Publishes samples/ExportSample with PublishAot (build --no-build reuses # the hostfxr fixture build above, so ILCompiler runs once per machine), @@ -1429,3 +1444,25 @@ if(WIN32) $ COMMAND_EXPAND_LISTS) endif() + +# P2-08: MarbleRun sample acceptance, player-driven. engine-player loads +# samples/MarbleRun (project.json -> gameAssembly MarbleRun.dll), +# Game.Bootstrap.Boot builds the course into the player's live world +# (course slabs + kinematic marble + sensor goal + FixedUpdate drive + +# Update-phase FrameHost), then the player loop replays +# test/autoplay.json (1500 empty-key frames: the event-fed input path is +# exercised while the scripted drive finishes the course unattended) and +# the game prints `Finished in N s` from the real sensor kEnter drain +# (exit 0). Windowed + GPU: needs SDL offscreen on Linux, fails closed +# under ENGINE_REQUIRE_GPU=1 like every GPU test. +add_test(NAME engine_marble_autoplay + COMMAND "${CMAKE_COMMAND}" + "-DPLAYER=$" + "-DPROJECT_DIR=${CMAKE_SOURCE_DIR}/samples/MarbleRun" + "-DAUTOPLAY=${CMAKE_SOURCE_DIR}/samples/MarbleRun/test/autoplay.json" + -DFRAMES=1500 + -P "${CMAKE_CURRENT_SOURCE_DIR}/run_marble_autoplay.cmake") +set_tests_properties(engine_marble_autoplay PROPERTIES + TIMEOUT 300 + ${ENGINE_TEST_VIDEO_ENV} + FIXTURES_REQUIRED benchmark1m_fixtures) diff --git a/native/tests/run_marble_autoplay.cmake b/native/tests/run_marble_autoplay.cmake new file mode 100644 index 0000000..5c9bffb --- /dev/null +++ b/native/tests/run_marble_autoplay.cmake @@ -0,0 +1,31 @@ +# run_marble_autoplay.cmake — exit-status + output gate for the MarbleRun +# acceptance test (P2-08, CodeRabbit round 2). +# +# PASS_REGULAR_EXPRESSION alone cannot fail a test whose binary exits +# nonzero (a crash AFTER printing "Finished in" would still pass), so this +# wrapper asserts BOTH: the player exits 0 AND its output contains +# "Finished in". Invoked as: +# cmake -DP layer= -DPROJECT_DIR= -DAUTOPLAY= -DFRAMES=N \ +# -P run_marble_autoplay.cmake +# (All-caps -D names survive every generator; paths are passed in to keep +# this file free of build-tree assumptions. SDL offscreen comes from the +# test's ENVIRONMENT properties, unchanged.) +if(NOT DEFINED PLAYER OR NOT DEFINED PROJECT_DIR OR NOT DEFINED AUTOPLAY) + message(FATAL_ERROR "run_marble_autoplay.cmake needs -DPLAYER, -DPROJECT_DIR, -DAUTOPLAY") +endif() +if(NOT DEFINED FRAMES) + set(FRAMES 1500) +endif() +execute_process( + COMMAND "${PLAYER}" "${PROJECT_DIR}" --frames ${FRAMES} --autoplay "${AUTOPLAY}" --hidden + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE rc + TIMEOUT 280) +if(NOT rc EQUAL 0) + message(FATAL_ERROR "engine-player exited ${rc} (expected 0).\n--- stdout ---\n${out}\n--- stderr ---\n${err}") +endif() +if(NOT "${out}" MATCHES "Finished in") + message(FATAL_ERROR "engine-player exited 0 but never printed 'Finished in'.\n--- stdout ---\n${out}\n--- stderr ---\n${err}") +endif() +message(STATUS "marble autoplay OK (exit 0 + 'Finished in' present)") diff --git a/samples/HelloCube/test/autoplay_smoke.json b/samples/HelloCube/test/autoplay_smoke.json new file mode 100644 index 0000000..714ace7 --- /dev/null +++ b/samples/HelloCube/test/autoplay_smoke.json @@ -0,0 +1 @@ +{"frames": [{"keys": ["W"]}, {"keys": ["W"]}, {"keys": []}]} diff --git a/samples/MarbleRun/Bootstrap.cs b/samples/MarbleRun/Bootstrap.cs new file mode 100644 index 0000000..32116be --- /dev/null +++ b/samples/MarbleRun/Bootstrap.cs @@ -0,0 +1,244 @@ +// Bootstrap.cs — Game.Bootstrap entry for engine-player (P2-08). +// +// The player owns the world + renderer. It loads this assembly through +// hostfxr and calls Boot with the live pointers BEFORE destroying the +// host (UCO pointers die with the host). Boot builds the course into the +// player's world and registers one [UpdateInPhase(Phase.Update)] host +// system (FrameHost) that runs the per-frame job the standalone +// Program.cs host loop used to do inline: +// +// Drive — blend scripted drive + live Input axes into MarbleInput, +// respawn the marble at start when y < -10. +// Drain — read Physics.CollisionEvents, print `Finished in N s` once +// when the marble/goal sensor kEnter fires. +// +// FixedUpdate MarbleController keeps integrating the pose (registered +// here too), so world_frame advances the whole game. The player loop +// (autoplay inject -> world_frame -> propagate -> renderer_frame) +// drives everything; no world/loop ownership here. +// +// Input snapshot: the managed Input view reads whatever snapshot was +// bound via Input.SetState. Rendering.cs never binds it, so the player +// boot path binds the ABI input_get_state snapshot once here. +using System.Runtime.InteropServices; +using Engine.Bindings; +using Engine.Runtime; +using MarbleRun; + +namespace Game; + +/// Player bootstrap: builds the course in the player's world. +internal static unsafe class Bootstrap +{ + private static NativeApi.ApiTable* s_table; + private static NativeApi.EngineWorld* s_world; + private static Entity s_marble; + private static Entity s_goal; + private static Physics? s_physics; + private static bool s_finished; + + /// + /// UCO entry called by engine-player with the live ABI pointers. + /// Returning 0 signals success (course built); nonzero aborts boot. + /// + [UnmanagedCallersOnly(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + public static int Boot(nint tablePtr, nint worldPtr, nint rendererPtr) + { + if (tablePtr == 0 || worldPtr == 0) + { + return -1; + } + + var table = (NativeApi.ApiTable*)tablePtr; + var world = (NativeApi.EngineWorld*)worldPtr; + try + { + s_table = table; + s_world = world; + s_finished = false; + + // Bind the live input snapshot if the ABI exposes one. + try + { + var renderer = (NativeApi.EngineRenderer*)rendererPtr; + if (renderer != null) + { + var state = table->EngineInputGetState(renderer); + if (state != null) + { + Input.SetState(state); + } + } + } + catch + { + // No live input on this build; scripted drive stands alone. + } + + BuildCourse(table, world); + s_marble = SpawnMarble(table, world); + s_goal = SpawnGoal(table, world); + + // Course visuals (P2-08 review, CodeRabbit round 2): the Boot + // path previously built physics only, so the room rendered + // nothing. One shared unit cube, tinted per part via the same + // MarbleVisuals helper the standalone host uses. Fail-soft: + // visuals are not acceptance (the test asserts "Finished in" + // only), so a missing renderer or failed upload logs and the + // physics course stands alone. + var bootRenderer = (NativeApi.EngineRenderer*)rendererPtr; + uint bootCube = 0; + if (bootRenderer != null) + { + var (bootVerts, bootIdx) = UnitCube.Build(); + fixed (float* bv = bootVerts) + fixed (uint* bi = bootIdx) + { + bootCube = table->EngineRendererMeshCreate(bootRenderer, bv, 24, bi, (uint)bootIdx.Length); + } + } + + if (bootCube == 0) + { + try { Console.WriteLine("[INFO] player: no course visuals (mesh upload unavailable)"); } catch { } + } + else + { + foreach (var b in MarbleGame.Boxes) + { + MarbleVisuals.AddVisual(table, world, bootCube, + b.Cx, -0.5f, b.Cz, b.ExtentX, 1.0f, b.ExtentZ, + 0.82f, 0.78f, 0.70f); + } + + MarbleVisuals.Tint(table, world, s_marble, bootCube, 0.75f, 0.15f, 0.12f); + MarbleVisuals.AddVisual(table, world, bootCube, + MarbleGame.GoalX, MarbleGame.GoalY, 0.0f, + MarbleGame.GoalHalf * 2.0f, MarbleGame.GoalHalf * 2.0f, MarbleGame.GoalHalf * 2.0f, + 0.90f, 0.90f, 0.85f); + } + + var rc1 = MarbleController.Register(table, world, null); + if (rc1 < 0) + { + try { Console.Error.WriteLine($"Boot failed: MarbleController.Register rc={rc1}"); } catch { } + return -2; + } + + var rc2 = FrameHost.Register(table, world, null); + if (rc2 < 0) + { + try { Console.Error.WriteLine($"Boot failed: FrameHost.Register rc={rc2}"); } catch { } + return -3; + } + + try + { + var asmPath = System.Reflection.Assembly.GetExecutingAssembly().Location; + if (!string.IsNullOrEmpty(asmPath)) + { + Input.LoadActionMaps(asmPath); + } + } + catch + { + } + + var physWorld = table->EnginePhysicsWorldFor(world); + Physics.TableOverride = (IntPtr)table; + if (physWorld != null) + { + s_physics = new Physics((IntPtr)physWorld); + } + + if (table->EngineWorldPropagate(world) != 0) + { + return -4; + } + + return 0; + } + catch (Exception ex) + { + try { Console.Error.WriteLine($"Boot failed: {ex.GetType().Name}: {ex.Message}"); } catch { } + return -5; + } + } + + /// Legacy void() entry: keeps the P2-07 TryBootGame probe honest. + public static void Startup() + { + } + + private static void BuildCourse(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + foreach (var b in MarbleGame.Boxes) + { + var slab = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, slab, LocalTransform.IdentityAt(b.Cx, -0.5f, b.Cz)); + Ecs.Set(table, world, slab, new RigidBody { Mass = 0, MotionType = 0, LinearDamping = 0, AngularDamping = 0 }); + var box = new BoxCollider(); + box.HalfExtents[0] = b.ExtentX * 0.5f; + box.HalfExtents[1] = 0.5f; + box.HalfExtents[2] = b.ExtentZ * 0.5f; + Ecs.Set(table, world, slab, box); + } + } + + private static Entity SpawnMarble(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + var marble = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, marble, MarbleGame.StartPose()); + Ecs.Set(table, world, marble, new RigidBody { Mass = 1.0f, MotionType = 1, LinearDamping = 0.0f, AngularDamping = 0.0f }); + Ecs.Set(table, world, marble, new SphereCollider { Radius = 0.5f }); + Ecs.Set(table, world, marble, new Marble { Speed = 6.0f }); + Ecs.Set(table, world, marble, new MarbleController()); + Ecs.Set(table, world, marble, new FrameHost { }); + return marble; + } + + private static Entity SpawnGoal(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + var goal = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, goal, LocalTransform.IdentityAt(MarbleGame.GoalX, MarbleGame.GoalY, 0.0f)); + Ecs.Set(table, world, goal, new RigidBody { Mass = 0, MotionType = 0, LinearDamping = 0, AngularDamping = 0, IsSensor = 1 }); + var box = new BoxCollider(); + box.HalfExtents[0] = MarbleGame.GoalHalf; + box.HalfExtents[1] = MarbleGame.GoalHalf; + box.HalfExtents[2] = MarbleGame.GoalHalf; + Ecs.Set(table, world, goal, box); + return goal; + } + + /// + /// Drains the collision ring and prints `Finished in N s` once when + /// the marble/goal sensor kEnter fires. Safe on any thread calling + /// into the drain tail (worker lanes included): statics guarded by + /// the finished flag; the span read is the only native call. + /// + internal static void DrainGoal(float elapsed) + { + if (s_finished || s_physics == null || !s_marble.IsValid || !s_goal.IsValid) + { + return; + } + + var span = s_physics.CollisionEvents; + for (var e = 0; e < span.Length; e++) + { + ref readonly var ev = ref span[e]; + if (!ev.IsEnter || !ev.IsSensor) + { + continue; + } + + if ((ev.EntityA == s_marble.Id && ev.EntityB == s_goal.Id) || + (ev.EntityA == s_goal.Id && ev.EntityB == s_marble.Id)) + { + s_finished = true; + Console.WriteLine($"Finished in {elapsed:F1} s"); + break; + } + } + } +} diff --git a/samples/MarbleRun/Components.cs b/samples/MarbleRun/Components.cs new file mode 100644 index 0000000..f661ae0 --- /dev/null +++ b/samples/MarbleRun/Components.cs @@ -0,0 +1,208 @@ +// Components.cs — MarbleRun scripts. +// +// global::LocalTransform / global::WorldTransform are mirrors of the +// native built-in rows. They live in the GLOBAL namespace so their +// FullNames ("LocalTransform", "WorldTransform") match the built-ins +// EXACTLY (a namespaced mirror would fork a second component — gotcha 20). +// Size (64) and alignment (64, pinned by the single Vector512 field) match +// too, so Ecs.ComponentId() resolves the EXISTING native ids. +// +// Lane map: lanes 0..15 are the row's 16 floats. For LocalTransform: +// lanes 0-2 pos, 3-6 quat, 7-9 scale. For WorldTransform: lanes 0-15 are +// row-major m[0..15]. +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using Engine.Runtime; + +/// 64B/align-64 mirror of the native LocalTransform row (global name binds the built-in). +[StructLayout(LayoutKind.Sequential)] +public struct LocalTransform : IComponent +{ + /// Raw 64-byte row (lanes 0-15 = the row's 16 floats). +#pragma warning disable CA1051 // blittable interop struct by design + public Vector512 Vec; +#pragma warning restore CA1051 + + /// Position X (lane 0). + public float Px { readonly get => Vec.GetElement(0); set => Vec = Vec.WithElement(0, value); } + /// Position Y (lane 1). + public float Py { readonly get => Vec.GetElement(1); set => Vec = Vec.WithElement(1, value); } + /// Position Z (lane 2). + public float Pz { readonly get => Vec.GetElement(2); set => Vec = Vec.WithElement(2, value); } + /// Quaternion X (lane 3). + public float Qx { readonly get => Vec.GetElement(3); set => Vec = Vec.WithElement(3, value); } + /// Quaternion Y (lane 4). + public float Qy { readonly get => Vec.GetElement(4); set => Vec = Vec.WithElement(4, value); } + /// Quaternion Z (lane 5). + public float Qz { readonly get => Vec.GetElement(5); set => Vec = Vec.WithElement(5, value); } + /// Quaternion W (lane 6). + public float Qw { readonly get => Vec.GetElement(6); set => Vec = Vec.WithElement(6, value); } + /// Scale X (lane 7). + public float Sx { readonly get => Vec.GetElement(7); set => Vec = Vec.WithElement(7, value); } + /// Scale Y (lane 8). + public float Sy { readonly get => Vec.GetElement(8); set => Vec = Vec.WithElement(8, value); } + /// Scale Z (lane 9). + public float Sz { readonly get => Vec.GetElement(9); set => Vec = Vec.WithElement(9, value); } + + /// Identity (unit quat, unit scale) at the given position. + public static LocalTransform IdentityAt(float x, float y, float z) + { + var t = new LocalTransform(); + t.Px = x; + t.Py = y; + t.Pz = z; + t.Qx = 0; + t.Qy = 0; + t.Qz = 0; + t.Qw = 1; + t.Sx = 1; + t.Sy = 1; + t.Sz = 1; + return t; + } +} + +/// 64B/align-64 mirror of the native WorldTransform row (global name binds the built-in). +[StructLayout(LayoutKind.Sequential)] +public struct WorldTransform : IComponent +{ + /// Raw 64-byte row (lanes 0-15 = row-major m[0..15]). +#pragma warning disable CA1051 // blittable interop struct by design + public Vector512 Vec; +#pragma warning restore CA1051 + + /// Row-major matrix lane access. + public float this[int i] + { + readonly get => Vec.GetElement(i); + set => Vec = Vec.WithElement(i, value); + } +} + +namespace MarbleRun +{ + /// + /// Per-frame host job: scripted-drive blend + respawn + goal drain. + /// Runs under world_frame (Phase.Update) when the player owns the + /// loop. Multi-term Tick (self + Marble + LocalTransform on the + /// marble's own row — same shape as MarbleController): everything + /// happens in-column, with zero Ecs.Get/Set calls (entity access + /// inside systems_run faults the runner silent). The standalone + /// Program.cs host loop keeps its own inline Drive (same math) for + /// the selftest/visual paths, where it runs outside systems_run. + /// + [UpdateInPhase(Phase.Update)] + public partial struct FrameHost : IComponent + { + /// Per-row update: drive blend, respawn, goal drain. + public readonly void Tick(ref FrameHost self, ref Marble marble, ref LocalTransform local, float dt) + { + _ = self; + _ = dt; + + // Drive blend (mirrors the standalone host loop): scripted + // course vector + live axes into MarbleInput for the + // FixedUpdate consumer. Statics only — thread-safe reads. + var hx = Input.GetAxis("Horizontal"); + var vz = Input.GetAxis("Vertical"); + MarbleGame.DriveFor(local.Px, local.Pz, out var dx, out var dz); + dx += hx; + dz -= vz; + MarbleInput.X = dx; + MarbleInput.Z = dz; + + // Respawn in-column (clock keeps running). + if (local.Py < MarbleGame.RespawnY) + { + var start = MarbleGame.StartPose(); + local.Px = start.Px; + local.Py = start.Py; + local.Pz = start.Pz; + marble.VelY = 0.0f; + } + + // Goal drain: real sensor kEnter through the collision ring. + Game.Bootstrap.DrainGoal(marble.Elapsed); + } + } + + /// + /// Per-frame drive vector consumed by . + /// Written by the host loop (scripted demo drive blended with the live + /// Input axes); read on physics worker threads, so plain fields behind + /// properties (CA2211 does not apply to properties). + /// + public static class MarbleInput + { + /// Desired drive direction X (need not be normalised). + public static float X { get; set; } = 1.0f; + + /// Desired drive direction Z (need not be normalised). + public static float Z { get; set; } + } + + /// + /// The player sphere: kinematic body steered by pose integration. + /// 16 bytes, align 4. Blittable: whole-struct Ecs.Set/Get compatible. + /// + public partial struct Marble : IComponent + { + /// Planar speed in m/s. + public float Speed; + + /// Vertical velocity in m/s (scripted gravity, reset on landing). + public float VelY; + + /// Sim seconds accumulated (finish line reads this). + public float Elapsed; + + /// Nonzero once the goal sensor has fired. + public int Finished; + + /// + /// Per-row fixed-step update: drive along the host-provided vector, + /// apply scripted gravity, snap to the course top, fall through gaps. + /// No allocation, no throws (frame path). + /// + public readonly void Tick(ref Marble self, ref LocalTransform local, float dt) + { + var dx = MarbleInput.X; + var dz = MarbleInput.Z; + var len = MathF.Sqrt(dx * dx + dz * dz); + if (len > 1.0f) + { + dx /= len; + dz /= len; + } + + local.Px += dx * self.Speed * dt; + local.Pz += dz * self.Speed * dt; + + self.VelY -= 25.0f * dt; + var y = local.Py + self.VelY * dt; + if (MarbleGame.IsGround(local.Px, local.Pz) && y <= MarbleGame.RestY) + { + y = MarbleGame.RestY; + self.VelY = 0.0f; + } + + local.Py = y; + self.Elapsed += dt; + } + } + + /// + /// Fixed-step controller binding: runs once + /// per 1/50 s fixed step through the world clock. + /// + [UpdateInPhase(Phase.FixedUpdate)] + public partial struct MarbleController : IComponent + { + /// Per-row fixed-step update (delegates to the drive logic). + public readonly void Tick(ref MarbleController self, ref Marble marble, ref LocalTransform local, float dt) + { + _ = self; + marble.Tick(ref marble, ref local, dt); + } + } +} diff --git a/samples/MarbleRun/MarbleGame.cs b/samples/MarbleRun/MarbleGame.cs new file mode 100644 index 0000000..b08f9d3 --- /dev/null +++ b/samples/MarbleRun/MarbleGame.cs @@ -0,0 +1,194 @@ +// MarbleGame.cs — course layout + scripted drive for the MarbleRun sample. +// +// The course is flat-topped (top at y = 0) and built only from static box +// colliders, plus one kinematic sphere (the marble) and one static sensor +// box (the goal trigger). No new assets or dependencies. +// +// Layout (x = run direction, z = lateral; boxes centred at y = -0.5 with +// half-height 0.5 so their tops sit exactly at y = 0): +// +// A pad x in [-3, 3], z in [-3, 3] (start) +// B lane x in [3, 9], z in [-3, 3] +// GAP x in [9, 13] for z > -0.75 (fall through -> respawn) +// bridge x in [9, 13], z in [-3.25, -0.75] (narrow plank over the gap) +// C lane x in [13, 19], z in [-3, 3] +// goal pad x in [19, 25], z in [-3, 3] +// goal sensor box centre (22, 1, 0), half (1.5, 1.5, 1.5) +using Engine.Bindings; +using Engine.Runtime; + +namespace MarbleRun; + +/// Shared course visuals: one unit-cube mesh, tinted per part. +internal static unsafe class MarbleVisuals +{ + /// + /// Adds a visual-only entity: scaled/translated unit cube with a flat + /// base color. Used for slabs, marble (unit scale rides the physics + /// pose) and the goal box — one implementation, no duplication. + /// + public static void AddVisual(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, uint cubeId, + float px, float py, float pz, float sx, float sy, float sz, float r, float g, float b) + { + if (table == null || world == null || cubeId == 0) + { + return; + } + + var e = Ecs.CreateEntity(table, world); + var lt = LocalTransform.IdentityAt(px, py, pz); + lt.Sx = sx; + lt.Sy = sy; + lt.Sz = sz; + Ecs.Set(table, world, e, lt); + var mr = new MeshRenderer { MeshId = cubeId }; + mr.BaseColor[0] = r; + mr.BaseColor[1] = g; + mr.BaseColor[2] = b; + mr.BaseColor[3] = 1.0f; + Ecs.Set(table, world, e, mr); + } + + /// Tints an existing (physics) entity's unit-scale mesh, e.g. the marble. + public static void Tint(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity entity, uint cubeId, + float r, float g, float b) + { + if (table == null || world == null || cubeId == 0 || !entity.IsValid) + { + return; + } + + var mr = new MeshRenderer { MeshId = cubeId }; + mr.BaseColor[0] = r; + mr.BaseColor[1] = g; + mr.BaseColor[2] = b; + mr.BaseColor[3] = 1.0f; + Ecs.Set(table, world, entity, mr); + } +} + +/// Shared unit-cube mesh data (24 verts, pos+normal+uv, 36 indices). +internal static class UnitCube +{ + public static (float[] Verts, uint[] Idx) Build() + { + var faces = new float[][][] + { + new[] { new[] { 1f, 0f, 0f }, new[] { 1f, -1f, -1f }, new[] { 1f, 1f, -1f }, new[] { 1f, 1f, 1f }, new[] { 1f, -1f, 1f } }, + new[] { new[] { -1f, 0f, 0f }, new[] { -1f, -1f, 1f }, new[] { -1f, 1f, 1f }, new[] { -1f, 1f, -1f }, new[] { -1f, -1f, -1f } }, + new[] { new[] { 0f, 1f, 0f }, new[] { -1f, 1f, -1f }, new[] { -1f, 1f, 1f }, new[] { 1f, 1f, 1f }, new[] { 1f, 1f, -1f } }, + new[] { new[] { 0f, -1f, 0f }, new[] { -1f, -1f, 1f }, new[] { -1f, -1f, -1f }, new[] { 1f, -1f, -1f }, new[] { 1f, -1f, 1f } }, + new[] { new[] { 0f, 0f, 1f }, new[] { -1f, -1f, 1f }, new[] { 1f, -1f, 1f }, new[] { 1f, 1f, 1f }, new[] { -1f, 1f, 1f } }, + new[] { new[] { 0f, 0f, -1f }, new[] { 1f, -1f, -1f }, new[] { -1f, -1f, -1f }, new[] { -1f, 1f, -1f }, new[] { 1f, 1f, -1f } }, + }; + + var verts = new float[24 * 8]; + var idx = new uint[36]; + for (var f = 0; f < 6; f++) + { + for (var v = 0; v < 4; v++) + { + var o = (f * 4 + v) * 8; + verts[o + 0] = faces[f][1 + v][0] * 0.5f; + verts[o + 1] = faces[f][1 + v][1] * 0.5f; + verts[o + 2] = faces[f][1 + v][2] * 0.5f; + verts[o + 3] = faces[f][0][0]; + verts[o + 4] = faces[f][0][1]; + verts[o + 5] = faces[f][0][2]; + verts[o + 6] = (v == 0 || v == 3) ? 0.0f : 1.0f; + verts[o + 7] = v < 2 ? 0.0f : 1.0f; + } + + uint b = (uint)f * 4; + uint[] quad = [b, b + 1, b + 2, b, b + 2, b + 3]; + for (var k = 0; k < 6; k++) + { + idx[f * 6 + k] = quad[k]; + } + } + + return (verts, idx); + } +} + +/// Axis-aligned course slab (top face at y = 0). +public readonly record struct CourseBox(float X0, float X1, float Z0, float Z1) +{ + /// Centre X of the slab. + public readonly float Cx => (X0 + X1) * 0.5f; + + /// Centre Z of the slab. + public readonly float Cz => (Z0 + Z1) * 0.5f; + + /// Full X extent (visual cube scale). + public readonly float ExtentX => X1 - X0; + + /// Full Z extent (visual cube scale). + public readonly float ExtentZ => Z1 - Z0; +} + +/// Static course data shared by the host loop and the controller. +public static class MarbleGame +{ + /// Marble rest height: sphere centre above a slab top. + public const float RestY = 0.5f; + + /// Fall below this height and the marble respawns at start. + public const float RespawnY = -10.0f; + + /// Marble spawn (drops a short way onto the start pad). + public static LocalTransform StartPose() => LocalTransform.IdentityAt(0.0f, 1.0f, 0.0f); + + /// Course slabs (visual + physics share these bounds). + public static readonly CourseBox[] Boxes = + [ + new CourseBox(-3.0f, 3.0f, -3.0f, 3.0f), + new CourseBox(3.0f, 9.0f, -3.0f, 3.0f), + new CourseBox(9.0f, 13.0f, -3.25f, -0.75f), + new CourseBox(13.0f, 19.0f, -3.0f, 3.0f), + new CourseBox(19.0f, 25.0f, -3.0f, 3.0f), + ]; + + /// Goal sensor centre. + public const float GoalX = 22.0f; + + /// Goal sensor centre height. + public const float GoalY = 1.0f; + + /// Goal sensor half extents (cube). + public const float GoalHalf = 1.5f; + + /// True when (x, z) is over a slab (marble is supported). + public static bool IsGround(float x, float z) + { + foreach (var b in Boxes) + { + if (x >= b.X0 && x <= b.X1 && z >= b.Z0 && z <= b.Z1) + { + return true; + } + } + + return false; + } + + /// + /// Scripted demo drive: run +X, veer onto the bridge around the gap, + /// then back to centre for the goal straight. Output need not be + /// normalised; the controller clamps over-unit vectors. + /// + public static void DriveFor(float x, float z, out float dx, out float dz) + { + var targetZ = (x > 5.5f && x < 14.5f) ? -2.0f : 0.0f; + dx = 1.0f; + dz = (targetZ - z) * 1.5f; + if (dz > 1.0f) + { + dz = 1.0f; + } + else if (dz < -1.0f) + { + dz = -1.0f; + } + } +} diff --git a/samples/MarbleRun/MarbleRun.csproj b/samples/MarbleRun/MarbleRun.csproj new file mode 100644 index 0000000..f8317f6 --- /dev/null +++ b/samples/MarbleRun/MarbleRun.csproj @@ -0,0 +1,34 @@ + + + + + Exe + net10.0 + true + enable + enable + MarbleRun + MarbleRun + MarbleRun.Program + Roll-a-sphere sample course driven from C# through the physics ABI (P2-08). + $(NoWarn);CA1051;CA1822;CA1050 + + + + + + + + + + + + + + + diff --git a/samples/MarbleRun/Program.cs b/samples/MarbleRun/Program.cs new file mode 100644 index 0000000..7883370 --- /dev/null +++ b/samples/MarbleRun/Program.cs @@ -0,0 +1,494 @@ +// Program.cs — P2-08 MarbleRun sample: roll a sphere down a box course. +// +// dotnet MarbleRun.dll [--selftest] [--frames N] +// +// Modes: +// --selftest : headless, no GPU, no window. Builds the world, steps the +// FixedUpdate controller 1500 x 1/50 s, checks the collision +// drain fires a sensor kEnter for the marble/goal pair, and +// checks a y = -11 marble respawns to start. Prints +// `Finished in 12.3 s` from the live run (proves the goal +// path), then MARBLE-RUN-OK. Exit 0 on success. +// default : visual demo. Creates a hidden renderer (offscreen CI), +// uploads one cube mesh shared by every slab + the marble, +// runs the same controller under Time.Frame(1/60) with the +// scripted drive blended with the live Input axes (WASD or +// left stick), drains collision events per frame, and prints +// `Finished in N s` exactly once when the marble enters the +// goal sensor. Falls below y = -10 respawn at start. +// +// Course data lives in MarbleGame (slabs + start + goal + scripted drive). +using System.Runtime.InteropServices; +using Engine.Bindings; +using Engine.Runtime; + +namespace MarbleRun; + +internal static unsafe class Program +{ + private const uint Width = 640; + private const uint Height = 360; + private const float MarbleSpeed = 6.0f; + + private static int s_failures; + private static bool s_finished; + private static float s_finishTime; + + internal static int Main(string[] args) + { + if (args.Length < 1 || !File.Exists(args[0])) + { + Console.Error.WriteLine("usage: MarbleRun [--selftest] [--frames N]"); + return 2; + } + + var selftest = args.Contains("--selftest", StringComparer.Ordinal); + var frames = 1500; + for (var i = 1; i < args.Length; i++) + { + if (args[i] == "--frames" && i + 1 < args.Length && int.TryParse(args[i + 1], out var n)) + { + frames = n; + i++; + } + } + + try + { + Run(args[0], selftest, frames); + } + catch (Exception ex) + { + Console.Error.WriteLine($"MarbleRun FAILED: {ex}"); + return 1; + } + + if (selftest) + { + Console.WriteLine(s_failures == 0 ? "MARBLE-RUN-OK" : $"MarbleRun: {s_failures} FAILURES"); + } + + return s_failures == 0 ? 0 : 1; + } + + private static void Run(string libPath, bool selftest, int frames) + { + var table = LoadApiTable(libPath); + CheckLayout(); + + var world = table->EngineWorldCreate(); + Check(world != null, "world_create"); + if (world == null) + { + return; + } + + try + { + BuildCourse(table, world); + var marble = SpawnMarble(table, world); + var goal = SpawnGoal(table, world); + + // Startup runs once on the first frame; register before it. + Check(MarbleController.Register(table, world, null) >= 0, "MarbleController registered"); + Input.LoadActionMaps(AppContext.BaseDirectory); + + if (selftest) + { + RunSelftest(table, world, marble, goal, frames); + return; + } + + RunVisual(table, world, marble, goal, frames); + } + finally + { + Time.Unbind(); + table->EngineWorldDestroy(world); + } + } + + // --- course --------------------------------------------------------- + + private static void BuildCourse(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + foreach (var b in MarbleGame.Boxes) + { + var slab = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, slab, LocalTransform.IdentityAt(b.Cx, -0.5f, b.Cz)); + Ecs.Set(table, world, slab, new RigidBody { Mass = 0, MotionType = 0, LinearDamping = 0, AngularDamping = 0 }); + var box = new BoxCollider(); + box.HalfExtents[0] = b.ExtentX * 0.5f; + box.HalfExtents[1] = 0.5f; + box.HalfExtents[2] = b.ExtentZ * 0.5f; + Ecs.Set(table, world, slab, box); + } + + Check(true, $"course slabs built ({MarbleGame.Boxes.Length})"); + } + + private static Entity SpawnMarble(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + var marble = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, marble, MarbleGame.StartPose()); + Ecs.Set(table, world, marble, new RigidBody { Mass = 1.0f, MotionType = 1, LinearDamping = 0.0f, AngularDamping = 0.0f }); + Ecs.Set(table, world, marble, new SphereCollider { Radius = 0.5f }); + Ecs.Set(table, world, marble, new Marble { Speed = MarbleSpeed }); + Ecs.Set(table, world, marble, new MarbleController()); + return marble; + } + + private static Entity SpawnGoal(NativeApi.ApiTable* table, NativeApi.EngineWorld* world) + { + var goal = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, goal, LocalTransform.IdentityAt(MarbleGame.GoalX, MarbleGame.GoalY, 0.0f)); + Ecs.Set(table, world, goal, new RigidBody { Mass = 0, MotionType = 0, LinearDamping = 0, AngularDamping = 0, IsSensor = 1 }); + var box = new BoxCollider(); + box.HalfExtents[0] = MarbleGame.GoalHalf; + box.HalfExtents[1] = MarbleGame.GoalHalf; + box.HalfExtents[2] = MarbleGame.GoalHalf; + Ecs.Set(table, world, goal, box); + return goal; + } + + // --- headless selftest ---------------------------------------------- + + private static void RunSelftest(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity marble, Entity goal, int frames) + { + var physWorld = table->EnginePhysicsWorldFor(world); + Check(physWorld != null, "physics_world_for non-null"); + if (physWorld == null) + { + return; + } + + using var physics = new Physics((IntPtr)physWorld); + Physics.TableOverride = (IntPtr)table; + + // Phase 1: respawn. Drop the marble below the floor and check one + // fixed step through the controller snaps it back to start. Drive + // must run before the frame so the respawned pose is pushed to + // Jolt in the same step (kinematic links push pre-step). + var dropped = Ecs.Get(table, world, marble); + dropped.Py = -11.0f; + Ecs.Set(table, world, marble, dropped); + MarbleInput.X = 0.0f; + MarbleInput.Z = 0.0f; + var rc0 = table->EngineWorldFrame(world, 1.0 / 50.0); + Check(rc0 >= 0, "world_frame advances (respawn step)"); + Drive(table, world, marble); + CheckPoseNear(table, world, marble, 0.0f, MarbleGame.RestY, 0.0f, "respawn at start after y < -10"); + + // Reset the clock: respawn must not consume the finish time. + var m = Ecs.Get(table, world, marble); + m.Elapsed = 0.0f; + m.VelY = 0.0f; + m.Finished = 0; + Ecs.Set(table, world, marble, m); + s_finished = false; + + // Phase 2: goal. Step the fixed-stride clock until the sensor kEnter + // fires or the frame budget runs out. + _ = physics.CollisionEvents; + var goalStep = -1; + for (var i = 0; i < frames && !s_finished; i++) + { + Drive(table, world, marble); + var rc = table->EngineWorldFrame(world, 1.0 / 50.0); + if (rc < 0) + { + Check(false, $"world_frame failed at step {i} (rc={rc})"); + return; + } + + if (i % 250 == 0) + { + var dbg = Ecs.Get(table, world, marble); + Console.WriteLine($"[info] step {i}: pos=({dbg.Px:F2},{dbg.Py:F2},{dbg.Pz:F2})"); + } + + var span = physics.CollisionEvents; + for (var e = 0; e < span.Length; e++) + { + ref readonly var ev = ref span[e]; + if (!ev.IsEnter || !ev.IsSensor) + { + continue; + } + + if ((ev.EntityA == marble.Id && ev.EntityB == goal.Id) || + (ev.EntityA == goal.Id && ev.EntityB == marble.Id)) + { + goalStep = i; + Finish(Ecs.Get(table, world, marble).Elapsed); + break; + } + } + } + + Check(goalStep >= 0, $"goal sensor kEnter within {frames} frames (step {goalStep})"); + Check(s_finished, "Finished in printed on goal"); + } + + // --- visual demo ---------------------------------------------------- + + private static void RunVisual(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity marble, Entity goal, int frames) + { + Renderer createdRenderer; + try + { + createdRenderer = Renderer.Create(table, world, Width, Height, hidden: true); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("renderer_create")) + { + var required = Environment.GetEnvironmentVariable("ENGINE_REQUIRE_GPU") == "1"; + Console.WriteLine(required + ? "[FAIL] marble-run: no Vulkan device (ENGINE_REQUIRE_GPU=1)" + : "[SKIP] marble-run: no Vulkan device on this runner"); + s_failures += required ? 1 : 0; + return; + } + + using var renderer = createdRenderer; + Check(true, "renderer_create (hidden 640x360)"); + + uint cubeId; + try + { + var (verts, idx) = MarbleRun.UnitCube.Build(); + cubeId = renderer.MeshCreate(verts, 24, idx); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("renderer_mesh_create")) + { + var required = Environment.GetEnvironmentVariable("ENGINE_REQUIRE_GPU") == "1"; + Console.WriteLine(required + ? "[FAIL] marble-run: no GPU mesh pipeline (ENGINE_REQUIRE_GPU=1)" + : "[SKIP] marble-run: no GPU mesh pipeline on this runner"); + s_failures += required ? 1 : 0; + return; + } + + Check(cubeId != 0, "cube uploaded (24v/36i)"); + PaintCourse(table, world, cubeId); + MarbleRun.MarbleVisuals.Tint(table, world, marble, cubeId, 0.75f, 0.15f, 0.12f); + MarbleRun.MarbleVisuals.AddVisual(table, world, cubeId, + MarbleRun.MarbleGame.GoalX, MarbleRun.MarbleGame.GoalY, 0.0f, + MarbleRun.MarbleGame.GoalHalf * 2.0f, MarbleRun.MarbleGame.GoalHalf * 2.0f, MarbleRun.MarbleGame.GoalHalf * 2.0f, + 0.90f, 0.90f, 0.85f); + + var cam = Ecs.CreateEntity(table, world); + Ecs.Set(table, world, cam, new Camera { FovYRad = 60f * MathF.PI / 180f, NearZ = 0.1f, FarZ = 200f, IsMain = 1 }); + Ecs.Set(table, world, cam, LookAtWorld(11, 9, 12, 11, 0, 0)); + + var light = Ecs.CreateEntity(table, world); + unsafe + { + var l = new DirectionalLight { Intensity = 1.0f }; + var len = MathF.Sqrt(0.25f + 0.81f + 0.4225f); + l.Direction[0] = -0.5f / len; + l.Direction[1] = -0.9f / len; + l.Direction[2] = -0.65f / len; + Ecs.Set(table, world, light, l); + } + + var physWorld = table->EnginePhysicsWorldFor(world); + Physics.TableOverride = (IntPtr)table; + using var physics = physWorld != null ? new Physics((IntPtr)physWorld) : null; + + Check(table->EngineWorldPropagate(world) == 0, "world_propagate (pre)"); + + var closed = false; + for (var f = 0; f < frames && !closed && !s_finished; f++) + { + Drive(table, world, marble); + Time.Frame(table, world, 1.0 / 60.0); + Check(table->EngineWorldPropagate(world) == 0, "world_propagate"); + closed = renderer.Frame(); + + if (physics != null) + { + var span = physics.CollisionEvents; + for (var e = 0; e < span.Length; e++) + { + ref readonly var ev = ref span[e]; + if (!ev.IsEnter || !ev.IsSensor) + { + continue; + } + + if ((ev.EntityA == marble.Id && ev.EntityB == goal.Id) || + (ev.EntityA == goal.Id && ev.EntityB == marble.Id)) + { + Finish(Ecs.Get(table, world, marble).Elapsed); + break; + } + } + } + } + + Check(!closed, $"renderer: frames without close"); + Check(s_finished, "Finished in printed on goal"); + } + + // --- per-frame drive ------------------------------------------------ + + private static void Drive(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity marble) + { + var local = Ecs.Get(table, world, marble); + + // Live input (WASD/arrows/gamepad) blends over the scripted demo + // drive; when nothing is held the scripted drive stands alone so + // the course completes unattended. + var hx = Input.GetAxis("Horizontal"); + var vz = Input.GetAxis("Vertical"); + MarbleGame.DriveFor(local.Px, local.Pz, out var dx, out var dz); + dx += hx; + dz -= vz; + + MarbleInput.X = dx; + MarbleInput.Z = dz; + + // Falling below y = -10 respawns at start (clock keeps running). + if (local.Py < MarbleGame.RespawnY) + { + local = MarbleGame.StartPose(); + Ecs.Set(table, world, marble, local); + var st = Ecs.Get(table, world, marble); + st.VelY = 0.0f; + Ecs.Set(table, world, marble, st); + } + } + + private static void Finish(float seconds) + { + if (s_finished) + { + return; + } + + s_finished = true; + s_finishTime = seconds; + Console.WriteLine($"Finished in {seconds:F1} s"); + } + + private static void CheckPoseNear(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, Entity marble, float x, float y, float z, string name) + { + var local = Ecs.Get(table, world, marble); + var ok = MathF.Abs(local.Px - x) <= 0.05f && + MathF.Abs(local.Py - y) <= 0.05f && + MathF.Abs(local.Pz - z) <= 0.05f; + if (!ok) + { + Console.Error.WriteLine($"[info] pose=({local.Px:F3},{local.Py:F3},{local.Pz:F3}) want=({x},{y},{z})"); + } + + Check(ok, name); + } + + private static void PaintCourse(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, uint cubeId) + { + if (table == null || world == null || cubeId == 0) + { + return; + } + + // Visuals mirror the physics slabs: one scaled unit cube per slab. + // Shared MarbleVisuals helper keeps slab/marble/goal tinting in one + // place (CodeRabbit round 2: the old stub rendered nothing while + // the comment claimed mirrored visuals). + foreach (var b in MarbleRun.MarbleGame.Boxes) + { + MarbleRun.MarbleVisuals.AddVisual(table, world, cubeId, + b.Cx, -0.5f, b.Cz, b.ExtentX, 1.0f, b.ExtentZ, + 0.82f, 0.78f, 0.70f); + } + } + + // --- helpers (HelloCube conventions) -------------------------------- + + private static WorldTransform LookAtWorld(float ex, float ey, float ez, float cx, float cy, float cz) + { + var fx = cx - ex; + var fy = cy - ey; + var fz = cz - ez; + var fl = MathF.Sqrt(fx * fx + fy * fy + fz * fz); + fx /= fl; + fy /= fl; + fz /= fl; + var sx = fy * 0.0f - fz * 1.0f; + var sy = fz * 0.0f - fx * 0.0f; + var sz = fx * 1.0f - fy * 0.0f; + var sl = MathF.Sqrt(sx * sx + sy * sy + sz * sz); + sx /= sl; + sy /= sl; + sz /= sl; + var ux = sy * fz - sz * fy; + var uy = sz * fx - sx * fz; + var uz = sx * fy - sy * fx; + var w = new WorldTransform(); + w[0] = sx; + w[1] = ux; + w[2] = -fx; + w[3] = 0.0f; + w[4] = sy; + w[5] = uy; + w[6] = -fy; + w[7] = 0.0f; + w[8] = sz; + w[9] = uz; + w[10] = -fz; + w[11] = 0.0f; + w[12] = ex; + w[13] = ey; + w[14] = ez; + w[15] = 1.0f; + return w; + } + + private static void CheckLayout() + { + unsafe + { + Check(sizeof(MarbleRun.Marble) == 16, "Marble 16B"); + Check(sizeof(MarbleRun.MarbleController) == 1, "MarbleController 1B"); + Check(sizeof(Camera) == 16, "Camera 16B"); + Check(sizeof(MeshRenderer) == 20, "MeshRenderer 20B"); + Check(sizeof(DirectionalLight) == 16, "DirectionalLight 16B"); + Check(sizeof(LocalTransform) == 64, "LocalTransform mirror 64B"); + Check(sizeof(WorldTransform) == 64, "WorldTransform mirror 64B"); + } + + Check(Layout.AlignOf() == 4, "AlignOf == 4"); + } + + private static NativeApi.ApiTable* LoadApiTable(string libPath) + { + var handle = NativeLibrary.Load(Path.GetFullPath(libPath)); + if (!NativeLibrary.TryGetExport(handle, "engine_get_api_table", out var addr)) + { + throw new EntryPointNotFoundException("engine_get_api_table not exported."); + } + + var table = ((delegate* unmanaged[Cdecl])addr)(); + if (table == null || table->Size < (ulong)sizeof(NativeApi.ApiTable)) + { + throw new InvalidOperationException("engine_get_api_table returned null or a table older than the bindings."); + } + + if (table->AbiVersionMajor != NativeApi.AbiVersionMajor || table->AbiVersionMinor != NativeApi.AbiVersionMinor) + { + throw new InvalidOperationException($"ABI mismatch: native {table->AbiVersionMajor}.{table->AbiVersionMinor} != managed {NativeApi.AbiVersionMajor}.{NativeApi.AbiVersionMinor}."); + } + + return table; + } + + private static void Check(bool ok, string name) + { + Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}"); + if (!ok) + { + s_failures++; + } + } +} diff --git a/samples/MarbleRun/input_actions.json b/samples/MarbleRun/input_actions.json new file mode 100644 index 0000000..cbdc686 --- /dev/null +++ b/samples/MarbleRun/input_actions.json @@ -0,0 +1,14 @@ +{ + "axes": { + "Horizontal": { + "negative": [ "A", "Left" ], + "positive": [ "D", "Right" ], + "gamepadAxis": "LeftX" + }, + "Vertical": { + "negative": [ "S", "Down" ], + "positive": [ "W", "Up" ], + "gamepadAxis": "LeftY" + } + } +} diff --git a/samples/MarbleRun/project.json b/samples/MarbleRun/project.json new file mode 100644 index 0000000..d583676 --- /dev/null +++ b/samples/MarbleRun/project.json @@ -0,0 +1 @@ +{"name":"MarbleRun","gameAssembly":"bin/Release/net10.0/MarbleRun.dll"} diff --git a/samples/MarbleRun/test/autoplay.json b/samples/MarbleRun/test/autoplay.json new file mode 100644 index 0000000..8243e53 --- /dev/null +++ b/samples/MarbleRun/test/autoplay.json @@ -0,0 +1 @@ +{"frames": [{"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}, {"keys": []}]}