phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

commit 134418710abb2b41662a5222ca093ec6d7ad914d
parent 8c44e7b0b3f77d80ec5e4c5553736f9c53c69dab
Author: beep <beep@wimdupont.com>
Date:   Tue, 27 Oct 2026 11:55:47 +0000

Add seeded input recording and headless replay

Diffstat:
MMakefile | 2+-
MREADME.adoc | 26++++++++++++++++++++++++++
DTODO.adoc | 16----------------
Mphantasia.6 | 16++++++++++++----
Msrc/game/game.c | 2+-
Msrc/game/game.h | 3++-
Msrc/game/main.c | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Asrc/game/replay.c | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/game/replay.h | 19+++++++++++++++++++
Msrc/tests/game.c | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
10 files changed, 370 insertions(+), 36 deletions(-)

diff --git a/Makefile b/Makefile @@ -34,7 +34,7 @@ AUDIO = \ ENGINE_SRC = src/engine/world.c src/engine/area.c src/engine/battle.c src/engine/save.c \ src/engine/audio.c src/engine/render.c src/engine/clock.c src/engine/validate.c -GAME_SRC = src/game/content.c src/game/game.c src/game/validate.c +GAME_SRC = src/game/content.c src/game/game.c src/game/validate.c src/game/replay.c SRC = $(GAME_SRC) src/game/main.c MAPC_SRC = src/tools/mapc.c src/game/content.c CONFIG = config.h diff --git a/README.adoc b/README.adoc @@ -69,6 +69,32 @@ Interactions and presentation events are handled after each simulation tick. Identical input per tick and random seeds reproduce simulation within the same build; this does not promise identical results across platforms. +Record a fresh session and reproduce it headlessly with: + +[source,sh] +---- +./bin/phantasia --record /tmp/bug.phr --seed 12345 +./bin/phantasia --replay /tmp/bug.phr +---- + +Recording starts directly in a new world and leaves the normal autosave alone. +The optional seed defaults to `0x5048414e`. Quit normally to finish the recording; +truncated or malformed streams fail replay with a nonzero exit status. Replay +requires no window or audio device, including in builds without SDL, and reports +the tick count and final area, mode, and random-generator state. + +The versioned binary stream stores the seed and simulation rate, movement and +interaction input for every executed tick, and ordered menu/battle actions before +the next tick. Paused menus therefore preserve action order without advancing +simulation. Rendering, debug controls, and wall-clock delays are excluded. Keep +the same build, maps, content, and simulation configuration when reproducing a +bug: the header checks format version and tick rate, not content compatibility. +This is a debugging format, not a portable save format or a cross-platform +floating-point determinism guarantee. `make game-smoke` exercises replay through +seeded combat and paused menus, compares resulting game state, and checks corrupt +streams. Tests can supply a specific starting fixture to `ph_replay_run` for a +focused regression; command-line recordings always start from a fresh world. + Camera state belongs to presentation. World drawing accepts a caller-supplied camera, and player following reads the world without changing simulation. World startup and save loading are independent of viewport dimensions. Compile-time diff --git a/TODO.adoc b/TODO.adoc @@ -1,16 +0,0 @@ -= TODO - -Keep Phantasia's engine small and reusable for other 2D RPGs. Game content and -rules should sit above the engine, platform details should stay in backends, and -simulation should be reproducible enough to test without a window. Prefer changes -that improve the game or demonstrate reuse over speculative framework features. - -The shared engine library, independent Parcel Run example, SDL-independent game -flow and media interfaces, and fixed simulation ticks provide the foundation. - -* Record and replay input per simulation tick with a known random seed. Turn - reproducible gameplay bugs into regression tests; do not assume cross-platform - floating-point determinism. - -Separate SDK packaging, API stability, editors, scripting, and additional graphics -backends can wait until a concrete game needs them. diff --git a/phantasia.6 b/phantasia.6 @@ -6,7 +6,8 @@ .Nd 2D SDL3 RPG prototype .Sh SYNOPSIS .Nm -.Op Fl -render-smoke-test +.Op Fl -record Ar file Op Fl -seed Ar n +.Op Fl -replay Ar file .Sh DESCRIPTION .Nm is a small top-down RPG prototype with connected map areas, item pickup, shops, @@ -51,7 +52,14 @@ Quit the game. .El .Sh OPTIONS .Bl -tag -width Ds -.It Fl -render-smoke-test -Run a headless software-render smoke test and save a frame dump to -.Pa obj/render-smoke.bmp . +.It Fl -record Ar file +Record input from a fresh game without loading or writing the normal save. +Quit normally to complete the recording. +.It Fl -seed Ar n +Set the recording's random seed (default 0x5048414e). +.It Fl -replay Ar file +Replay input headlessly without saving and report the final tick count and state. +Use the same build, content, and simulation configuration as the recording. +Malformed or incomplete recordings return a nonzero exit status. +Cross-platform floating-point determinism is not guaranteed. .El diff --git a/src/game/game.c b/src/game/game.c @@ -565,7 +565,7 @@ ph_game_update(PhGameContext *context, PhInput input, float dt, const char *save if (ph_world_update_portal(world, ph_game_world.areas) < 0) fprintf(stderr, "failed to enter area\n"); if (world->encounter_index >= 0) { - if (ph_save_write(save_path, world, context->spells) < 0) + if (save_path && ph_save_write(save_path, world, context->spells) < 0) fprintf(stderr, "failed to save pre-combat checkpoint\n"); ph_battle_begin(context->battle, world, world->encounter_index, *context->spells); diff --git a/src/game/game.h b/src/game/game.h @@ -163,7 +163,8 @@ enum { /* Returns request flags; the host performs lifecycle operations. */ int ph_game_input(PhGameContext *context, PhAction key); -/* Writes the pre-combat checkpoint; returns whether an autosave is needed. */ +/* Writes the pre-combat checkpoint if save_path is non-NULL; returns whether + * an autosave is needed. NULL disables persistence for replay/tests. */ int ph_game_update(PhGameContext *context, PhInput input, float dt, const char *save_path); void ph_game_interaction(PhGameContext *context); /* Discards paused time and the frame containing an input-driven state change. */ diff --git a/src/game/main.c b/src/game/main.c @@ -5,9 +5,14 @@ #include "engine/save.h" #include "game/content.h" #include "game/presentation.h" +#include "game/replay.h" #include <stdbool.h> #include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <errno.h> +#include <stdint.h> #if PH_USE_SDL #include <SDL3/SDL.h> @@ -131,7 +136,7 @@ ph_create_window_renderer(SDL_Window **window, SDL_Renderer **renderer) } static int -ph_run_game(void) +ph_run_game(FILE *recording, unsigned int seed) { SDL_Window *window; SDL_Renderer *target; @@ -171,6 +176,8 @@ ph_run_game(void) int running = 1; int interact_pending = 0; int save_status; + int result = 0; + const char *save_path = recording ? NULL : PH_SAVE_PATH; char content_error[256]; if (ph_game_validate_content(content_error, sizeof(content_error)) < 0) { @@ -182,7 +189,7 @@ ph_run_game(void) fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); return 1; } - save_status = ph_save_load(PH_SAVE_PATH, &world, &ph_game_world, &spells); + save_status = recording ? 0 : ph_save_load(save_path, &world, &ph_game_world, &spells); if (save_status < 0) { fprintf(stderr, "save is invalid; starting a new game\n"); spells = ph_player_spellbook; @@ -191,6 +198,10 @@ ph_run_game(void) SDL_Quit(); return 1; } + if (recording) { + world.rng_state = seed; + context.menu = PH_MENU_NONE; + } start_ui.can_continue = save_status > 0; start_ui.selected = start_ui.can_continue ? 0 : 1; if (ph_create_window_renderer(&window, &target) < 0) { @@ -229,19 +240,27 @@ ph_run_game(void) running = 0; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) { int request; + PhAction action; if (event.key.scancode == PH_KEY_DEBUG) { debug.enabled = !debug.enabled; continue; } - request = ph_game_input(&context, ph_sdl_action(event.key.scancode)); + action = ph_sdl_action(event.key.scancode); + if (recording && action != PH_ACTION_NONE && + ph_replay_write(recording, PH_REPLAY_ACTION, action, (PhInput){ 0 }) < 0) { + result = 1; + running = 0; + break; + } + request = ph_game_input(&context, action); if (context.menu == PH_MENU_NONE && game == PH_GAME_WORLD && (event.key.scancode == PH_KEY_INTERACT_1 || event.key.scancode == PH_KEY_INTERACT_2)) interact_pending = 1; if (request & PH_GAME_MENU_SOUND) effect = PH_SOUND_MENU; - if (request & PH_GAME_REQUEST_EXIT) running = 0; + if (request & PH_GAME_REQUEST_EXIT) { running = 0; break; } if (request & PH_GAME_REQUEST_NEW) { ph_world_destroy(&world); spells = ph_player_spellbook; @@ -269,8 +288,14 @@ ph_run_game(void) ++executed; tick_input.interact |= interact_pending; interact_pending = 0; - if (ph_game_update(&context, tick_input, 1.0f / PH_SIMULATION_HZ, PH_SAVE_PATH) && - ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) + if (recording && ph_replay_write(recording, PH_REPLAY_TICK, + PH_ACTION_NONE, tick_input) < 0) { + result = 1; + running = 0; + break; + } + if (ph_game_update(&context, tick_input, 1.0f / PH_SIMULATION_HZ, save_path) && + save_path && ph_save_write(save_path, &world, &spells) < 0) fprintf(stderr, "failed to autosave\n"); ph_game_interaction(&context); /* Drain each tick before the next can replace battle events or travel. */ @@ -295,24 +320,100 @@ ph_run_game(void) ph_render_destroy(renderer); SDL_DestroyRenderer(target); SDL_DestroyWindow(window); - if ((game == PH_GAME_WORLD || game == PH_GAME_VICTORY) && + if (save_path && (game == PH_GAME_WORLD || game == PH_GAME_VICTORY) && ph_save_write(PH_SAVE_PATH, &world, &spells) < 0) fprintf(stderr, "failed to save game\n"); ph_world_destroy(&world); SDL_Quit(); - return 0; + return result; } #endif +static int +ph_run_replay(FILE *file, unsigned int seed) +{ + PhWorld world = { 0 }; + PhBattle battle = { 0 }; + PhSpellBook spells = ph_player_spellbook; + PhInventoryUi inventory = { 0 }; + PhSpellbookUi spellbook = { 0 }; + PhShopUi shop = { 0 }; + PhOptionsUi options = { .music_enabled = 1, .effects_enabled = 1 }; + PhStartUi start = { 0 }; + PhGameMode mode = PH_GAME_WORLD; + PhGameContext context = { .world = &world, .battle = &battle, .spells = &spells, + .inventory = &inventory, .spellbook = &spellbook, .shop = &shop, + .options = &options, .start = &start, .mode = &mode }; + uint64_t ticks; + int result; + char error[256]; + + if (ph_game_validate_content(error, sizeof(error)) < 0) { + fprintf(stderr, "content: %s\n", error); + return 1; + } + if (ph_world_start(&world, &ph_game_world) < 0) return 1; + world.rng_state = seed; + result = ph_replay_run(file, &context, &ticks); + if (result < 0) fprintf(stderr, "invalid replay at tick %llu\n", (unsigned long long)ticks); + else printf("replay: %llu ticks, area %d, mode %d, RNG %u\n", + (unsigned long long)ticks, world.area_id, mode, world.rng_state); + ph_world_destroy(&world); + return result < 0; +} + int -main(void) +main(int argc, char **argv) { + FILE *file; + unsigned int seed = 0x5048414eu; + int recording, result; + + if (argc == 1) { #if PH_USE_SDL - return ph_run_game(); + return ph_run_game(NULL, seed); #else - fprintf(stderr, "SDL3 not available in this build; run `make smoke`\n"); - return 1; + fprintf(stderr, "SDL3 not available in this build; run `make smoke` or --replay\n"); + return 1; +#endif + } + if ((argc != 3 && argc != 5) || + (strcmp(argv[1], "--record") && strcmp(argv[1], "--replay"))) goto usage; + recording = !strcmp(argv[1], "--record"); + if (argc == 5) { + char *end; + unsigned long value; + + if (!recording || strcmp(argv[3], "--seed") || argv[4][0] == '-') goto usage; + errno = 0; + value = strtoul(argv[4], &end, 0); + if (errno || end == argv[4] || *end || value > UINT32_MAX) goto usage; + seed = (unsigned int)value; + } +#if !PH_USE_SDL + if (recording) { + fprintf(stderr, "recording requires SDL3\n"); + return 1; + } #endif + file = fopen(argv[2], recording ? "wb" : "rb"); + if (!file) { perror(argv[2]); return 1; } + result = ph_replay_header(file, &seed, recording); + if (result < 0) { + fprintf(stderr, "invalid or unreadable replay header\n"); + result = 1; + } else if (!recording) result = ph_run_replay(file, seed); +#if PH_USE_SDL + else result = ph_run_game(file, seed); +#endif + if (recording && !result && + ph_replay_write(file, PH_REPLAY_END, PH_ACTION_NONE, (PhInput){ 0 }) < 0) result = 1; + if (fclose(file)) result = 1; + if (recording && result) fprintf(stderr, "recording failed; file is incomplete\n"); + return result; +usage: + fprintf(stderr, "usage: %s [--record FILE [--seed N] | --replay FILE]\n", argv[0]); + return 1; } #endif diff --git a/src/game/replay.c b/src/game/replay.c @@ -0,0 +1,104 @@ +#include "game/replay.h" +#include <string.h> +#include "config.h" + +int +ph_replay_header(FILE *file, unsigned int *seed, int writing) +{ + unsigned char header[16] = { 'P', 'H', 'R', 'E', 'P', 'L', 'A', 'Y', 1, 0 }; + unsigned int value = *seed; + int i; + + header[10] = PH_SIMULATION_HZ & 255; + header[11] = (PH_SIMULATION_HZ >> 8) & 255; + if (writing) { + for (i = 0; i < 4; ++i) header[12 + i] = (value >> (8 * i)) & 255; + return fwrite(header, 1, sizeof(header), file) == sizeof(header) ? 0 : -1; + } + { + unsigned char actual[16]; + + if (fread(actual, 1, sizeof(actual), file) != sizeof(actual) || + memcmp(actual, header, 12)) return -1; + value = 0; + for (i = 0; i < 4; ++i) value |= (unsigned int)actual[12 + i] << (8 * i); + *seed = value; + } + return 0; +} + +int +ph_replay_write(FILE *file, int kind, PhAction action, PhInput input) +{ + unsigned char record[4] = { (unsigned char)kind }; + size_t size = 1; + + if (kind == PH_REPLAY_ACTION) { + if (action <= PH_ACTION_NONE || action > PH_ACTION_SPELLBOOK) return -1; + record[1] = action; + size = 2; + } else if (kind == PH_REPLAY_TICK) { + if (input.move_x < -1 || input.move_x > 1 || input.move_y < -1 || + input.move_y > 1 || input.interact < 0 || input.interact > 1) return -1; + record[1] = input.move_x + 1; + record[2] = input.move_y + 1; + record[3] = input.interact; + size = 4; + } else if (kind != PH_REPLAY_END) return -1; + return fwrite(record, 1, size, file) == size ? 0 : -1; +} + +int +ph_replay_read(FILE *file, PhAction *action, PhInput *input) +{ + int kind = fgetc(file), x, y, interact; + + *action = PH_ACTION_NONE; + *input = (PhInput){ 0 }; + if (kind == PH_REPLAY_END) + return fgetc(file) == EOF && !ferror(file) ? PH_REPLAY_END : -1; + if (kind == PH_REPLAY_ACTION) { + x = fgetc(file); + if (x <= PH_ACTION_NONE || x > PH_ACTION_SPELLBOOK) return -1; + *action = (PhAction)x; + } else if (kind == PH_REPLAY_TICK) { + x = fgetc(file); + y = fgetc(file); + interact = fgetc(file); + if (x < 0 || x > 2 || y < 0 || y > 2 || interact < 0 || interact > 1) return -1; + *input = (PhInput){ .move_x = x - 1, .move_y = y - 1, .interact = interact }; + } else return -1; + return kind; +} + +int +ph_replay_run(FILE *file, PhGameContext *context, uint64_t *ticks) +{ + PhAction action; + PhInput input; + int kind, exited = 0; + + *ticks = 0; + while ((kind = ph_replay_read(file, &action, &input)) > 0) { + PhWorldEvent event; + + if (exited) return -1; + if (kind == PH_REPLAY_ACTION) { + int request = ph_game_input(context, action); + + if (request & PH_GAME_REQUEST_NEW) return -1; + exited = request & PH_GAME_REQUEST_EXIT; + } else { + if (context->menu != PH_MENU_NONE || *context->mode == PH_GAME_VICTORY || + *context->mode == PH_GAME_OVER) return -1; + ph_game_update(context, input, 1.0f / PH_SIMULATION_HZ, NULL); + ph_game_interaction(context); + ++*ticks; + } + /* Match the live host's consumption of one-shot presentation events. */ + ph_battle_take_event(context->battle); + context->spellbook->spell_used = 0; + while (ph_world_take_event(context->world, &event)) { } + } + return kind == PH_REPLAY_END ? 0 : -1; +} diff --git a/src/game/replay.h b/src/game/replay.h @@ -0,0 +1,19 @@ +#ifndef PH_GAME_REPLAY_H +#define PH_GAME_REPLAY_H + +#include "game/game.h" +#include <stdio.h> + +/* Versioned, little-endian input stream. Actions are ordered before the next + * tick; paused menus can have any number of actions without advancing time. + * Fresh game only, with identical content/config/build required for replay. */ +enum { PH_REPLAY_END, PH_REPLAY_ACTION, PH_REPLAY_TICK }; +int ph_replay_header(FILE *file, unsigned int *seed, int writing); +int ph_replay_write(FILE *file, int kind, PhAction action, PhInput input); +/* Returns record kind, or -1 for malformed/truncated input (including no END). */ +int ph_replay_read(FILE *file, PhAction *action, PhInput *input); +/* Context must be a fresh game with no open menu and the header's RNG seed. + * Runs without saving; returns 0 at END or -1 on invalid input. */ +int ph_replay_run(FILE *file, PhGameContext *context, uint64_t *ticks); + +#endif diff --git a/src/tests/game.c b/src/tests/game.c @@ -1,6 +1,7 @@ #define _POSIX_C_SOURCE 200809L #include "game/game.h" #include "engine/save.h" +#include "game/replay.h" #include <stdio.h> #include <stdlib.h> @@ -75,6 +76,95 @@ ph_timing_smoke(void) return 0; } +static int +ph_replay_smoke(void) +{ + PhWorld worlds[2] = { 0 }; + PhBattle battles[2] = { 0 }; + PhSpellBook spells[2] = { ph_player_spellbook, ph_player_spellbook }; + PhInventoryUi inventory[2] = { 0 }; + PhSpellbookUi spellbook[2] = { 0 }; + PhShopUi shop[2] = { 0 }; + PhOptionsUi options[2] = { 0 }; + PhStartUi start[2] = { 0 }; + PhGameMode modes[2] = { PH_GAME_WORLD, PH_GAME_WORLD }; + PhGameContext games[2]; + FILE *file = tmpfile(); + unsigned int seed = 12345, loaded_seed = 0; + uint64_t ticks = 0, replay_ticks = 0; + int i, j, saw_battle = 0, result = 1; + PhAction action; + PhInput input; + + CHECK(file); + CHECK(ph_replay_header(file, &seed, 1) == 0); + for (i = 0; i < 2; ++i) { + games[i] = (PhGameContext){ .world = &worlds[i], .battle = &battles[i], + .spells = &spells[i], .inventory = &inventory[i], .spellbook = &spellbook[i], + .shop = &shop[i], .options = &options[i], .start = &start[i], .mode = &modes[i] }; + CHECK(ph_world_start(&worlds[i], &ph_game_world) == 0); + worlds[i].rng_state = seed; + /* Regression fixture: contact a slime immediately, then resolve real + * seeded attacks through the same input actions as the live host. */ + for (j = 0; j < worlds[i].entity_count; ++j) + if (worlds[i].entities[j].type_id == PH_DEF_SLIME) break; + CHECK(j < worlds[i].entity_count); + worlds[i].encounter_index = j; + } + for (i = 0; i < 1200; ++i) { + PhWorldEvent event; + + action = i == 0 ? PH_ACTION_CHARACTER : i == 5 ? PH_ACTION_BACK : PH_ACTION_ACCEPT; + /* Several menu actions share one simulation tick while paused. */ + if (i > 0 && i < 5) action = PH_ACTION_DOWN; + CHECK(ph_replay_write(file, PH_REPLAY_ACTION, action, (PhInput){ 0 }) == 0); + CHECK(!(ph_game_input(&games[0], action) & (PH_GAME_REQUEST_NEW | PH_GAME_REQUEST_EXIT))); + if (games[0].menu == PH_MENU_NONE && modes[0] != PH_GAME_VICTORY && modes[0] != PH_GAME_OVER) { + input = (PhInput){ .move_x = i < 600 ? 1 : -1, .interact = i % 17 == 0 }; + CHECK(ph_replay_write(file, PH_REPLAY_TICK, PH_ACTION_NONE, input) == 0); + ph_game_update(&games[0], input, 1.0f / PH_SIMULATION_HZ, NULL); + ph_game_interaction(&games[0]); + ++ticks; + } + if (modes[0] == PH_GAME_BATTLE) saw_battle = 1; + ph_battle_take_event(&battles[0]); + spellbook[0].spell_used = 0; + while (ph_world_take_event(&worlds[0], &event)) { } + } + CHECK(saw_battle && ticks > 100 && worlds[0].rng_state != seed); + CHECK(ph_replay_write(file, PH_REPLAY_END, PH_ACTION_NONE, (PhInput){ 0 }) == 0); + rewind(file); + CHECK(ph_replay_header(file, &loaded_seed, 0) == 0 && loaded_seed == seed); + CHECK(ph_replay_run(file, &games[1], &replay_ticks) == 0 && replay_ticks == ticks); + CHECK(worlds[0].rng_state == worlds[1].rng_state && worlds[0].area_id == worlds[1].area_id); + CHECK(worlds[0].entity_count == worlds[1].entity_count); + CHECK(!memcmp(worlds[0].entities, worlds[1].entities, sizeof(worlds[0].entities))); + CHECK(!memcmp(worlds[0].inventory, worlds[1].inventory, sizeof(worlds[0].inventory))); + CHECK(!memcmp(&battles[0], &battles[1], sizeof(battles[0]))); + CHECK(modes[0] == modes[1] && games[0].menu == games[1].menu); + CHECK(!memcmp(&spells[0], &spells[1], sizeof(spells[0]))); + /* Missing terminator, partial record, invalid input, and trailing bytes. */ + CHECK(ph_replay_read(file, &action, &input) == -1); + CHECK(ftruncate(fileno(file), 17) == 0); + CHECK(fseek(file, 16, SEEK_SET) == 0); + CHECK(ph_replay_read(file, &action, &input) == -1); + rewind(file); + CHECK(fputc(PH_REPLAY_TICK, file) != EOF && fputc(3, file) != EOF); + CHECK(fputc(1, file) != EOF && fputc(0, file) != EOF); + rewind(file); + CHECK(ph_replay_read(file, &action, &input) == -1); + rewind(file); + CHECK(fputc(PH_REPLAY_END, file) != EOF); + rewind(file); + CHECK(ph_replay_read(file, &action, &input) == -1); + CHECK(ph_replay_header(file, &loaded_seed, 0) == -1); + result = 0; +fail: + if (file) fclose(file); + for (i = 0; i < 2; ++i) ph_world_destroy(&worlds[i]); + return result; +} + int main(void) { @@ -97,6 +187,7 @@ main(void) int i, enemy = -1, potion = -1, result = 1; float timer; + if (ph_replay_smoke()) return 1; if (ph_timing_smoke()) { fputs("fixed-timestep smoke failed\n", stderr); return 1; @@ -208,7 +299,7 @@ main(void) game.menu = PH_MENU_START; start.can_continue = 0; CHECK(ph_game_input(&game, PH_ACTION_ACCEPT) & PH_GAME_REQUEST_NEW); - puts("game smoke: fixed timing, actions, menus, pause, checkpoint and battle flow passed"); + puts("game smoke: seeded replay, fixed timing, actions, menus, pause, checkpoint and battle flow passed"); result = 0; fail: ph_world_destroy(&world);