From 07e55935cf49c94a94dcc75d8240d0733d1ed5d7 Mon Sep 17 00:00:00 2001 From: Vas Crabb Date: Sat, 6 Nov 2021 05:20:59 +1100 Subject: [PATCH] plugins: Rewrote timer plugin fixing multiple issues. Added emulated time recording as well as wall clock time. Fixed recording time for multiple software items per system. An incorrect constraint on the database table meant that time was only being recorded for a single software item per system. Detect the "empty" driver so the time spent at the selection menu isn't recorded (you'd get multiple entries for this due to the way options leak when returning to the system selection menu). Included schema migration code to update existing timer plugin databases. Also replaced some unnecessary floating point code with integer maths, added log messages, and made the plugin unload unload its database access code during emulation. Changed other plugins' use of paths with trailing slashes as this causes stat to fail on Windows. --- docs/source/plugins/timer.rst | 16 +- plugins/autofire/autofire_save.lua | 8 +- plugins/hiscore/init.lua | 12 +- plugins/inputmacro/inputmacro_persist.lua | 6 +- plugins/timecode/init.lua | 6 +- plugins/timer/init.lua | 126 ++++------- plugins/timer/plugin.json | 16 +- plugins/timer/timer_persist.lua | 249 ++++++++++++++++++++++ 8 files changed, 325 insertions(+), 114 deletions(-) create mode 100644 plugins/timer/timer_persist.lua diff --git a/docs/source/plugins/timer.rst b/docs/source/plugins/timer.rst index 9576f0acc20..578dd2beb50 100644 --- a/docs/source/plugins/timer.rst +++ b/docs/source/plugins/timer.rst @@ -1,7 +1,7 @@ .. _plugins-timer: -Timer Plugin -============ +Game Play Timer Plugin +====================== The timer plugin records the total time spent emulating each combination of a system and a software list item, as well as the number of times each combination @@ -9,12 +9,12 @@ has been launched. To see the statistics, bring up the main menu (press **Tab** during emulation by default), choose **Plugin Options**, and then choose **Timer**. -Note that this plugin records wall clock time, not emulated time. That is, it -records the real time duration elapsed while the emulation is running, according -to the host OS. This may be shorter than the elapsed emulated time if you turn -off throttling or use MAME’s “fast forward” feature, or it may be longer than -the elapsed emulated time if you pause the emulation of if the emulation is too -demanding to run at full speed. +This plugin records wall clock time (the real time duration elapsed while +emulation is running, according to the host OS) as well as emulated time. The +elapsed wall clock time may be shorter than the elapsed emulated time if you +turn off throttling or use MAME’s “fast forward” feature, or it may be longer +than the elapsed emulated time if you pause the emulation of if the emulation is +too demanding to run at full speed. The statistics are stored in the file **timer.db** in the **timer** folder inside your plugin data folder (see the diff --git a/plugins/autofire/autofire_save.lua b/plugins/autofire/autofire_save.lua index 1ebe06561e1..5ca4cd217b6 100644 --- a/plugins/autofire/autofire_save.lua +++ b/plugins/autofire/autofire_save.lua @@ -1,7 +1,7 @@ local lib = {} local function get_settings_path() - return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/autofire/' + return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/autofire' end local function get_settings_filename() @@ -52,7 +52,7 @@ end function lib:load_settings() local buttons = {} local json = require('json') - local filename = get_settings_path() .. get_settings_filename() + local filename = get_settings_path() .. '/' .. get_settings_filename() local file = io.open(filename, 'r') if not file then return buttons @@ -81,14 +81,14 @@ function lib:save_settings(buttons) emu.print_error(string.format('Error saving autofire settings: "%s" is not a directory', path)) return end + local filename = path .. '/' .. get_settings_filename() if #buttons == 0 then - os.remove(path .. get_settings_filename()) + os.remove(filename) return end local json = require('json') local settings = serialize_settings(buttons) local data = json.stringify(settings, {indent = true}) - local filename = path .. get_settings_filename() local file = io.open(filename, 'w') if not file then emu.print_error(string.format('Error saving autofire settings: error opening file "%s" for writing', filename)) diff --git a/plugins/hiscore/init.lua b/plugins/hiscore/init.lua index 4101dd48708..652061c92cf 100644 --- a/plugins/hiscore/init.lua +++ b/plugins/hiscore/init.lua @@ -23,7 +23,7 @@ end function hiscore.startplugin() local function get_data_path() - return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/hiscore/' + return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/hiscore' end -- configuration @@ -35,7 +35,7 @@ function hiscore.startplugin() if config_read then return true end - local filename = get_data_path() .. 'plugin.cfg' + local filename = get_data_path() .. '/plugin.cfg' local file = io.open(filename, 'r') if file then local json = require('json') @@ -67,7 +67,7 @@ function hiscore.startplugin() end local settings = { only_save_at_exit = not timed_save } -- TODO: other settings? - local filename = path .. 'plugin.cfg' + local filename = path .. '/plugin.cfg' local json = require('json') local data = json.stringify(settings, { indent = true }) local file = io.open(filename, 'w') @@ -210,9 +210,9 @@ function hiscore.startplugin() local r; if emu.softname() ~= "" then local soft = emu.softname():match("([^:]*)$") - r = get_data_path() .. emu.romname() .. "_" .. soft .. ".hi"; + r = get_data_path() .. '/' .. emu.romname() .. "_" .. soft .. ".hi"; else - r = get_data_path() .. emu.romname() .. ".hi"; + r = get_data_path() .. '/' .. emu.romname() .. ".hi"; end return r; end @@ -223,7 +223,7 @@ function hiscore.startplugin() local output = io.open(get_file_name(), "wb"); if not output then -- attempt to create the directory, and try again - lfs.mkdir( get_data_path() ); + lfs.mkdir(get_data_path()); output = io.open(get_file_name(), "wb"); end emu.print_verbose("hiscore: write_scores output") diff --git a/plugins/inputmacro/inputmacro_persist.lua b/plugins/inputmacro/inputmacro_persist.lua index 4e043852c51..6c2aa6fd188 100644 --- a/plugins/inputmacro/inputmacro_persist.lua +++ b/plugins/inputmacro/inputmacro_persist.lua @@ -5,7 +5,7 @@ -- Helpers local function settings_path() - return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/inputmacro/' + return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/inputmacro' end local function settings_filename() @@ -101,7 +101,7 @@ end local lib = { } function lib:load_settings() - filename = settings_path() .. settings_filename() + filename = settings_path() .. '/' .. settings_filename() local file = io.open(filename, 'r') if not file then return { } @@ -133,7 +133,7 @@ function lib:save_settings(macros) emu.print_error(string.format('Error saving input macros: "%s" is not a directory', path)) return end - filename = path .. settings_filename() + filename = path .. '/' .. settings_filename() if #macros == 0 then os.remove(filename) diff --git a/plugins/timecode/init.lua b/plugins/timecode/init.lua index 084d3b3e8d6..09e49229cae 100644 --- a/plugins/timecode/init.lua +++ b/plugins/timecode/init.lua @@ -34,7 +34,7 @@ function timecode.startplugin() local function get_settings_path() - return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/timecode/' + return emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/timecode' end @@ -50,7 +50,7 @@ function timecode.startplugin() set_default_hotkey() -- try to open configuration file - local cfgname = get_settings_path() .. 'plugin.cfg' + local cfgname = get_settings_path() .. '/plugin.cfg' local cfgfile = io.open(cfgname, 'r') if not cfgfile then return -- probably harmless, configuration just doesn't exist yet @@ -97,7 +97,7 @@ function timecode.startplugin() settings.hotkey = hotkey_cfg end local data = json.stringify(settings, { indent = true }) - local cfgname = path .. 'plugin.cfg' + local cfgname = path .. '/plugin.cfg' local cfgfile = io.open(cfgname, 'w') if not cfgfile then emu.print_error(string.format('Error saving timecode recorder settings: error opening file "%s" for writing', cfgname)) diff --git a/plugins/timer/init.lua b/plugins/timer/init.lua index 19755f7a348..d39243da730 100644 --- a/plugins/timer/init.lua +++ b/plugins/timer/init.lua @@ -1,111 +1,73 @@ -- license:BSD-3-Clause --- copyright-holders:Carl -require('lfs') -local sqlite3 = require('lsqlite3') -local exports = {} -exports.name = "timer" -exports.version = "0.0.2" -exports.description = "Game play timer" -exports.license = "BSD-3-Clause" -exports.author = { name = "Carl" } +-- copyright-holders:Vas Crabb +local exports = { + name = 'timer', + version = '0.0.3', + description = 'Game play timer', + license = 'BSD-3-Clause', + author = { name = 'Vas Crabb' } } local timer = exports function timer.startplugin() - local dir = emu.subst_env(manager.options.entries.homepath:value()) - local timer_db = dir .. "/timer/timer.db" - local timer_started = false local total_time = 0 local start_time = 0 local play_count = 0 + local emu_total = emu.attotime() - local function save() - total_time = total_time + (os.time() - start_time) + local reference = 0 + local lastupdate - local db = assert(sqlite3.open(timer_db)) - - local insert_stmt = assert( db:prepare("INSERT OR IGNORE INTO timer VALUES (?, ?, 0, 0)") ) - insert_stmt:bind_values(emu.romname(), emu.softname()) - insert_stmt:step() - insert_stmt:reset() - - local update_stmt = assert( db:prepare("UPDATE timer SET total_time=?, play_count=? WHERE driver=? AND software=?") ) - update_stmt:bind_values(total_time, play_count,emu.romname(), emu.softname()) - update_stmt:step() - update_stmt:reset() - - assert(db:close() == sqlite3.OK) - end - - - emu.register_start(function() - local file - if timer_started then - save() - end - timer_started = true - lfs.mkdir(dir .. '/timer') - local db = assert(sqlite3.open(timer_db)) - local found=false - db:exec([[select * from sqlite_master where name='timer';]], function(...) found=true return 0 end) - if not found then - db:exec[[ CREATE TABLE timer ( - driver VARCHAR(32) PRIMARY KEY, - software VARCHAR(40), - total_time INTEGER NOT NULL, - play_count INTEGER NOT NULL - ); ]] - end - - local stmt, row - stmt = db:prepare("SELECT total_time, play_count FROM timer WHERE driver = ? AND software = ?") - stmt:bind_values(emu.romname(), emu.softname()) - if (stmt:step() == sqlite3.ROW) then - row = stmt:get_named_values() - play_count = row.play_count - total_time = row.total_time - else - play_count = 0 - total_time = 0 - end - - assert(db:close() == sqlite3.OK) - - start_time = os.time() - play_count = play_count + 1 - end) - - emu.register_stop(function() - timer_started = false - save() - total_time = 0 - play_count = 0 - end) local function sectohms(time) - local hrs = math.floor(time / 3600) - local min = math.floor((time % 3600) / 60) + local hrs = time // 3600 + local min = (time % 3600) // 60 local sec = time % 60 - return string.format(_p("plugin-timer", "%03d:%02d:%02d"), hrs, min, sec) + return string.format(_p('plugin-timer', '%03d:%02d:%02d'), hrs, min, sec) end - local lastupdate - local function menu_populate() lastupdate = os.time() - local time = lastupdate - start_time + local refname = (reference == 0) and _p('plugin-timer', 'Wall clock') or _p('plugin-timer', 'Emulated time') + local time = (reference == 0) and (lastupdate - start_time) or manager.machine.time.seconds + local total = (reference == 0) and (total_time + time) or (manager.machine.time + emu_total).seconds return - {{ _p("plugin-timer", "Current time"), sectohms(time), "off" }, - { _p("plugin-timer", "Total time"), sectohms(total_time + time), "off" }, - { _p("plugin-timer", "Play Count"), play_count, "off" }}, + { + { _p("plugin-timer", "Reference"), refname, (reference == 0) and 'r' or 'l' }, + { '---', '', '' }, + { _p("plugin-timer", "Current time"), sectohms(time), "off" }, + { _p("plugin-timer", "Total time"), sectohms(total), "off" }, + { _p("plugin-timer", "Play Count"), play_count, "off" } }, nil, "idle" end local function menu_callback(index, event) + if (index == 1) and ((event == 'left') or (event == 'right') or (event == 'select')) then + reference = reference ~ 1 + return true + end return os.time() > lastupdate end + + emu.register_start( + function() + if emu.romname() ~= '___empty' then + start_time = os.time() + local persister = require('timer/timer_persist') + total_time, play_count, emu_total = persister:start_session() + end + end) + + emu.register_stop( + function() + if emu.romname() ~= '___empty' then + local persister = require('timer/timer_persist') + persister:update_totals(start_time) + end + end) + emu.register_menu(menu_callback, menu_populate, _p("plugin-timer", "Timer")) end diff --git a/plugins/timer/plugin.json b/plugins/timer/plugin.json index 005a0fad9a4..6755f434f94 100644 --- a/plugins/timer/plugin.json +++ b/plugins/timer/plugin.json @@ -1,10 +1,10 @@ { - "plugin": { - "name": "timer", - "description": "Timer plugin", - "version": "0.0.1", - "author": "Carl", - "type": "plugin", - "start": "false" - } + "plugin": { + "name": "timer", + "description": "Game play timer", + "version": "0.0.3", + "author": "Vas Crabb", + "type": "plugin", + "start": "false" + } } diff --git a/plugins/timer/timer_persist.lua b/plugins/timer/timer_persist.lua new file mode 100644 index 00000000000..5ed33f03eb4 --- /dev/null +++ b/plugins/timer/timer_persist.lua @@ -0,0 +1,249 @@ +-- license:BSD-3-Clause +-- copyright-holders:Vas Crabb + +local sqlite3 = require('lsqlite3') + + +local function check_schema(db) + local create_statement = + [[CREATE TABLE timer ( + driver VARCHAR(32) NOT NULL, + softlist VARCHAR(24) NOT NULL DEFAULT '', + software VARCHAR(16) NOT NULL DEFAULT '', + total_time INTEGER NOT NULL DEFAULT 0, + play_count INTEGER NOT NULL DEFAULT 0, + emu_sec INTEGER NOT NULL DEFAULT 0, + emu_nsec INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (driver, softlist, software));]] + + -- create table if it doesn't exist yet + local table_found = false + db:exec( + [[SELECT * FROM sqlite_master WHERE type = 'table' AND name='timer';]], + function() + table_found = true + end) + if not table_found then + emu.print_verbose('Creating timer database table') + db:exec(create_statement) + return + end + + -- check recently added columns + local have_softlist = false + local have_emu_sec = false + local have_emu_nsec = false + db:exec( + [[PRAGMA table_info(timer);]], + function(udata, n, vals, cols) + for i, name in ipairs(cols) do + if name == 'name' then + if vals[i] == 'softlist' then + have_softlist = true + elseif vals[i] == 'emu_sec' then + have_emu_sec = true + elseif vals[i] == 'emu_nsec' then + have_emu_nsec = true + end + return 0 + end + end + return 0 + end) + if not have_softlist then + emu.print_verbose('Adding softlist column to timer database') + db:exec([[ALTER TABLE timer ADD COLUMN softlist VARCHAR(24) NOT NULL DEFAULT '';]]) + local to_split = { } + db:exec( + [[SELECT DISTINCT software FROM timer WHERE software LIKE '%:%';]], + function(udata, n, vals) + table.insert(to_split, vals[1]) + return 0 + end) + if #to_split > 0 then + local update = db:prepare([[UPDATE timer SET softlist = ?, software = ? WHERE software = ?;]]) + for i, softname in ipairs(to_split) do + local x, y = softname:find(':') + local softlist = softname:sub(1, x - 1) + local software = softname:sub(y + 1) + update:bind_values(softlist, software, softname) + update:step() + update:reset() + end + end + end + if not have_emu_sec then + emu.print_verbose('Adding emu_sec column to timer database') + db:exec([[ALTER TABLE timer ADD COLUMN emu_sec INTEGER NOT NULL DEFAULT 0;]]) + end + if not have_emu_nsec then + emu.print_verbose('Adding emu_nsec column to timer database') + db:exec([[ALTER TABLE timer ADD COLUMN emu_nsec INTEGER NOT NULL DEFAULT 0;]]) + end + + -- check the required columns are in the primary key + local index_name + db:exec( + [[SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'timer';]], + function(udata, n, vals) + index_name = vals[1] + end) + local index_good + if index_name then + local driver_indexed = false + local softlist_indexed = false + local software_indexed = false + db:exec( + string.format([[PRAGMA index_info('%s');]], index_name), -- can't use prepared statement for PRAGMA + function(udata, n, vals, cols) + for i, name in ipairs(cols) do + if name == 'name' then + if vals[i] == 'driver' then + driver_indexed = true + elseif vals[i] == 'softlist' then + softlist_indexed = true + elseif vals[i] == 'software' then + software_indexed = true + end + return 0 + end + end + return 0 + end) + index_good = driver_indexed and softlist_indexed and software_indexed + end + + -- if the required columns are not indexed, migrate to a new table with desired primary key + if not index_good then + emu.print_verbose('Re-indexing timer database table') + db:exec([[DROP TABLE IF EXISTS timer_old;]]) + db:exec([[ALTER TABLE timer RENAME TO timer_old;]]) + db:exec(create_statement) + db:exec( + [[INSERT + INTO timer (driver, softlist, software, total_time, play_count, emu_sec, emu_nsec) + SELECT driver, softlist, software, total_time, play_count, emu_sec, emu_nsec FROM timer_old;]]) + db:exec([[DROP TABLE timer_old;]]) + end +end + + +local function open_database() + -- make sure settings directory exists + local dir = emu.subst_env(manager.machine.options.entries.homepath:value():match('([^;]+)')) .. '/timer' + local attr = lfs.attributes(dir) + if not attr then + lfs.mkdir(dir) + elseif attr.mode ~= 'directory' then + emu.print_error(string.format('Error opening timer database: "%s" is not a directory', dir)) + return nil + end + + -- open database + local filename = dir .. '/timer.db' + local db = sqlite3.open(filename) + if not db then + emu.print_error(string.format('Error opening timer database file "%s"', filename)) + return nil + end + + -- make sure schema is up-to-date + check_schema(db) + return db +end + + +local function get_software() + local softname = emu.softname() + local i, j = softname:find(':') + if i then + return softname:sub(1, i - 1), softname:sub(j + 1) + else + -- FIXME: need a way to get the implicit software list when no colon in the option value + return '', softname + end +end + + +local function get_current(db) + local statement = db:prepare( + [[SELECT + total_time, play_count, emu_sec, emu_nsec + FROM timer + WHERE driver = ? AND softlist = ? AND software = ?;]]) + statement:bind_values(emu.romname(), get_software()) + local result + if statement:step() == sqlite3.ROW then + result = statement:get_named_values() + end + statement:reset() + return result +end + + +local lib = { } + +function lib:start_session() + -- open database + local db = open_database() + if not db then + return 0, 0, emu.attotime() + end + + -- get existing values + local row = get_current(db) + local update + if row then + update = db:prepare( + [[UPDATE timer + SET play_count = play_count + 1 + WHERE driver = ? AND softlist = ? AND software = ?;]]) + else + row = { total_time = 0, play_count = 0, emu_sec = 0, emu_nsec = 0 } + update = db:prepare( + [[INSERT + INTO timer (driver, softlist, software, total_time, play_count, emu_sec, emu_nsec) + VALUES (?, ?, ?, 0, 1, 0, 0);]]) + end + update:bind_values(emu.romname(), get_software()) + update:step() + update:reset() + return row.total_time, row.play_count + 1, emu.attotime.from_seconds(row.emu_sec) + emu.attotime.from_nsec(row.emu_nsec) +end + +function lib:update_totals(start) + -- open database + local db = open_database() + if not db then + return + end + + -- get existing values + local row = get_current(db) + if not row then + row = { total_time = 0, play_count = 1, emu_sec = 0, emu_nsec = 0 } + end + + -- calculate new totals + local emu_total = emu.attotime.from_seconds(row.emu_sec) + emu.attotime.from_nsec(row.emu_nsec) + manager.machine.time + row.total_time = os.time() - start + row.total_time + row.emu_sec = emu_total.seconds + row.emu_nsec = emu_total.nsec + + -- update database + local update = db:prepare( + [[INSERT OR REPLACE + INTO timer (driver, softlist, software, total_time, play_count, emu_sec, emu_nsec) + VALUES (?, ?, ?, ?, ?, ?, ?);]]) + local softlist, software = get_software() + update:bind_values(emu.romname(), softlist, software, row.total_time, row.play_count, row.emu_sec, row.emu_nsec) + update:step() + update:reset() + + -- close database + if db:close() ~= sqlite3.OK then + emu.print_error('Error closing timer database') + end +end + +return lib