Deterministic playback for debugging.
This commit is contained in:
parent
d024a50c8d
commit
caac38336c
15 changed files with 319 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
27
src/main.c
27
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 <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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
63
src/singe.c
63
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,8 +14483,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
|
|||
_global.refreshDisplay = false;
|
||||
}
|
||||
|
||||
// Real time is meaningless on the virtual clock, so a reproducible run does not wait.
|
||||
if (!utilIsDeterministic()) {
|
||||
SDL_Delay(IDLE_SLEEP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// End game
|
||||
_progTrace("Script is shutting down");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
src/util.c
52
src/util.c
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ 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);
|
||||
|
|
@ -50,6 +51,7 @@ 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);
|
||||
|
|
@ -60,6 +62,9 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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,6 +2111,16 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
|
|||
_resetClock(v, now);
|
||||
} else {
|
||||
if (v->playing) {
|
||||
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);
|
||||
|
|
@ -2110,10 +2143,15 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (utilIsDeterministic()) {
|
||||
_decodeHere(v);
|
||||
} else {
|
||||
_requestFrame(v);
|
||||
}
|
||||
if (_takeDecodedFrame(v) || v->flashed) {
|
||||
_uploadFrame(v);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue