[P2-08] Sample game "Marble Run" with autoplay test (#211)

* parity: 86 marble-run sample course with sphere and goal

* parity: 86 add --autoplay inputs.json replay to player binary

* parity: 86 player-driven marble autoplay (Boot + test/autoplay.json + engine_marble_autoplay)

MarbleRun gains Game.Bootstrap.Boot(table, world, renderer): builds the
course into the player's live world and registers FixedUpdate drive +
Update-phase FrameHost (drive blend + respawn + sensor-kEnter drain).
The player prefers Boot over legacy void Main and keeps the host alive
for the run. test/autoplay.json (1500 empty-key frames) exercises the
event-fed input path while the scripted drive finishes in 3.6 s.
engine_marble_autoplay now drives engine-player --autoplay and greps
Finished in.

* parity: 86 offscreen GPU verification notes (build+ctest logs, banned-pattern + ABI checks)

* parity: 86 PR consolidation notes (gate, ABI race, fresh verification)

* parity: 86 resolve Sonar autoplay findings (S6232/S3776/S134/S6197/S6004, Boot/Release helpers)

* parity: 86 notes for Sonar fix push 2306c54

* parity: 86 notes CodeRabbit re-trigger rate-limited

* parity: P2-08 CodeRabbit round 2 (5 fixed, 2 skipped with reason)

---------

Co-authored-by: hermes-agent <hermes-agent@local>
Co-authored-by: Hermes <hermes@local>
This commit is contained in:
authored and GitHub committed 2026-11-04 12:39:10 -05:00
1 parent 09d48dc301
commit b62d6e386c
17 files changed
+2013 -58

No files matched your search

+1
View File
@@ -15,6 +15,7 @@
<Project Path="samples/ExportSample/ExportSample.csproj" />
<Project Path="samples/IllusionSample/IllusionSample.csproj" />
<Project Path="samples/HelloCube/HelloCube.csproj" />
<Project Path="samples/MarbleRun/MarbleRun.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/abi_codegen/abi_codegen.csproj" />
+172
View File
@@ -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 <engine_core> --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).
+6 -1
View File
@@ -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;
}
/// <summary>Bind the live native snapshot (P1-09 renderer_frame path).</summary>
+11
View File
@@ -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;
+9
View File
@@ -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
+555 -57
View File
@@ -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 <inputs.json> 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 <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <ranges>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
#if defined(ENGINE_PLAYER_HAS_SDL3)
#include <SDL3/SDL.h>
#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<int>(kScancodeLetterBase +
static_cast<std::uint32_t>(c - 'a'));
}
if (c >= 'A' && c <= 'Z') {
return static_cast<int>(kScancodeLetterBase +
static_cast<std::uint32_t>(c - 'A'));
}
return -1;
}
if (name == "Space" || name == "space" || name == "SPACE") {
return static_cast<int>(kScancodeSpace);
}
return -1;
}
struct AutoplayFrame {
std::vector<std::uint32_t> keys;
};
struct AutoplayScript {
std::vector<AutoplayFrame> 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<std::uint32_t>(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<char> 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<SDL_Scancode>(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<std::uint32_t>& want,
std::vector<std::uint32_t>& held) -> void {
#if defined(ENGINE_PLAYER_HAS_SDL3)
std::vector<std::uint32_t> 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<BootstrapBootFn>(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<BootstrapFn>(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<BootstrapFn>(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 <project_dir> [--scene <rel>] [--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 <project_dir> [--scene <rel>] "
"[--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<std::uint32_t> 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<std::size_t>(frames) > script_len) {
run_frames = static_cast<int>(script_len);
}
}
for (int f = 0; f < run_frames; ++f) {
if (use_autoplay) {
ApplyAutoplayFrame(autoplay.frames[static_cast<std::size_t>(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;
}
+37
View File
@@ -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)
$<TARGET_FILE_DIR:engine_hud_composite>
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=$<TARGET_FILE:engine-player>"
"-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)
+31
View File
@@ -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=<path> -DPROJECT_DIR=<dir> -DAUTOPLAY=<file> -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)")
@@ -0,0 +1 @@
{"frames": [{"keys": ["W"]}, {"keys": ["W"]}, {"keys": []}]}
+244
View File
@@ -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;
/// <summary>Player bootstrap: builds the course in the player's world.</summary>
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;
/// <summary>
/// UCO entry called by engine-player with the live ABI pointers.
/// Returning 0 signals success (course built); nonzero aborts boot.
/// </summary>
[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;
}
}
/// <summary>Legacy void() entry: keeps the P2-07 TryBootGame probe honest.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
}
+208
View File
@@ -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<T>() 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;
/// <summary>64B/align-64 mirror of the native LocalTransform row (global name binds the built-in).</summary>
[StructLayout(LayoutKind.Sequential)]
public struct LocalTransform : IComponent
{
/// <summary>Raw 64-byte row (lanes 0-15 = the row's 16 floats).</summary>
#pragma warning disable CA1051 // blittable interop struct by design
public Vector512<float> Vec;
#pragma warning restore CA1051
/// <summary>Position X (lane 0).</summary>
public float Px { readonly get => Vec.GetElement(0); set => Vec = Vec.WithElement(0, value); }
/// <summary>Position Y (lane 1).</summary>
public float Py { readonly get => Vec.GetElement(1); set => Vec = Vec.WithElement(1, value); }
/// <summary>Position Z (lane 2).</summary>
public float Pz { readonly get => Vec.GetElement(2); set => Vec = Vec.WithElement(2, value); }
/// <summary>Quaternion X (lane 3).</summary>
public float Qx { readonly get => Vec.GetElement(3); set => Vec = Vec.WithElement(3, value); }
/// <summary>Quaternion Y (lane 4).</summary>
public float Qy { readonly get => Vec.GetElement(4); set => Vec = Vec.WithElement(4, value); }
/// <summary>Quaternion Z (lane 5).</summary>
public float Qz { readonly get => Vec.GetElement(5); set => Vec = Vec.WithElement(5, value); }
/// <summary>Quaternion W (lane 6).</summary>
public float Qw { readonly get => Vec.GetElement(6); set => Vec = Vec.WithElement(6, value); }
/// <summary>Scale X (lane 7).</summary>
public float Sx { readonly get => Vec.GetElement(7); set => Vec = Vec.WithElement(7, value); }
/// <summary>Scale Y (lane 8).</summary>
public float Sy { readonly get => Vec.GetElement(8); set => Vec = Vec.WithElement(8, value); }
/// <summary>Scale Z (lane 9).</summary>
public float Sz { readonly get => Vec.GetElement(9); set => Vec = Vec.WithElement(9, value); }
/// <summary>Identity (unit quat, unit scale) at the given position.</summary>
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;
}
}
/// <summary>64B/align-64 mirror of the native WorldTransform row (global name binds the built-in).</summary>
[StructLayout(LayoutKind.Sequential)]
public struct WorldTransform : IComponent
{
/// <summary>Raw 64-byte row (lanes 0-15 = row-major m[0..15]).</summary>
#pragma warning disable CA1051 // blittable interop struct by design
public Vector512<float> Vec;
#pragma warning restore CA1051
/// <summary>Row-major matrix lane access.</summary>
public float this[int i]
{
readonly get => Vec.GetElement(i);
set => Vec = Vec.WithElement(i, value);
}
}
namespace MarbleRun
{
/// <summary>
/// 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.
/// </summary>
[UpdateInPhase(Phase.Update)]
public partial struct FrameHost : IComponent
{
/// <summary>Per-row update: drive blend, respawn, goal drain.</summary>
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);
}
}
/// <summary>
/// Per-frame drive vector consumed by <see cref="MarbleController"/>.
/// 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).
/// </summary>
public static class MarbleInput
{
/// <summary>Desired drive direction X (need not be normalised).</summary>
public static float X { get; set; } = 1.0f;
/// <summary>Desired drive direction Z (need not be normalised).</summary>
public static float Z { get; set; }
}
/// <summary>
/// The player sphere: kinematic body steered by pose integration.
/// 16 bytes, align 4. Blittable: whole-struct Ecs.Set/Get compatible.
/// </summary>
public partial struct Marble : IComponent
{
/// <summary>Planar speed in m/s.</summary>
public float Speed;
/// <summary>Vertical velocity in m/s (scripted gravity, reset on landing).</summary>
public float VelY;
/// <summary>Sim seconds accumulated (finish line reads this).</summary>
public float Elapsed;
/// <summary>Nonzero once the goal sensor has fired.</summary>
public int Finished;
/// <summary>
/// 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).
/// </summary>
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;
}
}
/// <summary>
/// Fixed-step controller binding: runs <see cref="Marble.Tick"/> once
/// per 1/50 s fixed step through the world clock.
/// </summary>
[UpdateInPhase(Phase.FixedUpdate)]
public partial struct MarbleController : IComponent
{
/// <summary>Per-row fixed-step update (delegates to the drive logic).</summary>
public readonly void Tick(ref MarbleController self, ref Marble marble, ref LocalTransform local, float dt)
{
_ = self;
marble.Tick(ref marble, ref local, dt);
}
}
}
+194
View File
@@ -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;
/// <summary>Shared course visuals: one unit-cube mesh, tinted per part.</summary>
internal static unsafe class MarbleVisuals
{
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Tints an existing (physics) entity's unit-scale mesh, e.g. the marble.</summary>
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);
}
}
/// <summary>Shared unit-cube mesh data (24 verts, pos+normal+uv, 36 indices).</summary>
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);
}
}
/// <summary>Axis-aligned course slab (top face at y = 0).</summary>
public readonly record struct CourseBox(float X0, float X1, float Z0, float Z1)
{
/// <summary>Centre X of the slab.</summary>
public readonly float Cx => (X0 + X1) * 0.5f;
/// <summary>Centre Z of the slab.</summary>
public readonly float Cz => (Z0 + Z1) * 0.5f;
/// <summary>Full X extent (visual cube scale).</summary>
public readonly float ExtentX => X1 - X0;
/// <summary>Full Z extent (visual cube scale).</summary>
public readonly float ExtentZ => Z1 - Z0;
}
/// <summary>Static course data shared by the host loop and the controller.</summary>
public static class MarbleGame
{
/// <summary>Marble rest height: sphere centre above a slab top.</summary>
public const float RestY = 0.5f;
/// <summary>Fall below this height and the marble respawns at start.</summary>
public const float RespawnY = -10.0f;
/// <summary>Marble spawn (drops a short way onto the start pad).</summary>
public static LocalTransform StartPose() => LocalTransform.IdentityAt(0.0f, 1.0f, 0.0f);
/// <summary>Course slabs (visual + physics share these bounds).</summary>
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),
];
/// <summary>Goal sensor centre.</summary>
public const float GoalX = 22.0f;
/// <summary>Goal sensor centre height.</summary>
public const float GoalY = 1.0f;
/// <summary>Goal sensor half extents (cube).</summary>
public const float GoalHalf = 1.5f;
/// <summary>True when (x, z) is over a slab (marble is supported).</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- P2-08 MarbleRun sample (original small game: roll a sphere down a
box-built course to a sensor goal). -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MarbleRun</RootNamespace>
<AssemblyName>MarbleRun</AssemblyName>
<StartupObject>MarbleRun.Program</StartupObject>
<Description>Roll-a-sphere sample course driven from C# through the physics ABI (P2-08).</Description>
<NoWarn>$(NoWarn);CA1051;CA1822;CA1050</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\managed\Engine.Bindings\Engine.Bindings.csproj" />
<ProjectReference Include="..\..\managed\Engine.Runtime\Engine.Runtime.csproj" />
<ProjectReference Include="..\..\managed\Engine.Analyzers\Engine.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<!-- Action maps ship next to the game assembly; Input.LoadActionMaps
picks them up, otherwise the built-in Horizontal default stands.
project.json names the gameAssembly for engine-player. -->
<None Include="input_actions.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="project.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+494
View File
@@ -0,0 +1,494 @@
// Program.cs — P2-08 MarbleRun sample: roll a sphere down a box course.
//
// dotnet MarbleRun.dll <engine_core> [--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 <absolute path to the engine_core shared library> [--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<LocalTransform>(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<MarbleRun.Marble>(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<LocalTransform>(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<MarbleRun.Marble>(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<MarbleRun.Marble>(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<LocalTransform>(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<MarbleRun.Marble>(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<LocalTransform>(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<MarbleRun.Marble>() == 4, "AlignOf<Marble> == 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]<NativeApi.ApiTable*>)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++;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"axes": {
"Horizontal": {
"negative": [ "A", "Left" ],
"positive": [ "D", "Right" ],
"gamepadAxis": "LeftX"
},
"Vertical": {
"negative": [ "S", "Down" ],
"positive": [ "W", "Up" ],
"gamepadAxis": "LeftY"
}
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"MarbleRun","gameAssembly":"bin/Release/net10.0/MarbleRun.dll"}
+1
View File
@@ -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": []}, {"kLine truncated