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:
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);