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.
This commit is contained in:
Vas Crabb 2021-11-06 05:20:59 +11:00
parent 3bb8e3adc7
commit 07e55935cf
8 changed files with 325 additions and 114 deletions

View File

@ -1,7 +1,7 @@
.. _plugins-timer: .. _plugins-timer:
Timer Plugin Game Play Timer Plugin
============ ======================
The timer plugin records the total time spent emulating each combination of a 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 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 during emulation by default), choose **Plugin Options**, and then choose
**Timer**. **Timer**.
Note that this plugin records wall clock time, not emulated time. That is, it This plugin records wall clock time (the real time duration elapsed while
records the real time duration elapsed while the emulation is running, according emulation is running, according to the host OS) as well as emulated time. The
to the host OS. This may be shorter than the elapsed emulated time if you turn elapsed wall clock time may be shorter than the elapsed emulated time if you
off throttling or use MAMEs “fast forward” feature, or it may be longer than turn off throttling or use MAMEs “fast forward” feature, or it may be longer
the elapsed emulated time if you pause the emulation of if the emulation is too than the elapsed emulated time if you pause the emulation of if the emulation is
demanding to run at full speed. too demanding to run at full speed.
The statistics are stored in the file **timer.db** in the **timer** folder The statistics are stored in the file **timer.db** in the **timer** folder
inside your plugin data folder (see the inside your plugin data folder (see the

View File

@ -1,7 +1,7 @@
local lib = {} local lib = {}
local function get_settings_path() 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 end
local function get_settings_filename() local function get_settings_filename()
@ -52,7 +52,7 @@ end
function lib:load_settings() function lib:load_settings()
local buttons = {} local buttons = {}
local json = require('json') 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') local file = io.open(filename, 'r')
if not file then if not file then
return buttons 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)) emu.print_error(string.format('Error saving autofire settings: "%s" is not a directory', path))
return return
end end
local filename = path .. '/' .. get_settings_filename()
if #buttons == 0 then if #buttons == 0 then
os.remove(path .. get_settings_filename()) os.remove(filename)
return return
end end
local json = require('json') local json = require('json')
local settings = serialize_settings(buttons) local settings = serialize_settings(buttons)
local data = json.stringify(settings, {indent = true}) local data = json.stringify(settings, {indent = true})
local filename = path .. get_settings_filename()
local file = io.open(filename, 'w') local file = io.open(filename, 'w')
if not file then if not file then
emu.print_error(string.format('Error saving autofire settings: error opening file "%s" for writing', filename)) emu.print_error(string.format('Error saving autofire settings: error opening file "%s" for writing', filename))

View File

@ -23,7 +23,7 @@ end
function hiscore.startplugin() function hiscore.startplugin()
local function get_data_path() 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 end
-- configuration -- configuration
@ -35,7 +35,7 @@ function hiscore.startplugin()
if config_read then if config_read then
return true return true
end end
local filename = get_data_path() .. 'plugin.cfg' local filename = get_data_path() .. '/plugin.cfg'
local file = io.open(filename, 'r') local file = io.open(filename, 'r')
if file then if file then
local json = require('json') local json = require('json')
@ -67,7 +67,7 @@ function hiscore.startplugin()
end end
local settings = { only_save_at_exit = not timed_save } local settings = { only_save_at_exit = not timed_save }
-- TODO: other settings? -- TODO: other settings?
local filename = path .. 'plugin.cfg' local filename = path .. '/plugin.cfg'
local json = require('json') local json = require('json')
local data = json.stringify(settings, { indent = true }) local data = json.stringify(settings, { indent = true })
local file = io.open(filename, 'w') local file = io.open(filename, 'w')
@ -210,9 +210,9 @@ function hiscore.startplugin()
local r; local r;
if emu.softname() ~= "" then if emu.softname() ~= "" then
local soft = emu.softname():match("([^:]*)$") local soft = emu.softname():match("([^:]*)$")
r = get_data_path() .. emu.romname() .. "_" .. soft .. ".hi"; r = get_data_path() .. '/' .. emu.romname() .. "_" .. soft .. ".hi";
else else
r = get_data_path() .. emu.romname() .. ".hi"; r = get_data_path() .. '/' .. emu.romname() .. ".hi";
end end
return r; return r;
end end

