Deterministic playback for debugging.

This commit is contained in:
Scott Duensing 2026-09-09 19:38:20 -05:00
parent d024a50c8d
commit caac38336c
15 changed files with 319 additions and 79 deletions

View file

@ -649,6 +649,15 @@ API Changes
- navAgentNew keeps the radius and height at 0.01 or more, as navNew
does, so onNavArrived fires for an agent given a radius of zero.
- --deterministic[=MS] runs the engine on a virtual clock stepped MS
milliseconds (default 15) once a frame instead of on real time, steps
the disc one video frame a frame with it, and seeds Lua's generator
and the engine's from the same number, so a screenshot taken at a
fixed frame number is byte identical between runs. It is a testing
facility: audio and pacing are meaningless in it. Settable as
deterministic in settings.cfg. With the option absent nothing
changes.
Fixes

View file

@ -122,3 +122,7 @@
-- trace = false -- trace every Lua call to trace.txt
-- showcalculated = false -- print the frame ranges of every framefile segment
-- softwarevideo = false -- decode video in software even when the machine can do it in hardware
-- deterministic = 15 -- testing only: ignore real time, run on a virtual clock stepped this
-- many milliseconds a frame and seed the random generators with the same
-- number, so a run repeats to the pixel. true takes the default 15.
-- Audio and pacing are meaningless in this mode.

View file

@ -325,6 +325,10 @@ An option that takes no value on the command line takes `true` or `false` here
is the same as leaving it out. An option that takes a value takes the same value
it would on the command line, as a number or a string.
One option, `--deterministic`, takes a value or leaves it out, and its key does
the same: `deterministic = true` uses the default step, `deterministic = 20`
names one, and `deterministic = false` leaves the option off.
==== What wins
. The built in default.
@ -416,6 +420,7 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile.
| `--absolutes_only` | Keep only the mice that report an absolute position, which is what a real light gun does and an ordinary mouse does not. ManyMouse cannot be asked what a device is, so a device counts as absolute once it has reported an absolute position and not before; see <<mousedevices,Mice, Guns, and Who Chooses>>. Hypseus writes it `-absolutes-only`; the name here uses an underscore because a settings file key has to be a Lua name. Default: off.
| `--altaudio=SUFFIX` | Play `<base><SUFFIX>.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none.
| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <<apiversion,The Version Line>>. Nothing else is printed.
| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc steps one video frame a frame with it, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see <<deterministic,Deterministic Test Mode>>. Default: off.
| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`.
| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order.
| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for.
@ -770,6 +775,80 @@ empty until the next reload fixes it; the file stays watched, so saving the
fix is enough. Because the reload runs the same
teardown that quitting does, anything a script leaks shows up here first.
[[deterministic]]
=== Deterministic Test Mode
`--deterministic` is a testing facility, not a way to play. It makes the engine
ignore real time altogether: instead of asking the machine what time it is, the
engine runs on a virtual clock that moves a fixed step -- 15 milliseconds by
default -- exactly once a frame. Everything paced by time follows that clock:
physics steps, sprite and model animation, particles, navigation, the GUI's
animations and transitions, `os.clock()` and `singeGetTicks()`. The disc follows
it too: while it is playing it advances exactly one video frame for every frame
the engine draws, from wherever it was last parked, and a paused or searched
disc stays where it was put. Lua's generator and the engine's own are seeded
from a fixed number. Nothing waits for anything, so the game runs as fast as the machine
will let it.
The point of all this is that a screenshot taken at a fixed frame number is the
same picture in every run. Without it, frame 40 is a different moment each time
-- the physics has taken a different number of steps and the disc is on a
different video frame -- so two runs of the same unchanged game can differ by a
million pixels, and a real change can hide inside that noise.
What it costs is everything that depends on real time. Audio still plays on the
device's own clock, which no virtual clock reaches, so sound drifts away from the
picture and means nothing. The frame rate means nothing either, and neither does
anything a game measures in seconds. Never report a performance number, an audio
problem or a timing problem from a run in this mode, and never ship a launcher
that uses it.
The number the option carries, if it carries one, is both the size of the step
and the seed the generators start from, `1` to `1000`:
----
Singe --deterministic the default 15 ms step, seeded with 15
Singe --deterministic=20 a 20 ms step, seeded with 20
----
Two runs at the same number are identical; two runs at different numbers are each
repeatable but not the same as each other, which is how a test asks for a
different but equally reproducible run. It may be set in a settings file as
`deterministic` like any other option.
==== Reference screenshots
The scenes in `testScripts` each take a screenshot or two at a fixed frame number
and quit by themselves, so in this mode they make a set of reference pictures a
later change can be compared against pixel for pixel. To record them, run every
scene with the option and keep what lands in the data directory:
[source,sh]
----
for n in 2 3 4 5 6 7 8 9 10 11 13 14 15 16 18 19 21 22 23 24 25 26 \
36 37 38 39 40 41 42 43 44 45 48 49 50 51; do
Singe -w -d data --deterministic -v Singe/menuBackground.mkv \
testScripts/scene$n.singe
done
----
The scenes that do not take the disc want their own options, the same ones the
table in `testScripts/README.md` gives them: `scene12` runs with no disc at all,
`scene17` with the disc, and `scene20` with `-C 720x480`, all three with
`SDL_GPU_DRIVER=nothing`; `scene27` to `scene35` want `-x 1280 -y 720
-C 1280x720` and no disc; `scene46` wants `-g '10 5'`; and `scene47` wants
`--bezeldir=testScripts/bezels --bezel=cabinet.png`. Copy the `singe*.png` files
out of `data/testScripts` after each run, since the next run numbers its
screenshots from zero again.
Comparing a later build is then a matter of running the same commands and asking
for the difference, which must be nothing:
[source,sh]
----
compare -metric AE reference/scene36-singe000.png new/scene36-singe000.png null:
----
=== Game Directory Layout
A Singe installation is a game directory containing the `Singe` support

