diff --git a/CHANGELOG b/CHANGELOG index 6ea543d95..a8a8c3974 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/assets/settings.cfg b/assets/settings.cfg index 02469e8d5..c7ac0ced9 100644 --- a/assets/settings.cfg +++ b/assets/settings.cfg @@ -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. diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 7c9843a05..f5eac8497 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -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 <>. 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 `.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 <>. 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 <>. 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 diff --git a/src/gui.cpp b/src/gui.cpp index 8f2c7b741..439d1772c 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -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; } diff --git a/src/main.c b/src/main.c index a852ae9ef..23ac8961c 100644 --- a/src/main.c +++ b/src/main.c @@ -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 .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; diff --git a/src/main.h b/src/main.h index 0ae325cce..eb051a0ce 100644 --- a/src/main.h +++ b/src/main.h @@ -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); diff --git a/src/model.c b/src/model.c index 18137e292..58f931174 100644 --- a/src/model.c +++ b/src/model.c @@ -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; diff --git a/src/navRecast.cpp b/src/navRecast.cpp index 671455d4a..84fd5e341 100644 --- a/src/navRecast.cpp +++ b/src/navRecast.cpp @@ -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; diff --git a/src/particles.c b/src/particles.c index 4504ddd3c..a6f927676 100644 --- a/src/particles.c +++ b/src/particles.c @@ -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)) { diff --git a/src/physicsJolt.cpp b/src/physicsJolt.cpp index 1ac8c99d8..5be2ff17f 100644 --- a/src/physicsJolt.cpp +++ b/src/physicsJolt.cpp @@ -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; } diff --git a/src/singe.c b/src/singe.c index f6c1dd1a2..cfd61fc22 100644 --- a/src/singe.c +++ b/src/singe.c @@ -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 diff --git a/src/singe.h b/src/singe.h index 450407e3f..8ef463d3a 100644 --- a/src/singe.h +++ b/src/singe.h @@ -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 diff --git a/src/util.c b/src/util.c index 517f3a152..4411f597e 100644 --- a/src/util.c +++ b/src/util.c @@ -43,6 +43,8 @@ #include #include +#include + #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; diff --git a/src/util.h b/src/util.h index 0b6b70b7e..14c5fadae 100644 --- a/src/util.h +++ b/src/util.h @@ -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 diff --git a/src/videoPlayer.c b/src/videoPlayer.c index 871683818..b2827f4d2 100644 --- a/src/videoPlayer.c +++ b/src/videoPlayer.c @@ -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); }