View File

@ -5,7 +5,7 @@
-- Helpers -- Helpers
local function settings_path() 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 end
local function settings_filename() local function settings_filename()
@ -101,7 +101,7 @@ end
local lib = { } local lib = { }
function lib:load_settings() function lib:load_settings()
filename = settings_path() .. settings_filename() filename = settings_path() .. '/' .. settings_filename()
local file = io.open(filename, 'r') local file = io.open(filename, 'r')
if not file then if not file then
return { } 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)) emu.print_error(string.format('Error saving input macros: "%s" is not a directory', path))
return return
end end
filename = path .. settings_filename() filename = path .. '/' .. settings_filename()
if #macros == 0 then if #macros == 0 then
os.remove(filename) os.remove(filename)

View File

@ -34,7 +34,7 @@ function timecode.startplugin()
local function get_settings_path() 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 end
@ -50,7 +50,7 @@ function timecode.startplugin()
set_default_hotkey() set_default_hotkey()
-- try to open configuration file -- 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') local cfgfile = io.open(cfgname, 'r')
if not cfgfile then if not cfgfile then
return -- probably harmless, configuration just doesn't exist yet return -- probably harmless, configuration just doesn't exist yet
@ -97,7 +97,7 @@ function timecode.startplugin()
settings.hotkey = hotkey_cfg settings.hotkey = hotkey_cfg
end end
local data = json.stringify(settings, { indent = true }) local data = json.stringify(settings, { indent = true })
local cfgname = path .. 'plugin.cfg' local cfgname = path .. '/plugin.cfg'
local cfgfile = io.open(cfgname, 'w') local cfgfile = io.open(cfgname, 'w')
if not cfgfile then if not cfgfile then
emu.print_error(string.format('Error saving timecode recorder settings: error opening file "%s" for writing', cfgname)) emu.print_error(string.format('Error saving timecode recorder settings: error opening file "%s" for writing', cfgname))

View File

@ -1,111 +1,73 @@
-- license:BSD-3-Clause -- license:BSD-3-Clause
-- copyright-holders:Carl -- copyright-holders:Vas Crabb
require('lfs') local exports = {
local sqlite3 = require('lsqlite3') name = 'timer',
local exports = {} version = '0.0.3',
exports.name = "timer" description = 'Game play timer',
exports.version = "0.0.2" license = 'BSD-3-Clause',
exports.description = "Game play timer" author = { name = 'Vas Crabb' } }
exports.license = "BSD-3-Clause"
exports.author = { name = "Carl" }
local timer = exports local timer = exports
function timer.startplugin() 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 total_time = 0
local start_time = 0 local start_time = 0
local play_count = 0 local play_count = 0
local emu_total = emu.attotime()
local function save() local reference = 0
total_time = total_time + (os.time() - start_time) 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 function sectohms(time)
local hrs = math.floor(time / 3600) local hrs = time // 3600
local min = math.floor((time % 3600) / 60) local min = (time % 3600) // 60
local sec = time % 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 end
local lastupdate
local function menu_populate() local function menu_populate()
lastupdate = os.time() 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 return
{{ _p("plugin-timer", "Current time"), sectohms(time), "off" }, {
{ _p("plugin-timer", "Total time"), sectohms(total_time + time), "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" } }, { _p("plugin-timer", "Play Count"), play_count, "off" } },
nil, nil,
"idle" "idle"
end end
local function menu_callback(index, event) 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 return os.time() > lastupdate
end 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")) emu.register_menu(menu_callback, menu_populate, _p("plugin-timer", "Timer"))
end end

View File

@ -1,9 +1,9 @@
{ {
"plugin": { "plugin": {
"name": "timer", "name": "timer",
"description": "Timer plugin", "description": "Game play timer",
"version": "0.0.1", "version": "0.0.3",
"author": "Carl", "author": "Vas Crabb",
"type": "plugin", "type": "plugin",
"start": "false" "start": "false"
} }

View File

@ -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