diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e6064e5..77c1b56b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,7 @@ set(ROLLER_GAME_BASE_SOURCES PROJECTS/ROLLER/polytex.c PROJECTS/ROLLER/replay.c PROJECTS/ROLLER/roller_core_error.c + PROJECTS/ROLLER/roller_runtime.c PROJECTS/ROLLER/roller.c PROJECTS/ROLLER/rollercd.c PROJECTS/ROLLER/rollerinput.c diff --git a/PROJECTS/ROLLER/3d.c b/PROJECTS/ROLLER/3d.c index 43525a2b..d43d62c5 100644 --- a/PROJECTS/ROLLER/3d.c +++ b/PROJECTS/ROLLER/3d.c @@ -23,6 +23,7 @@ #include "crashdump.h" #include "snapshot.h" #include "snapshot_scenes.h" +#include "roller_runtime.h" #include "rollerinput.h" #include "phone_ui.h" #include "touch_ui.h" @@ -109,6 +110,8 @@ static int g_iDirectTrackMalformedCount = 0; static int g_iDirectTrackReloadCycles = 0; static uint32 g_uiDirectTrackSeedBeforeLoad = 0; static uint32 g_uiDirectTrackSeedAfterLoad = 0; +static int g_bRuntimeSnapshotMode = 0; +static RollerRuntime *g_pSnapshotRuntime = NULL; //------------------------------------------------------------------------------------------------- //symbols defined by ROLLER @@ -604,6 +607,7 @@ static void print_usage(FILE *f, const char *argv0) cli_fprintf(f, " --net-slot N network slot index; use -1 to join as client\n"); cli_fprintf(f, " --no-crash-handler disable crash dump generation for this run\n"); cli_fprintf(f, " --snapshot REPLAY headless replay-capture mode (writes indexed PNGs)\n"); + cli_fprintf(f, " --runtime-snapshot drive replay snapshot ticks through RollerRuntime\n"); cli_fprintf(f, " --snapshot-scene NAME render a headless named scene snapshot\n"); cli_fprintf(f, " --frames N[,M,...] replay-frame indices to capture (--snapshot only)\n"); cli_fprintf(f, " --out DIR output directory for snapshot PNGs (--snapshot only)\n"); @@ -1003,6 +1007,16 @@ static void main_loop_iteration_wrapper(void) //------------------------------------------------------------------------------------------------- +static eRollerRuntimeResult ROLLER_RUNTIME_CALL RuntimeSnapshotAdvanceInput( + void *pUserData, uint32_t uiTickIndex) +{ + (void)pUserData; + (void)uiTickIndex; + return ROLLER_RUNTIME_RESULT_OK; +} + +//------------------------------------------------------------------------------------------------- + static void frontend_run_game_loop(eFrontendState eInitialState) { VIEWDIST = 270; @@ -1707,7 +1721,17 @@ void race_update(void) // iteration with no wall-clock pacing, and zero scrbuf so any // unredrawn region cannot leak from the previous frame. SnapshotZeroScreen(); - SnapshotAdvanceTick(); + if (g_pSnapshotRuntime) { + eRollerRuntimeResult runtimeResult = RollerRuntime_Step(g_pSnapshotRuntime, 1u); + if (runtimeResult != ROLLER_RUNTIME_RESULT_OK) { + SDL_Log("RollerRuntime snapshot step failed: %s", + RollerRuntime_GetLastError(g_pSnapshotRuntime)); + quit_game = 1; + racing = 0; + } + } else { + SnapshotAdvanceTick(); + } } else if (fadedin || replaytype == 2) { // Cap drained ticks per frame to prevent spiral-of-death: if a slow frame // backs up iTicksPending, catching up burns main-thread time and starves @@ -2918,6 +2942,9 @@ int main(int argc, const char **argv, const char **envp) cli_fprintf(stderr, "ERROR: '--snapshot' needs an argument\n"); return 1; } + } else if (strcmp(argv[i], "--runtime-snapshot") == 0) { + g_bRuntimeSnapshotMode = 1; + consumed = 1; } else if (strcmp(argv[i], "--snapshot-scene") == 0) { if (i + 1 < argc) { SnapshotSetScene(argv[i + 1]); @@ -3091,6 +3118,12 @@ int main(int argc, const char **argv, const char **envp) srand(0); ROLLERsrand(0); } + if (g_bRuntimeSnapshotMode) { + if (!g_bSnapshotMode || g_SnapshotConfig.eKind != SNAPSHOT_KIND_REPLAY) { + cli_fprintf(stderr, "ERROR: '--runtime-snapshot' requires replay '--snapshot' mode\n"); + return 1; + } + } if (iCrashHandlerEnabled) InitCrashHandler(whiplash_root); @@ -3256,7 +3289,32 @@ int main(int argc, const char **argv, const char **envp) winner_mode = 0; intro = -1; race_set_track(TrackLoad); // Start initial intro replay through the dispatcher. + if (g_bRuntimeSnapshotMode) { + tRollerRuntimeConfig runtimeConfig = { + .uiStructSize = sizeof(runtimeConfig), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + }; + tRollerRuntimeInputSource inputSource = { + .uiStructSize = sizeof(inputSource), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + .pUserData = NULL, + .pfnAdvance = RuntimeSnapshotAdvanceInput, + }; + eRollerRuntimeResult runtimeResult = RollerRuntime_New( + &runtimeConfig, &g_pSnapshotRuntime); + if (runtimeResult == ROLLER_RUNTIME_RESULT_OK) + runtimeResult = RollerRuntime_SetInputSource(g_pSnapshotRuntime, &inputSource); + if (runtimeResult != ROLLER_RUNTIME_RESULT_OK) { + cli_fprintf(stderr, "ERROR: failed to initialize RollerRuntime snapshot driver: %s\n", + RollerRuntime_GetLastError(g_pSnapshotRuntime)); + RollerRuntime_Delete(g_pSnapshotRuntime); + g_pSnapshotRuntime = NULL; + return 1; + } + } frontend_run_game_loop(g_bSnapshotMode ? eFRONTEND_STATE_RACING : eFRONTEND_STATE_COPYRIGHT); + RollerRuntime_Delete(g_pSnapshotRuntime); + g_pSnapshotRuntime = NULL; //__asm { int 10h; Reset video mode and exit game }// Reset video mode and exit game if (!frontend_shutdown_complete()) doexit(); diff --git a/PROJECTS/ROLLER/roller_runtime.c b/PROJECTS/ROLLER/roller_runtime.c new file mode 100644 index 00000000..10f41ec6 --- /dev/null +++ b/PROJECTS/ROLLER/roller_runtime.c @@ -0,0 +1,244 @@ +#include "roller_runtime.h" + +#include +#include +#include +#include + +#if defined(ROLLER_RUNTIME_TEST_SEAMS) +#if defined(ROLLER_RUNTIME_TEST_SEAMS_DEFAULTS) +static int g_runtime_test_frontend_on; +static void runtime_test_tick_clock_step(void) {} +static void runtime_test_game_tick_step(void) {} +static void runtime_test_clear_pending_ticks(void) {} +#else +extern int g_runtime_test_frontend_on; +void runtime_test_tick_clock_step(void); +void runtime_test_game_tick_step(void); +void runtime_test_clear_pending_ticks(void); +#endif +#define RUNTIME_FRONTEND_ON() (g_runtime_test_frontend_on) +#define RUNTIME_TICK_CLOCK_STEP() runtime_test_tick_clock_step() +#define RUNTIME_GAME_TICK_STEP() runtime_test_game_tick_step() +#define RUNTIME_CLEAR_PENDING_TICKS() runtime_test_clear_pending_ticks() +#else +#include "frontend.h" +#include "roller.h" +#include "sound.h" +#include +#define RUNTIME_FRONTEND_ON() (frontend_on) +#define RUNTIME_TICK_CLOCK_STEP() tick_clock_step() +#define RUNTIME_GAME_TICK_STEP() game_tick_step() +#define RUNTIME_CLEAR_PENDING_TICKS() SDL_SetAtomicInt(&iTicksPending, 0) +#endif + +static void runtime_clear_error(RollerRuntime *pRuntime) +{ + if (pRuntime) + pRuntime->szLastError[0] = '\0'; +} + +static void runtime_set_error(RollerRuntime *pRuntime, const char *szFormat, ...) +{ + if (!pRuntime) + return; + + va_list args; + va_start(args, szFormat); + vsnprintf(pRuntime->szLastError, sizeof(pRuntime->szLastError), szFormat, args); + va_end(args); + pRuntime->szLastError[sizeof(pRuntime->szLastError) - 1u] = '\0'; +} + +static eRollerRuntimeResult runtime_validate_struct(uint32_t uiStructSize, + uint32_t uiVersion, + uint32_t uiRequiredSize) +{ + if (uiVersion != ROLLER_RUNTIME_API_VERSION) + return ROLLER_RUNTIME_RESULT_INVALID_VERSION; + if (uiStructSize < uiRequiredSize) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + return ROLLER_RUNTIME_RESULT_OK; +} + +static int runtime_is_usable(const RollerRuntime *pRuntime) +{ + if (!pRuntime) + return 0; + return pRuntime->eStatus == ROLLER_RUNTIME_STATUS_CREATED || + pRuntime->eStatus == ROLLER_RUNTIME_STATUS_READY || + pRuntime->eStatus == ROLLER_RUNTIME_STATUS_RUNNING; +} + +static eRollerRuntimeResult runtime_validate_config( + const tRollerRuntimeConfig *pConfig) +{ + if (!pConfig) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + return runtime_validate_struct(pConfig->uiStructSize, pConfig->uiVersion, + sizeof(*pConfig)); +} + +static RollerRuntime runtime_make_failed(const char *szMessage) +{ + RollerRuntime Runtime; + + memset(&Runtime, 0, sizeof(Runtime)); + Runtime.eStatus = ROLLER_RUNTIME_STATUS_FAILED; + runtime_set_error(&Runtime, "%s", szMessage); + return Runtime; +} + +RollerRuntime ROLLER_RUNTIME_CALL +RollerRuntime_Create(const tRollerRuntimeConfig *pConfig) +{ + eRollerRuntimeResult eResult; + RollerRuntime Runtime; + + eResult = runtime_validate_config(pConfig); + if (eResult == ROLLER_RUNTIME_RESULT_INVALID_VERSION) + return runtime_make_failed("runtime config version is not supported"); + if (eResult != ROLLER_RUNTIME_RESULT_OK) + return runtime_make_failed("runtime config is invalid"); + + memset(&Runtime, 0, sizeof(Runtime)); + Runtime.eStatus = ROLLER_RUNTIME_STATUS_CREATED; + return Runtime; +} + +eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_New(const tRollerRuntimeConfig *pConfig, + RollerRuntime **ppRuntime) +{ + eRollerRuntimeResult eResult; + RollerRuntime *pRuntime; + + if (!ppRuntime) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + *ppRuntime = NULL; + + eResult = runtime_validate_config(pConfig); + if (eResult != ROLLER_RUNTIME_RESULT_OK) + return eResult; + + pRuntime = (RollerRuntime *)malloc(sizeof(*pRuntime)); + if (!pRuntime) + return ROLLER_RUNTIME_RESULT_OUT_OF_MEMORY; + + *pRuntime = RollerRuntime_Create(pConfig); + *ppRuntime = pRuntime; + return ROLLER_RUNTIME_RESULT_OK; +} + +void ROLLER_RUNTIME_CALL RollerRuntime_Destroy(RollerRuntime *pRuntime) +{ + if (!pRuntime) + return; + memset(pRuntime, 0, sizeof(*pRuntime)); +} + +void ROLLER_RUNTIME_CALL RollerRuntime_Delete(RollerRuntime *pRuntime) +{ + if (!pRuntime) + return; + RollerRuntime_Destroy(pRuntime); + free(pRuntime); +} + +eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_SetInputSource(RollerRuntime *pRuntime, + const tRollerRuntimeInputSource *pSource) +{ + eRollerRuntimeResult eResult; + + if (!pRuntime || !pSource) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + runtime_clear_error(pRuntime); + if (!runtime_is_usable(pRuntime)) { + runtime_set_error(pRuntime, "runtime is not initialized"); + return ROLLER_RUNTIME_RESULT_INVALID_STATE; + } + + + eResult = runtime_validate_struct(pSource->uiStructSize, pSource->uiVersion, + sizeof(*pSource)); + if (eResult != ROLLER_RUNTIME_RESULT_OK) + return eResult; + if (!pSource->pfnAdvance) { + runtime_set_error(pRuntime, "input source requires pfnAdvance"); + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + } + + pRuntime->InputSource = *pSource; + pRuntime->iHasInputSource = 1; + pRuntime->eStatus = ROLLER_RUNTIME_STATUS_READY; + return ROLLER_RUNTIME_RESULT_OK; +} + +eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_ClearInputSource(RollerRuntime *pRuntime) +{ + if (!pRuntime) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + runtime_clear_error(pRuntime); + if (!runtime_is_usable(pRuntime)) { + runtime_set_error(pRuntime, "runtime is not initialized"); + return ROLLER_RUNTIME_RESULT_INVALID_STATE; + } + + memset(&pRuntime->InputSource, 0, sizeof(pRuntime->InputSource)); + pRuntime->iHasInputSource = 0; + pRuntime->eStatus = ROLLER_RUNTIME_STATUS_CREATED; + return ROLLER_RUNTIME_RESULT_OK; +} + +eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_Step(RollerRuntime *pRuntime, uint32_t uiTicks) +{ + if (!pRuntime) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + runtime_clear_error(pRuntime); + if (!runtime_is_usable(pRuntime)) { + runtime_set_error(pRuntime, "runtime is not initialized"); + return ROLLER_RUNTIME_RESULT_INVALID_STATE; + } + + if (uiTicks == 0u) { + runtime_set_error(pRuntime, "step tick count must be greater than zero"); + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + } + if (!pRuntime->iHasInputSource) { + runtime_set_error(pRuntime, "runtime requires an input source before stepping"); + return ROLLER_RUNTIME_RESULT_INVALID_STATE; + } + + for (uint32_t i = 0; i < uiTicks; ++i) { + eRollerRuntimeResult eAdvance = pRuntime->InputSource.pfnAdvance( + pRuntime->InputSource.pUserData, i); + if (eAdvance != ROLLER_RUNTIME_RESULT_OK) { + runtime_set_error(pRuntime, "input source advance failed with result %u", + (unsigned)eAdvance); + pRuntime->eStatus = ROLLER_RUNTIME_STATUS_FAILED; + return ROLLER_RUNTIME_RESULT_STEP_FAILED; + } + RUNTIME_TICK_CLOCK_STEP(); + RUNTIME_CLEAR_PENDING_TICKS(); + if (!RUNTIME_FRONTEND_ON()) + RUNTIME_GAME_TICK_STEP(); + } + + pRuntime->eStatus = ROLLER_RUNTIME_STATUS_RUNNING; + return ROLLER_RUNTIME_RESULT_OK; +} + +eRollerRuntimeStatus ROLLER_RUNTIME_CALL +RollerRuntime_GetStatus(const RollerRuntime *pRuntime) +{ + return pRuntime ? pRuntime->eStatus : ROLLER_RUNTIME_STATUS_EMPTY; +} + +const char *ROLLER_RUNTIME_CALL +RollerRuntime_GetLastError(const RollerRuntime *pRuntime) +{ + return pRuntime ? pRuntime->szLastError : ""; +} diff --git a/PROJECTS/ROLLER/roller_runtime.h b/PROJECTS/ROLLER/roller_runtime.h new file mode 100644 index 00000000..b6044b7d --- /dev/null +++ b/PROJECTS/ROLLER/roller_runtime.h @@ -0,0 +1,138 @@ +#ifndef ROLLER_RUNTIME_H +#define ROLLER_RUNTIME_H + +#include +#include + +#if defined(_WIN32) +# if defined(ROLLER_RUNTIME_BUILD_SHARED) +# if defined(ROLLER_RUNTIME_EXPORTS) +# define ROLLER_RUNTIME_API __declspec(dllexport) +# else +# define ROLLER_RUNTIME_API __declspec(dllimport) +# endif +# else +# define ROLLER_RUNTIME_API +# endif +# define ROLLER_RUNTIME_CALL __cdecl +#elif defined(__GNUC__) || defined(__clang__) +# define ROLLER_RUNTIME_API __attribute__((visibility("default"))) +# define ROLLER_RUNTIME_CALL +#else +# define ROLLER_RUNTIME_API +# define ROLLER_RUNTIME_CALL +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +/* + * SDL-free fixed-step ROLLER simulation runtime. + * + * RollerRuntime owns no replay files, file-format parsing, rendering resources, + * or SDL objects. Replay/tools/editor hosts feed input/timeline data through an + * input source, step the runtime, then render from the existing game state via + * separate renderer APIs. + */ +#define ROLLER_RUNTIME_API_VERSION 2u + +typedef uint32_t eRollerRuntimeResult; +enum +{ + ROLLER_RUNTIME_RESULT_OK = 0u, + ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT = 1u, + ROLLER_RUNTIME_RESULT_INVALID_VERSION = 2u, + ROLLER_RUNTIME_RESULT_OUT_OF_MEMORY = 3u, + ROLLER_RUNTIME_RESULT_INVALID_STATE = 4u, + ROLLER_RUNTIME_RESULT_STEP_FAILED = 5u, +}; + +typedef uint32_t eRollerRuntimeStatus; +enum +{ + ROLLER_RUNTIME_STATUS_EMPTY = 0u, + ROLLER_RUNTIME_STATUS_CREATED = 1u, + ROLLER_RUNTIME_STATUS_READY = 2u, + ROLLER_RUNTIME_STATUS_RUNNING = 3u, + ROLLER_RUNTIME_STATUS_FINISHED = 4u, + ROLLER_RUNTIME_STATUS_FAILED = 5u, +}; + +typedef eRollerRuntimeResult (ROLLER_RUNTIME_CALL *RollerRuntimeInputAdvanceFn)( + void *pUserData, uint32_t uiTickIndex); + +/* + * Config/input-source structs include uiStructSize and uiVersion so callers can + * be rejected cleanly if they were compiled against a different runtime API. + * Set uiStructSize to sizeof(the struct being passed) and uiVersion to + * ROLLER_RUNTIME_API_VERSION. + */ +typedef struct +{ + uint32_t uiStructSize; + uint32_t uiVersion; +} tRollerRuntimeConfig; + +typedef struct +{ + uint32_t uiStructSize; + uint32_t uiVersion; + void *pUserData; + RollerRuntimeInputAdvanceFn pfnAdvance; +} tRollerRuntimeInputSource; + +/* + * Runtime state is public so embedders can place it on the stack or inside + * their own allocation systems. Treat the fields as private: initialize with + * RollerRuntime_Create(), mutate through the RollerRuntime_* functions, and + * finish with RollerRuntime_Destroy(). Recompile consumers when + * ROLLER_RUNTIME_API_VERSION changes; the visible struct layout is API, not a + * persistent save-data format. + */ +typedef struct RollerRuntime +{ + eRollerRuntimeStatus eStatus; + tRollerRuntimeInputSource InputSource; + int iHasInputSource; + char szLastError[512]; +} RollerRuntime; + +/* Creates a caller-owned runtime value. No heap allocation is performed. */ +ROLLER_RUNTIME_API RollerRuntime ROLLER_RUNTIME_CALL +RollerRuntime_Create(const tRollerRuntimeConfig *pConfig); + +/* Allocates a runtime with malloc(), then initializes it with RollerRuntime_Create(). */ +ROLLER_RUNTIME_API eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_New(const tRollerRuntimeConfig *pConfig, + RollerRuntime **ppRuntime); + +/* Releases runtime-owned references but does not free the RollerRuntime object. */ +ROLLER_RUNTIME_API void ROLLER_RUNTIME_CALL +RollerRuntime_Destroy(RollerRuntime *pRuntime); + +/* Calls RollerRuntime_Destroy(), then frees a runtime allocated by RollerRuntime_New(). */ +ROLLER_RUNTIME_API void ROLLER_RUNTIME_CALL +RollerRuntime_Delete(RollerRuntime *pRuntime); + +ROLLER_RUNTIME_API eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_SetInputSource(RollerRuntime *pRuntime, + const tRollerRuntimeInputSource *pSource); + +ROLLER_RUNTIME_API eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_ClearInputSource(RollerRuntime *pRuntime); + +ROLLER_RUNTIME_API eRollerRuntimeResult ROLLER_RUNTIME_CALL +RollerRuntime_Step(RollerRuntime *pRuntime, uint32_t uiTicks); + +ROLLER_RUNTIME_API eRollerRuntimeStatus ROLLER_RUNTIME_CALL +RollerRuntime_GetStatus(const RollerRuntime *pRuntime); + +ROLLER_RUNTIME_API const char *ROLLER_RUNTIME_CALL +RollerRuntime_GetLastError(const RollerRuntime *pRuntime); + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/build.zig b/build.zig index cd2aa61f..9c0ada0d 100644 --- a/build.zig +++ b/build.zig @@ -118,6 +118,7 @@ pub fn build(b: *std.Build) void { "PROJECTS/ROLLER/polytex.c", "PROJECTS/ROLLER/replay.c", "PROJECTS/ROLLER/roller_core_error.c", + "PROJECTS/ROLLER/roller_runtime.c", "PROJECTS/ROLLER/roller.c", "PROJECTS/ROLLER/rollercd.c", "PROJECTS/ROLLER/rollerinput.c", @@ -317,8 +318,11 @@ pub fn build(b: *std.Build) void { }); run_step.dependOn(&wildmidi_config_install.step); + const test_step = b.step("test", "Run focused unit tests and optional seam checks"); + configureRollerRuntimeApiTests(b, target, optimize, c_flags, test_step); + configureRollerRuntimeStepTests(b, target, optimize, c_flags, test_step); configureRenderQueue3DTests( - b, target, optimize, c_flags, python_checks, assets_path, + b, target, optimize, c_flags, python_checks, assets_path, test_step, ); // Snapshot regression harness: drive the snapshot binary serially across @@ -333,6 +337,77 @@ pub fn build(b: *std.Build) void { configureE1S4DocumentAssetTests(b, exe, assets_path); } +fn configureRollerRuntimeApiTests( + b: *Build, + target: ResolvedTarget, + optimize: OptimizeMode, + c_flags: []const []const u8, + test_step: *Step, +) void { + const runtime_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }); + runtime_mod.addIncludePath(b.path("PROJECTS/ROLLER")); + runtime_mod.addCSourceFiles(.{ + .flags = c_flags, + .files = &.{ + "PROJECTS/ROLLER/roller_runtime.c", + "tests/roller_runtime_api_test.c", + }, + }); + runtime_mod.addCMacro("ROLLER_RUNTIME_TEST_SEAMS", "1"); + runtime_mod.addCMacro("ROLLER_RUNTIME_TEST_SEAMS_DEFAULTS", "1"); + + const runtime_test = b.addExecutable(.{ + .name = "roller_runtime_api_test", + .root_module = runtime_mod, + }); + const run_runtime_test = b.addRunArtifact(runtime_test); + const runtime_tests = b.step( + "test-roller-runtime-api", + "Run RollerRuntime public API lifecycle tests", + ); + runtime_tests.dependOn(&run_runtime_test.step); + test_step.dependOn(runtime_tests); +} + +fn configureRollerRuntimeStepTests( + b: *Build, + target: ResolvedTarget, + optimize: OptimizeMode, + c_flags: []const []const u8, + test_step: *Step, +) void { + const runtime_step_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }); + runtime_step_mod.addIncludePath(b.path("PROJECTS/ROLLER")); + runtime_step_mod.addCSourceFiles(.{ + .flags = c_flags, + .files = &.{ + "PROJECTS/ROLLER/roller_runtime.c", + "tests/roller_runtime_step_test.c", + }, + }); + runtime_step_mod.addCMacro("ROLLER_RUNTIME_TEST_SEAMS", "1"); + + const runtime_step_test = b.addExecutable(.{ + .name = "roller_runtime_step_test", + .root_module = runtime_step_mod, + }); + const run_runtime_step_test = b.addRunArtifact(runtime_step_test); + const runtime_step_tests = b.step( + "test-roller-runtime-step", + "Run RollerRuntime fixed-step adapter tests", + ); + runtime_step_tests.dependOn(&run_runtime_step_test.step); + test_step.dependOn(runtime_step_tests); +} + fn configureRenderQueue3DTests( b: *Build, target: ResolvedTarget, @@ -340,6 +415,7 @@ fn configureRenderQueue3DTests( c_flags: []const []const u8, python_checks: bool, assets_path: LazyPath, + test_step: *Step, ) void { const test_mod = b.createModule(.{ .target = target, @@ -546,7 +622,6 @@ fn configureRenderQueue3DTests( ); tick_clock_tests.dependOn(&run_tick_clock.step); - const test_step = b.step("test", "Run focused unit tests and optional seam checks"); test_step.dependOn(render_queue_tests); test_step.dependOn(tick_clock_tests); @@ -952,6 +1027,33 @@ const snapshot_scenes = [_]SnapshotScene{ .{ .name = "time-trials", .frames = "1" }, }; +fn addSnapshotReplayRuns( + b: *Build, + roller_exe: *Compile, + assets_path: LazyPath, + out_abs: []const u8, + runtime_snapshot: bool, + prev_run: *?*Step, +) void { + for (snapshot_replays) |replay| { + const run_capture = b.addRunArtifact(roller_exe); + run_capture.addArg("--no-crash-handler"); + run_capture.addArg("--whiplash-root"); + run_capture.addDirectoryArg(assets_path); + run_capture.addArg("--snapshot"); + run_capture.addArg(b.fmt("{s}.gss", .{replay.name})); + if (runtime_snapshot) + run_capture.addArg("--runtime-snapshot"); + run_capture.addArg("--frames"); + run_capture.addArg(replay.frames); + run_capture.addArg("--out"); + run_capture.addArg(out_abs); + run_capture.has_side_effects = true; + if (prev_run.*) |p| run_capture.step.dependOn(p); + prev_run.* = &run_capture.step; + } +} + fn configureSnapshotTests( b: *Build, roller_exe: *Compile, @@ -972,6 +1074,10 @@ fn configureSnapshotTests( "test-snapshots", "Run rendering snapshot regression tests across the intro replays", ); + const test_runtime_snapshots = b.step( + "test-runtime-snapshots", + "Run runtime-driven replay snapshot regression tests", + ); const assets_abs = assets_path.getPath2(b, null); const assets_available = blk: { @@ -986,6 +1092,7 @@ fn configureSnapshotTests( .{assets_abs}, )); test_snapshots.dependOn(&missing_assets.step); + test_runtime_snapshots.dependOn(&missing_assets.step); return; } @@ -994,21 +1101,7 @@ fn configureSnapshotTests( // contention on shared system probes during early init); chaining each // run through the previous one's step forces a one-at-a-time schedule. var prev_run: ?*Step = null; - for (snapshot_replays) |replay| { - const run_capture = b.addRunArtifact(roller_exe); - run_capture.addArg("--no-crash-handler"); - run_capture.addArg("--whiplash-root"); - run_capture.addDirectoryArg(assets_path); - run_capture.addArg("--snapshot"); - run_capture.addArg(b.fmt("{s}.gss", .{replay.name})); - run_capture.addArg("--frames"); - run_capture.addArg(replay.frames); - run_capture.addArg("--out"); - run_capture.addArg(out_abs); - run_capture.has_side_effects = true; - if (prev_run) |p| run_capture.step.dependOn(p); - prev_run = &run_capture.step; - } + addSnapshotReplayRuns(b, roller_exe, assets_path, out_abs, false, &prev_run); for (snapshot_scenes) |scene| { const run_capture = b.addRunArtifact(roller_exe); @@ -1032,25 +1125,43 @@ fn configureSnapshotTests( // directory against the baselines with whatever tool they prefer // (e.g. `diff -rq tests/snapshots/baselines zig-out/snapshot-scratch`). if (prev_run) |p| test_snapshots.dependOn(p); - return; + } else { + // After the captures land in the canonical baseline directory, fail the + // build if any baseline diverged from HEAD. The diff itself is what + // reviewers see in the PR (GitHub renders LFS-backed PNGs as + // side-by-side image diffs). To bless an intentional change the + // developer reruns, eyeballs the working-tree diff, and commits. + const diff_check = b.addSystemCommand(&.{ + "git", + "diff", + "--exit-code", + "--stat", + "--", + baselines_dir, + }); + diff_check.has_side_effects = true; + if (prev_run) |p| diff_check.step.dependOn(p); + test_snapshots.dependOn(&diff_check.step); } - // After the captures land in the canonical baseline directory, fail the - // build if any baseline diverged from HEAD. The diff itself is what - // reviewers see in the PR (GitHub renders LFS-backed PNGs as - // side-by-side image diffs). To bless an intentional change the - // developer reruns, eyeballs the working-tree diff, and commits. - const diff_check = b.addSystemCommand(&.{ - "git", - "diff", - "--exit-code", - "--stat", - "--", - baselines_dir, - }); - diff_check.has_side_effects = true; - if (prev_run) |p| diff_check.step.dependOn(p); - test_snapshots.dependOn(&diff_check.step); + var runtime_prev_run: ?*Step = null; + addSnapshotReplayRuns(b, roller_exe, assets_path, out_abs, true, &runtime_prev_run); + + if (scratch) { + if (runtime_prev_run) |p| test_runtime_snapshots.dependOn(p); + } else { + const runtime_diff_check = b.addSystemCommand(&.{ + "git", + "diff", + "--exit-code", + "--stat", + "--", + baselines_dir, + }); + runtime_diff_check.has_side_effects = true; + if (runtime_prev_run) |p| runtime_diff_check.step.dependOn(p); + test_runtime_snapshots.dependOn(&runtime_diff_check.step); + } } fn configureEpicFPathReloadTests( diff --git a/roller-core.srclist b/roller-core.srclist index 8f47b4e0..c3f2d5ab 100644 --- a/roller-core.srclist +++ b/roller-core.srclist @@ -60,6 +60,7 @@ KEEP|PROJECTS/ROLLER/func3.c KEEP|PROJECTS/ROLLER/userfns.c KEEP|PROJECTS/ROLLER/platform_log.c KEEP|PROJECTS/ROLLER/roller_core_error.c +KEEP|PROJECTS/ROLLER/roller_runtime.c KEEP|PROJECTS/ROLLER/editor_camera.c KEEP|PROJECTS/ROLLER/editor_api.c KEEP|PROJECTS/ROLLER/editor_legacy_scene.c diff --git a/tests/roller_runtime_api_test.c b/tests/roller_runtime_api_test.c new file mode 100644 index 00000000..8c282c43 --- /dev/null +++ b/tests/roller_runtime_api_test.c @@ -0,0 +1,95 @@ +#include "roller_runtime.h" + +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "roller_runtime_api_test failed at line %d: %s\n", \ + __LINE__, #condition); \ + return 1; \ + } \ + } while (0) + +static eRollerRuntimeResult ROLLER_RUNTIME_CALL test_advance(void *pUserData, uint32_t uiTickIndex) +{ + int *piAdvanceCount = (int *)pUserData; + if (!piAdvanceCount) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + *piAdvanceCount += (int)uiTickIndex + 1; + return ROLLER_RUNTIME_RESULT_OK; +} + +int main(void) +{ + RollerRuntime runtime; + RollerRuntime *pHeapRuntime = (RollerRuntime *)0x1; + int iAdvanceCount = 0; + tRollerRuntimeConfig config = { + .uiStructSize = sizeof(config), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + }; + tRollerRuntimeInputSource source = { + .uiStructSize = sizeof(source), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + .pUserData = &iAdvanceCount, + .pfnAdvance = test_advance, + }; + + runtime = RollerRuntime_Create(NULL); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_FAILED); + CHECK(strcmp(RollerRuntime_GetLastError(&runtime), "") != 0); + CHECK(RollerRuntime_ClearInputSource(&runtime) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + + config.uiVersion++; + runtime = RollerRuntime_Create(&config); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_FAILED); + pHeapRuntime = NULL; + CHECK(RollerRuntime_New(&config, &pHeapRuntime) == ROLLER_RUNTIME_RESULT_INVALID_VERSION); + CHECK(pHeapRuntime == NULL); + + config.uiVersion = ROLLER_RUNTIME_API_VERSION; + CHECK(RollerRuntime_New(NULL, &pHeapRuntime) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + CHECK(RollerRuntime_New(&config, NULL) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + + runtime = RollerRuntime_Create(&config); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_CREATED); + CHECK(strcmp(RollerRuntime_GetLastError(&runtime), "") == 0); + + CHECK(RollerRuntime_SetInputSource(NULL, &source) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + CHECK(RollerRuntime_SetInputSource(&runtime, NULL) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + + source.uiVersion++; + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_INVALID_VERSION); + + source.uiVersion = ROLLER_RUNTIME_API_VERSION; + source.pfnAdvance = NULL; + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + + source.pfnAdvance = test_advance; + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_OK); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_READY); + CHECK(RollerRuntime_ClearInputSource(&runtime) == ROLLER_RUNTIME_RESULT_OK); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_CREATED); + RollerRuntime_Destroy(&runtime); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_EMPTY); + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + CHECK(RollerRuntime_ClearInputSource(&runtime) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + runtime = (RollerRuntime){0}; + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + CHECK(RollerRuntime_ClearInputSource(&runtime) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + + + CHECK(RollerRuntime_New(&config, &pHeapRuntime) == ROLLER_RUNTIME_RESULT_OK); + CHECK(pHeapRuntime != NULL); + CHECK(RollerRuntime_GetStatus(pHeapRuntime) == ROLLER_RUNTIME_STATUS_CREATED); + RollerRuntime_Delete(pHeapRuntime); + RollerRuntime_Delete(NULL); + + return 0; +} diff --git a/tests/roller_runtime_step_test.c b/tests/roller_runtime_step_test.c new file mode 100644 index 00000000..6f312c04 --- /dev/null +++ b/tests/roller_runtime_step_test.c @@ -0,0 +1,93 @@ +#include "roller_runtime.h" + +#include + +int g_runtime_test_frontend_on = 0; +int g_runtime_test_tick_clock_calls = 0; +int g_runtime_test_game_tick_calls = 0; +int g_runtime_test_clear_pending_calls = 0; +int g_runtime_test_input_advance_calls = 0; +uint32_t g_runtime_test_last_tick_index = 999u; + +void runtime_test_tick_clock_step(void) +{ + g_runtime_test_tick_clock_calls++; +} + +void runtime_test_game_tick_step(void) +{ + g_runtime_test_game_tick_calls++; +} + +void runtime_test_clear_pending_ticks(void) +{ + g_runtime_test_clear_pending_calls++; +} + +static eRollerRuntimeResult ROLLER_RUNTIME_CALL input_advance(void *pUserData, uint32_t uiTickIndex) +{ + int *piAccumulator = (int *)pUserData; + if (!piAccumulator) + return ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT; + g_runtime_test_input_advance_calls++; + g_runtime_test_last_tick_index = uiTickIndex; + *piAccumulator += 1; + return ROLLER_RUNTIME_RESULT_OK; +} + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "roller_runtime_step_test failed at line %d: %s\n", \ + __LINE__, #condition); \ + return 1; \ + } \ + } while (0) + +int main(void) +{ + RollerRuntime runtime; + int iInputAccumulator = 0; + tRollerRuntimeConfig config = { + .uiStructSize = sizeof(config), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + }; + tRollerRuntimeInputSource source = { + .uiStructSize = sizeof(source), + .uiVersion = ROLLER_RUNTIME_API_VERSION, + .pUserData = &iInputAccumulator, + .pfnAdvance = input_advance, + }; + + CHECK(RollerRuntime_Step(NULL, 1) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + runtime = (RollerRuntime){0}; + CHECK(RollerRuntime_Step(&runtime, 1) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + runtime = RollerRuntime_Create(NULL); + CHECK(RollerRuntime_Step(&runtime, 1) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + runtime = RollerRuntime_Create(&config); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_CREATED); + CHECK(RollerRuntime_Step(&runtime, 1) == ROLLER_RUNTIME_RESULT_INVALID_STATE); + + CHECK(RollerRuntime_SetInputSource(&runtime, &source) == ROLLER_RUNTIME_RESULT_OK); + CHECK(RollerRuntime_Step(&runtime, 0) == ROLLER_RUNTIME_RESULT_INVALID_ARGUMENT); + CHECK(RollerRuntime_Step(&runtime, 3) == ROLLER_RUNTIME_RESULT_OK); + CHECK(g_runtime_test_input_advance_calls == 3); + CHECK(g_runtime_test_last_tick_index == 2u); + CHECK(iInputAccumulator == 3); + CHECK(g_runtime_test_tick_clock_calls == 3); + CHECK(g_runtime_test_clear_pending_calls == 3); + CHECK(g_runtime_test_game_tick_calls == 3); + CHECK(RollerRuntime_GetStatus(&runtime) == ROLLER_RUNTIME_STATUS_RUNNING); + + g_runtime_test_frontend_on = 1; + CHECK(RollerRuntime_Step(&runtime, 2) == ROLLER_RUNTIME_RESULT_OK); + CHECK(g_runtime_test_input_advance_calls == 5); + CHECK(g_runtime_test_tick_clock_calls == 5); + CHECK(g_runtime_test_clear_pending_calls == 5); + CHECK(g_runtime_test_game_tick_calls == 3); + + RollerRuntime_Destroy(&runtime); + return 0; +} diff --git a/tests/snapshots/README.md b/tests/snapshots/README.md index a9e66b1b..7a404925 100644 --- a/tests/snapshots/README.md +++ b/tests/snapshots/README.md @@ -39,7 +39,26 @@ files from there, same as a normal `zig build run`. zig build test-snapshots ``` -On the canonical host (Apple Silicon macOS at the time of writing) this: +### Runtime-driven replay snapshots + +```bash +zig build test-runtime-snapshots +``` + +This runs the replay snapshot list through `RollerRuntime` using the same +VCS-managed PNG baselines as `zig build test-snapshots`. Replay file loading +remains part of the existing snapshot/replay setup; runtime owns fixed stepping +only. This is a parallel migration gate: the legacy snapshot harness remains +authoritative while the runtime-driven replay path proves it can reproduce the +same pixels. + +`test-runtime-snapshots` currently covers replay entries from +`build.zig`'s `snapshot_replays` table. Named scene snapshots remain covered by +`test-snapshots` until scene rendering is wired to an explicit runtime-state +view. + +On the canonical host (Apple Silicon macOS at the time of writing), +`zig build test-snapshots`: 1. Builds the `roller` binary if needed. 2. Runs `roller --snapshot introN.gss --frames ... --out @@ -48,6 +67,9 @@ On the canonical host (Apple Silicon macOS at the time of writing) this: tests/snapshots/baselines/` once per named scene. 3. Runs `git diff --exit-code --stat -- tests/snapshots/baselines/`. +`zig build test-runtime-snapshots` follows the same baseline/diff flow for +replay entries only, adding `--runtime-snapshot` to each replay capture. + If the captures match HEAD, exit 0. If anything diverged, the build fails and `git status` shows you what changed. @@ -104,6 +126,7 @@ tracked separately in a future ADR. | (none) | Run captures into `tests/snapshots/baselines/`, then `git diff --exit-code` against HEAD. Fail on divergence. | | `-Dscratch` | Run captures into `zig-out/snapshot-scratch/` and skip the diff check. Working tree stays clean. | | `-Dassets-path=PATH` | Use `PATH` instead of `./fatdata` as the data root. | +| `test-runtime-snapshots` | Build step that drives replay snapshots through `RollerRuntime` and compares against the same checked-in baselines. | The list of replays and which frames are captured per replay live in `build.zig`'s `snapshot_replays` table.