View file

@ -145,7 +145,7 @@ static char _error[GUI_ERROR_MAX];
// ===== Interfaces =====
double GuiSystemInterfaceT::GetElapsedTime() {
return (double)SDL_GetTicks() / MILLISECONDS_PER_SECOND;
return (double)utilTicks() / MILLISECONDS_PER_SECOND;
}

View file

@ -112,6 +112,7 @@ typedef enum LongOptionE {
OPT_ABSOLUTES_ONLY = 256,
OPT_ALTAUDIO,
OPT_APIVERSION,
OPT_DETERMINISTIC,
OPT_FVALUE,
OPT_GAMEPAD_REORDER,
OPT_HAPTIC,
@ -195,6 +196,7 @@ static const OptionT _options[] = {
{ OPT_ABSOLUTES_ONLY, "absolutes_only", ap_no, NULL, "keep only the mice that report absolute positions, which is what light guns do", false, true },
{ OPT_ALTAUDIO, "altaudio", ap_yes, "SUFFIX", "play <base><SUFFIX>.ogg beside the disc video in place of its own audio", false, true },
{ OPT_APIVERSION, "apiversion", ap_no, NULL, "print one machine readable version line and exit", false, false },
{ OPT_DETERMINISTIC, "deterministic", ap_maybe, "MS", "for testing: ignore real time and move the clock MS milliseconds each frame, seeding the random generators from the same number", false, true },
{ OPT_FVALUE, "fvalue", ap_yes, "NUMBER", "one number handed to the game, which reads it with getFValue()", false, true },
{ OPT_GAMEPAD_REORDER, "gamepad_reorder", ap_yes, "DIGITS", "which pad fills which slot, as enumeration positions from 0", false, true },
{ OPT_HAPTIC, "haptic", ap_yes, "STEP", "strongest rumble step a game may use, 0 to 4; 0 turns rumble off", false, true },
@ -578,6 +580,14 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
_showApiVersion();
break;
// Reproducible Runs
case OPT_DETERMINISTIC:
conf->deterministic = true;
if ((arg != NULL) && (arg[0] != 0)) {
target = &conf->deterministicStep;
}
break;
// The Launcher's Number
case OPT_FVALUE:
targetFloat = &conf->fValue;
@ -725,6 +735,7 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons
_requireRange(exeName, source, conf->haptic, RUMBLE_LEVEL_NONE, RUMBLE_LEVEL_MAX, "Rumble strength", "steps");
_requireRange(exeName, source, conf->idleExitSeconds, IDLE_EXIT_MIN, IDLE_EXIT_MAX, "Idle timeout", "seconds");
_requireRange(exeName, source, conf->joyMouseRange, JOY_MOUSE_RANGE_MIN, JOY_MOUSE_RANGE_MAX, "Joystick mouse speed", "steps");
_requireRange(exeName, source, conf->deterministicStep, DETERMINISTIC_STEP_MIN, DETERMINISTIC_STEP_MAX, "Deterministic step", "milliseconds");
_requireRangeFloat(exeName, source, conf->triggerThreshold, TRIGGER_THRESHOLD_MIN, TRIGGER_THRESHOLD_MAX, "Trigger threshold");
_requireRangeFloat(exeName, source, conf->ratioX, RATIO_MIN, RATIO_MAX, "Horizontal gun ratio");
_requireRangeFloat(exeName, source, conf->ratioY, RATIO_MIN, RATIO_MAX, "Vertical gun ratio");
@ -833,6 +844,12 @@ static bool _applySetting(const char *exeName, ConfigT *conf, const SettingT *se
return true;
}
text = utilCreateString("--%s", setting->key);
} else if ((_options[index].hasArgument == ap_maybe) && _parseBoolean(setting->value, &flag)) {
// One whose value is optional may be written as a boolean as well as a value.
if (!flag) {
return true;
}
text = utilCreateString("--%s", setting->key);
} else {
text = utilCreateString("--%s=%s", setting->key, setting->value);
}
@ -1345,6 +1362,7 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[])
conf->linearScale = true;
conf->mapJoysticks = true;
conf->joyMouseRange = JOY_MOUSE_RANGE_DEFAULT;
conf->deterministicStep = FRAME_TICK_MS;
_applyOptions(exeName, conf, argc, (const char **)argv, NULL);
@ -1609,10 +1627,13 @@ static void _showUsage(const char *name, const char *message) {
if (_options[x].hidden) {
continue;
}
if (_options[x].value != NULL) {
longForm = utilCreateString("--%s=%s", _options[x].name, _options[x].value);
} else {
if (_options[x].value == NULL) {
longForm = utilCreateString("--%s", _options[x].name);
} else if (_options[x].hasArgument == ap_maybe) {
// The value may be left off, and the usage text says so.
longForm = utilCreateString("--%s[=%s]", _options[x].name, _options[x].value);
} else {
longForm = utilCreateString("--%s=%s", _options[x].name, _options[x].value);
}
// Wrap the help at word boundaries; continuation lines start in the help column.
help = _options[x].help;

View file

@ -50,6 +50,9 @@
#define SCRIPT_MODE 0755 // Menu.sh: chmod ignores the umask, so no world write
#define CANVAS_DEFAULT_WIDTH 720
#define CANVAS_DEFAULT_HEIGHT 480
#define FRAME_TICK_MS 15 // Minimum time between onOverlayUpdate calls, so the rate a frame is worth
#define DETERMINISTIC_STEP_MIN 1 // --deterministic=MS: simulated milliseconds a frame
#define DETERMINISTIC_STEP_MAX 1000
ConfigT *cloneConf(const ConfigT *conf);

View file

@ -1823,7 +1823,7 @@ int32_t modelRootOf(int32_t root) {
// Once per frame: advances every playing animation by the wall clock (not at all while the game
// is paused, and by at most MAX_STEP_SECONDS after a stall) and poses the nodes.
void modelUpdate(bool advance) {
uint64_t now = SDL_GetTicksNS();
uint64_t now = utilTicksNS();
double delta = (_lastTick != 0) ? (double)(now - _lastTick) / 1e9 : 0.0;
int32_t x;

View file

@ -627,7 +627,7 @@ bool navAgentValid(int32_t agent) {
bool navBuild(int32_t nav) {
NavRecordT *record = _navRecord(nav);
uint64_t start = SDL_GetTicksNS();
uint64_t start = SDL_GetTicksNS(); // Real: this times the bake for the trace, not the simulation
bool ok;
if ((record == nullptr) || record->built) {
@ -908,7 +908,7 @@ bool navSave(int32_t nav, const char *path) {
void navUpdate(bool advance) {
uint64_t now = SDL_GetTicksNS();
uint64_t now = utilTicksNS();
double dt = 0.0;
int32_t n;
int32_t x;

View file

@ -967,7 +967,7 @@ void particlesQuit(void) {
void particlesUpdate(bool advance) {
EmitterT *emitter;
EmitterT *next;
uint64_t now = SDL_GetTicksNS();
uint64_t now = utilTicksNS();
double dt = 0.0;
if (advance && (_lastTick != 0)) {

View file

@ -2905,7 +2905,7 @@ void physicsUpdate(bool advance) {
return;
}
_releaseDead();
now = SDL_GetTicksNS();
now = utilTicksNS();
if (advance && _world->enabled && (_world->lastTick != 0)) {
_world->accumulator += (double)(now - _world->lastTick) / 1e9;
}

View file

@ -117,7 +117,6 @@ LSEC_API int luaopen_ssl_config(lua_State *L);
// Codes below the gamepad range are keyboard scancodes, so every scancode SDL can hand out must sit under it.
SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESERVED + SCANCODE_DYNAMIC_COUNT);
#define FRAME_TICK_MS 15 // Minimum time between onOverlayUpdate calls
#define IDLE_SLEEP_MS 1
#define OVERLAY_SCALE_DEFAULT 0.5
#define CONSOLE_FONT_GLYPHS 256
@ -2341,6 +2340,8 @@ static int32_t _defaultVolume(int32_t maximum) {
// Sleeps while keeping the window responsive. Returns false if the user asked to quit.
// The real clock, deliberately: this waits out a real sleep, and the virtual clock only moves in
// the frame loop, so a virtual deadline here would never arrive.
static bool _delayAndPump(uint32_t ms) {
SDL_Event event;
uint64_t until = SDL_GetTicks() + ms;
@ -3450,10 +3451,10 @@ static void _joyMouseUpdate(void) {
if (!_global.joyMouseEnabled || !_global.mouseEnabled || (_global.mouseMode != MOUSE_SINGLE) || (_global.canvasWidth <= 0)) {
return;
}
if (SDL_GetTicks() < _global.joyMouseClock) {
if (utilTicks() < _global.joyMouseClock) {
return;
}
_global.joyMouseClock = SDL_GetTicks() + FRAME_TICK_MS;
_global.joyMouseClock = utilTicks() + FRAME_TICK_MS;
if (_global.controllers[0] == NULL) {
return;
}
@ -4283,7 +4284,7 @@ static void _navCallbacks(void) {
// Any input of any kind: it restarts --idleexit's clock and lifts a --startsilent mute.
static void _noteInput(void) {
_global.idleClock = SDL_GetTicks();
_global.idleClock = utilTicks();
// Only --startsilent leaves the mixer muted with sound wanted, so nothing else can be lifted here.
if (_global.audioMuted && !_global.conf->noSound) {
_setAudioMuted(false);
@ -6201,6 +6202,13 @@ static void _startLuaContext(lua_State *L) {
lua_getfield(L, -1, "randomseed");
lua_pushcclosure(L, apiMathRandomseed, 1);
lua_setfield(L, -2, "randomseed");
// A deterministic run starts from a known seed, and does so again for every rebuilt context, so
// a reload lands on the same numbers the first context saw.
if (_global.conf->deterministic) {
lua_getfield(L, -1, "randomseed");
lua_pushinteger(L, (lua_Integer)_global.conf->deterministicStep);
lua_call(L, 1, 0);
}
lua_pop(L, 1);
// Every file a script names goes through the vfs.
@ -6558,7 +6566,7 @@ static void _suppressHeldInput(void) {
// SDL may not know about a held key or button yet (gamepads are polled on their own thread, and
// X11 re-reports keys after the focus event), so presses that arrive soon after count as held too.
_global.inputGraceUntil = SDL_GetTicks() + INPUT_GRACE_MS;
_global.inputGraceUntil = utilTicks() + INPUT_GRACE_MS;
for (x = 0; x < SDL_SCANCODE_COUNT; x++) {
_global.keySuppressed[x] = (x < count) && state[x];
@ -6882,7 +6890,7 @@ static void _videoDestroy(VideoT *video) {
// Once a second: whether any watched file's modification time moved.
static bool _watchedChanged(void) {
uint64_t now = SDL_GetTicks();
uint64_t now = utilTicks();
int32_t x;
if (now < _global.watchTick + WATCH_INTERVAL_MS) {
@ -9013,6 +9021,12 @@ static int32_t apiMathRandomseed(lua_State *L) {
int32_t x = 0;
lua_Number n = 0.0;
// Nothing is unseeded in a deterministic run: a script asking Lua to pick a seed for itself
// gets the one the option named instead, so the run still repeats.
if ((top == 0) && _global.conf->deterministic) {
lua_pushinteger(L, (lua_Integer)_global.conf->deterministicStep);
top = 1;
}
for (x = 1; (x <= top) && (x <= 2); x++) {
if (lua_isnumber(L, x) && !lua_isinteger(L, x)) {
n = floor(lua_tonumber(L, x));
@ -10326,7 +10340,7 @@ static int32_t apiNodeSetVisible(lua_State *L) {
// seconds = os.clock() Replaces Lua's processor-time clock with wall time since the engine started.
static int32_t apiOsClock(lua_State *L) {
lua_pushnumber(L, (lua_Number)SDL_GetTicks() / MS_PER_SECOND_NUMBER);
lua_pushnumber(L, (lua_Number)utilTicks() / MS_PER_SECOND_NUMBER);
return 1;
}
@ -11468,7 +11482,7 @@ static int32_t apiSingeGetScriptPath(lua_State *L) {
// milliseconds = singeGetTicks() Wall clock since the engine started.
static int32_t apiSingeGetTicks(lua_State *L) {
uint64_t ticks = SDL_GetTicks();
uint64_t ticks = utilTicks();
_luaTrace(L, "singeGetTicks", "%" PRIu64, ticks);
lua_pushinteger(L, (lua_Integer)ticks);
@ -12013,7 +12027,7 @@ static int32_t apiSpriteDraw(lua_State *L) {
// Advance animation, if any.
if ((sprite->animation != NULL) && sprite->animating) {
now = SDL_GetTicks();
now = utilTicks();
sprite->ticks += now - sprite->lastTick;
sprite->lastTick = now;
// Whole loops (after a long pause, say) land on the same frame, so they need not be stepped.
@ -12411,7 +12425,7 @@ static int32_t apiSpritePlay(lua_State *L) {
_argCheck(L, "spritePlay", 1, 1);
sprite = _argSprite(L, "spritePlay", 1);
if (!sprite->animating) {
sprite->lastTick = SDL_GetTicks();
sprite->lastTick = utilTicks();
sprite->animating = true;
}
_luaTrace(L, "spritePlay", "%d", sprite->id);
@ -13556,7 +13570,7 @@ static int32_t apiVldpSetRotate(lua_State *L) {
// is the rate Hypseus limits its own held-key zoom to.
static int32_t apiVldpSetScale(lua_State *L) {
int32_t percent = 0;
uint64_t now = SDL_GetTicks();
uint64_t now = utilTicks();
bool applied = false;
_argCheck(L, "vldpSetScale", 1, 1);
@ -13847,6 +13861,13 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// Local copy of config
_global.conf = cloneConf(conf);
// --deterministic: the virtual clock and the fixed random stream, both settled before anything
// that reads either. The step doubles as the seed, so one number describes the whole run.
utilDeterministic(_global.conf->deterministic, (uint32_t)_global.conf->deterministicStep);
if (_global.conf->deterministic) {
SDL_srand((uint64_t)_global.conf->deterministicStep);
_progTrace("Deterministic mode: %d ms a frame, generators seeded with %d", _global.conf->deterministicStep, _global.conf->deterministicStep);
}
vfsInit(_global.conf->container, _global.conf->dataDirBase, _global.conf->dataDir);
videoSetAudioDelay(_global.conf->audioDelayMs);
videoSetAudioCalibration(_loadAudioCalibration());
@ -14036,7 +14057,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// Nothing is heard while --nosound is given, and nothing until the first input while
// --startsilent is: one gain over the whole mixer, so no configured volume is disturbed.
_setAudioMuted(_global.conf->noSound || _global.conf->startSilent);
_global.idleClock = SDL_GetTicks();
_global.idleClock = utilTicks();
// The script's own defaults (the disc parked on frame 1 among them), now that everything they touch exists.
_resetScriptState();
@ -14056,6 +14077,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// Game Loop
_progTrace("Script is running");
while (_global.running) {
// One frame of simulated time, the only place the virtual clock moves. Off it, nothing happens.
utilTickAdvance();
// A reload asked for by the script, F5 or a changed file happens here, between frames.
if (_global.conf->reload && _watchedChanged()) {
_global.reloadRequested = true;
@ -14101,7 +14125,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
break;
}
if (event.gbutton.button < CONTROLLER_BUTTON_COUNT) {
if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) && (SDL_GetTicks() < _global.inputGraceUntil)) {
if ((event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) && (utilTicks() < _global.inputGraceUntil)) {
_global.buttonSuppressed[slot][event.gbutton.button] = true;
}
if (_global.buttonSuppressed[slot][event.gbutton.button]) {
@ -14133,7 +14157,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
break;
}
if (event.key.scancode < SDL_SCANCODE_COUNT) {
if ((event.type == SDL_EVENT_KEY_DOWN) && (SDL_GetTicks() < _global.inputGraceUntil)) {
if ((event.type == SDL_EVENT_KEY_DOWN) && (utilTicks() < _global.inputGraceUntil)) {
_global.keySuppressed[event.key.scancode] = true;
}
if (_global.keySuppressed[event.key.scancode]) {
@ -14315,7 +14339,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
_joyMouseUpdate();
// --idleexit: nothing has been touched for that long, so an attract cabinet lets go.
if ((_global.conf->idleExitSeconds > 0) && ((SDL_GetTicks() - _global.idleClock) >= ((uint64_t)_global.conf->idleExitSeconds * MS_PER_SECOND))) {
if ((_global.conf->idleExitSeconds > 0) && ((utilTicks() - _global.idleClock) >= ((uint64_t)_global.conf->idleExitSeconds * MS_PER_SECOND))) {
_progTrace("Idle for %d seconds; quitting", _global.conf->idleExitSeconds);
_global.running = false;
}
@ -14346,13 +14370,13 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
}
// Call game code, unless the engine has it paused.
if (!_global.frozen && (SDL_GetTicks() > frameClock)) {
if (!_global.frozen && (utilTicks() > frameClock)) {
intReturn = OVERLAY_NOT_UPDATED;
_callLua("onOverlayUpdate", ">i", &intReturn);
if (intReturn == OVERLAY_UPDATED) {
_global.refreshDisplay = true;
}
frameClock = SDL_GetTicks() + FRAME_TICK_MS; // Don't eat all the CPU.
frameClock = utilTicks() + FRAME_TICK_MS; // Don't eat all the CPU.
// Clear per-frame values.
_global.keyboardLastDown = SDL_SCANCODE_UNKNOWN;
_global.keyboardLastUp = SDL_SCANCODE_UNKNOWN;
@ -14395,7 +14419,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
particlesUpdate(!_global.frozen);
// The subtitle or the banner is settled before the GUIs render, so a change lands this frame.
_subtitleUpdate();
guiUpdate((double)SDL_GetTicks() / MS_PER_SECOND_NUMBER);
guiUpdate((double)utilTicks() / MS_PER_SECOND_NUMBER);
_guiTextInput();
sceneUpdateVideo(_sceneVideoSource);
sceneTexture = sceneRender();
@ -14459,7 +14483,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
_global.refreshDisplay = false;
}
SDL_Delay(IDLE_SLEEP_MS);
// Real time is meaningless on the virtual clock, so a reproducible run does not wait.
if (!utilIsDeterministic()) {
SDL_Delay(IDLE_SLEEP_MS);
}
}
// End game

View file

@ -114,6 +114,7 @@ typedef struct ConfigS {
bool startSilent; // --startsilent: muted until the first input of any kind
bool monochrome; // --monochrome: the disc picture starts in greyscale
bool linearScale; // --linearscale: the overlay starts filtered linearly, which is the default
bool deterministic; // --deterministic: run on a virtual clock stepped once a frame, so a test repeats
int32_t bestRatioIndex;
int32_t volumeVldp;
int32_t volumeNonVldp;
@ -134,6 +135,7 @@ typedef struct ConfigS {
int32_t idleExitSeconds; // --idleexit: quit after this long with no input; 0 never quits
int32_t haptic; // --haptic: the strongest rumble step a script may ask for; 0 is no rumble
int32_t joyMouseRange; // --js_range: video pixels a frame the cursor moves at full stick deflection
int32_t deterministicStep; // --deterministic=MS: simulated milliseconds a frame, and the seed the generators start from
double triggerThreshold; // --trigger_threshold: per cent of full travel a trigger counts as pressed at; 0 uses the dead zone
double ratioX; // --xratio: the gun coordinate scale a script reads with ratioGetX
double ratioY; // --yratio: the same for ratioGetY

View file

@ -43,6 +43,8 @@
#include <string.h>
#include <ctype.h>
#include <SDL3/SDL.h>
#include "util.h"
@ -50,6 +52,13 @@ static bool _consoleEnabled = true;
static bool _outputHappened = false;
static FILE *_utilTraceFile = NULL;
// The virtual clock deterministic mode runs on. utilTicks and utilTicksNS answer from it, and the
// frame loop is the only thing that moves it, exactly once a frame, so a frame number always names
// the same elapsed time however long the machine took to draw the frames.
static bool _deterministic = false;
static uint64_t _virtualStepNS = 0;
static uint64_t _virtualTicksNS = 0;
static bool _ensureDirectory(const char *path, const mode_t mode);
static void _printLine(FILE *stream, const char *fmt, va_list args) __attribute__((format(printf, 2, 0)));
@ -69,6 +78,11 @@ static bool _ensureDirectory(const char *path, const mode_t mode) {
static void _printLine(FILE *stream, const char *fmt, va_list args) {
// No caller passes a null format, but glibc's fortified vfprintf is inlined here and the
// compiler cannot see that, so say it plainly rather than carry the warning.
if (fmt == NULL) {
return;
}
vfprintf(stream, fmt, args);
fputc('\n', stream);
fflush(stream);
@ -144,6 +158,15 @@ char *utilCreateStringVArgs(const char *format, va_list args) {
}
// Puts the engine on the virtual clock, moving stepMs of simulated time every frame, or back on the
// real one. The clock starts from zero, so the first frame of a game is always the same moment.
void utilDeterministic(bool enable, uint32_t stepMs) {
_deterministic = enable;
_virtualStepNS = (uint64_t)stepMs * SDL_NS_PER_MS;
_virtualTicksNS = 0;
}
void utilDie(const char *fmt, ...) {
va_list args;
@ -270,6 +293,13 @@ char *utilGetUpToLastPathComponent(const char *pathname) {
}
// Whether the engine is on the virtual clock. Anything that has to behave differently for a
// reproducible run -- the disc's frame stepping, the frame loop's idle sleep -- asks here.
bool utilIsDeterministic(void) {
return _deterministic;
}
bool utilMkDirP(const char *dir, const mode_t mode) {
const char separator = utilGetPathSeparator();
char tmp[UTIL_PATH_MAX];
@ -464,6 +494,28 @@ char *utilStrndup(const char *s1, size_t n) {
}
// One frame of simulated time. The frame loop calls this once a frame, and it is the only thing
// that moves the virtual clock; on the real clock it does nothing at all.
void utilTickAdvance(void) {
if (_deterministic) {
_virtualTicksNS += _virtualStepNS;
}
}
// Milliseconds since the engine started, from whichever clock is running. Everything that paces
// the simulation reads the time here so one option settles all of it.
uint64_t utilTicks(void) {
return _deterministic ? (_virtualTicksNS / SDL_NS_PER_MS) : SDL_GetTicks();
}
// The same moment in nanoseconds, for the fixed step simulations.
uint64_t utilTicksNS(void) {
return _deterministic ? _virtualTicksNS : SDL_GetTicksNS();
}
void utilTrace(const char *fmt, ...) {
va_list args;

View file

@ -36,35 +36,40 @@
#define UTIL_PATH_MAX 1024
char *utilAbsolutePath(const char *path);
bool utilChangeDirectory(const char *path);
bool utilChMod(const char *path, const mode_t mode);
char *utilCreateString(const char *format, ...) __attribute__((format(printf, 1, 2)));
char *utilCreateStringVArgs(const char *format, va_list args) __attribute__((format(printf, 1, 0)));
void utilDie(const char *fmt, ...) __attribute__((format(printf, 1, 2))) __attribute__((noreturn));
void utilEnableConsole(bool enable);
bool utilFileExists(const char *filename);
void utilFixPathSeparators(char **path, bool slash);
bool utilGetConsoleEnabled(void);
char *utilGetFileExtension(const char *filename);
char *utilGetLastPathComponent(const char *pathname);
char utilGetPathSeparator(void);
char *utilGetUpToLastPathComponent(const char *pathname);
bool utilMkDirP(const char *dir, const mode_t mode);
void utilNewline(void);
bool utilPathExists(const char *pathname);
char *utilReadFile(const char *filename, size_t *bytes);
char *utilReadLine(const char *haystack, size_t length, const char **offset);
void utilRedirectConsole(void);
void utilSay(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
bool utilStartsWith(const char *string, const char *start);
int32_t utilStricmp(const char *a, const char *b);
char *utilStrndup(const char *s1, size_t n);
void utilTrace(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void utilTraceEnd(void);
void utilTraceStart(const char *filename);
void utilTraceVArgs(const char *fmt, va_list args) __attribute__((format(printf, 1, 0)));
void utilWaitForKeyOnWindows(void);
char *utilAbsolutePath(const char *path);
bool utilChangeDirectory(const char *path);
bool utilChMod(const char *path, const mode_t mode);
char *utilCreateString(const char *format, ...) __attribute__((format(printf, 1, 2)));
char *utilCreateStringVArgs(const char *format, va_list args) __attribute__((format(printf, 1, 0)));
void utilDeterministic(bool enable, uint32_t stepMs);
void utilDie(const char *fmt, ...) __attribute__((format(printf, 1, 2))) __attribute__((noreturn));
void utilEnableConsole(bool enable);
bool utilFileExists(const char *filename);
void utilFixPathSeparators(char **path, bool slash);
bool utilGetConsoleEnabled(void);
char *utilGetFileExtension(const char *filename);
char *utilGetLastPathComponent(const char *pathname);
char utilGetPathSeparator(void);
char *utilGetUpToLastPathComponent(const char *pathname);
bool utilIsDeterministic(void);
bool utilMkDirP(const char *dir, const mode_t mode);
void utilNewline(void);
bool utilPathExists(const char *pathname);
char *utilReadFile(const char *filename, size_t *bytes);
char *utilReadLine(const char *haystack, size_t length, const char **offset);
void utilRedirectConsole(void);
void utilSay(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
bool utilStartsWith(const char *string, const char *start);
int32_t utilStricmp(const char *a, const char *b);
char *utilStrndup(const char *s1, size_t n);
void utilTickAdvance(void);
uint64_t utilTicks(void);
uint64_t utilTicksNS(void);
void utilTrace(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void utilTraceEnd(void);
void utilTraceStart(const char *filename);
void utilTraceVArgs(const char *fmt, va_list args) __attribute__((format(printf, 1, 0)));
void utilWaitForKeyOnWindows(void);
#endif // UTIL_H

View file

@ -253,6 +253,7 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, co
static int _compareFrames(const void *a, const void *b); // qsort callback. Not changing int.
static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame);
static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want);
static void _decodeHere(VideoPlayerT *v);
static int _decoderThread(void *data); // SDL thread entry. Not changing int.
static void _feedAudio(VideoPlayerT *v);
static int64_t _findFrameIndex(VideoPlayerT *v, int64_t pts);
@ -658,7 +659,7 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *
char *indexName = _indexFileName(filename, indexPath);
int64_t capacity = 0;
int64_t pts = 0;
uint64_t started = SDL_GetTicks();
uint64_t started = SDL_GetTicks(); // Real: this times the indexing for the trace, not the simulation
int64_t lastPts = AV_NOPTS_VALUE;
int64_t duration = 1;
int64_t x = 0;
@ -890,6 +891,26 @@ static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) {
}
// Deterministic mode decodes the wanted frame here and now, so the texture always carries the frame
// the counter names instead of whatever the decoder thread had finished in time. That thread is
// never asked for anything then: it stays parked on its condition, and the codec has only this
// thread touching it.
static void _decodeHere(VideoPlayerT *v) {
DecodeResultE result = DECODE_NONE;
if ((v->frame == v->front.frame) || v->backReady) {
return;
}
result = _decodeFrame(v, v->frame);
if (result == DECODE_OK) {
v->back.frame = v->frame;
v->backReady = true;
} else if (result == DECODE_ERROR) {
v->threadError = true;
}
}
// Decodes whatever frame was last requested into the back buffer, then waits for the next request.
// Seeks decode forward from a keyframe, so this keeps long seeks off the main loop. A frame that
// cannot be decoded stays the pending one, so it is not asked for again until the clock moves on.
@ -1090,7 +1111,7 @@ static uint8_t *_lumaPlane(VideoPlayerT *v) {
// microseconds apart, then one per chunk period. The burst is the queue depth between the mixer
// and the speaker, which is what the audio clock has to subtract.
static void _measureDeviceQueue(void *udata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples) {
uint64_t now = SDL_GetTicks();
uint64_t now = SDL_GetTicks(); // Real: the audio device runs on real time whatever the simulation does
int64_t frames = samples / spec->channels;
(void)udata;
@ -1367,7 +1388,8 @@ static void _resetClock(VideoPlayerT *v, uint64_t now) {
}
// Positions the video demuxer at a keyframe and resets the decoder. Decoder thread only.
// Positions the video demuxer at a keyframe and resets the decoder. Only ever called by whichever
// thread is decoding: the decoder thread, or the frame loop in deterministic mode.
static void _seekVideo(VideoPlayerT *v, int64_t keyframe) {
if (av_seek_frame(v->videoFormat, v->videoStream, v->frames[keyframe].pts, AVSEEK_FLAG_BACKWARD) < 0) {
avformat_seek_file(v->videoFormat, v->videoStream, INT64_MIN, 0, INT64_MAX, 0);
@ -1451,7 +1473,7 @@ static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec
}
v->samplesPlayed += samples / spec->channels;
v->trackRate = spec->freq;
v->lastCallbackTicks = SDL_GetTicks();
v->lastCallbackTicks = SDL_GetTicks(); // Real: the audio clock is the device's, and the device keeps real time
v->audioClockValid = true;
}
@ -1759,6 +1781,7 @@ void videoInit(MIX_Mixer *mixer) {
_measureFrames = 0;
SDL_Delay(AUDIO_DRAIN_MS);
MIX_UnlockMixer(_mixer);
// Real: a timeout on a real wait for the device, and nothing is stepping a virtual clock here.
started = SDL_GetTicks();
while (_measuring && ((SDL_GetTicks() - started) < AUDIO_MEASURE_TIMEOUT_MS)) {
SDL_Delay(1);
@ -2078,7 +2101,7 @@ void videoUnlockAudio(void) {
// Advances playback to match the audio clock (or the wall clock for silent videos). Returns the frame now on the texture.
int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
VideoPlayerT *v = _getPlayer(playerHandle, "videoUpdate");
uint64_t now = SDL_GetTicks();
uint64_t now = utilTicks();
int64_t elapsed = 0;
int64_t count = v->frameCount;
int64_t next = 0;
@ -2088,32 +2111,47 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
_resetClock(v, now);
} else {
if (v->playing) {
// Where in the video should we be? Follow the audio when there is any.
if (v->audioSourceCount > 0) {
elapsed = _audioClock(v, now);
} else {
elapsed = (int64_t)(now - v->startTicks) + v->startTime;
}
// Advance to the last frame whose presentation time has arrived.
next = v->frame + 1;
while ((next < count) && (_frameTime(v, next) <= elapsed)) {
v->frame = next;
next++;
}
// Past the end of the last frame? Loop.
if (next >= count) {
lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num);
if (elapsed >= _frameTime(v, count - 1) + lastDuration) {
if (utilIsDeterministic()) {
// Decoding has a clock of its own, which no virtual clock reaches, so a
// reproducible run steps the disc a frame at a time from wherever it is
// parked. A paused disc and a search still settle where they always did.
v->frame++;
if (v->frame >= count) {
v->frame = 0;
_resetClock(v, now);
}
} else {
// Where in the video should we be? Follow the audio when there is any.
if (v->audioSourceCount > 0) {
elapsed = _audioClock(v, now);
} else {
elapsed = (int64_t)(now - v->startTicks) + v->startTime;
}
// Advance to the last frame whose presentation time has arrived.
next = v->frame + 1;
while ((next < count) && (_frameTime(v, next) <= elapsed)) {
v->frame = next;
next++;
}
// Past the end of the last frame? Loop.
if (next >= count) {
lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num);
if (elapsed >= _frameTime(v, count - 1) + lastDuration) {
v->frame = 0;
_resetClock(v, now);
}
}
}
}
}
// Hand the decoder the frame we want, and show whatever it has finished. A flash left on the
// texture comes off here even when nothing new was decoded, so it cannot stick on a still disc.
_requestFrame(v);
if (utilIsDeterministic()) {
_decodeHere(v);
} else {
_requestFrame(v);
}
if (_takeDecodedFrame(v) || v->flashed) {
_uploadFrame(v);
}