mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 10:55:58 -10:00
[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:
17 files changed
+2013
-58
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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": []}]}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"axes": {
|
||||
"Horizontal": {
|
||||
"negative": [ "A", "Left" ],
|
||||
"positive": [ "D", "Right" ],
|
||||
"gamepadAxis": "LeftX"
|
||||
},
|
||||
"Vertical": {
|
||||
"negative": [ "S", "Down" ],
|
||||
"positive": [ "W", "Up" ],
|
||||
"gamepadAxis": "LeftY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"MarbleRun","gameAssembly":"bin/Release/net10.0/MarbleRun.dll"}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user