-- hiscore.lua -- by borgar@borgar.net, CC0 license -- -- This uses MAME's built-in Lua scripting to implement -- high-score saving with hiscore.dat infom just as older -- builds did in the past. -- local exports = { name = 'hiscore', version = '1.0.1', description = 'Hiscore', license = 'CC0', author = { name = 'borgar@borgar.net' } } local hiscore = exports local hiscore_plugin_path local reset_subscription, frame_subscription, stop_subscription function hiscore.set_folder(path) hiscore_plugin_path = path end function hiscore.startplugin() local function get_data_path() return manager.machine.options.entries.homepath:value():match('([^;]+)') .. '/hiscore' end -- configuration local config_read = false local timed_save = true -- read configuration file from data directory local function read_config() if config_read then return true end local filename = get_data_path() .. '/plugin.cfg' local file = io.open(filename, 'r') if file then local json = require('json') local parsed_settings = json.parse(file:read('a')) file:close() if parsed_settings then if parsed_settings.only_save_at_exit and (parsed_settings.only_save_at_exit ~= 0) then timed_save = false end -- TODO: other settings? maybe path overrides for hiscore.dat or the hiscore data? config_read = true return true else emu.print_error(string.format('Error loading hiscore plugin settings: error parsing file "%s" as JSON', filename)) end end return false end -- save configuration file local function save_config() local path = get_data_path() local attr = lfs.attributes(path) if not attr then lfs.mkdir(path) elseif attr.mode ~= 'directory' then emu.print_error(string.format('Error saving hiscore plugin settings: "%s" is not a directory', path)) return end local settings = { only_save_at_exit = not timed_save } -- TODO: other settings? local filename = path .. '/plugin.cfg' local json = require('json') local data = json.stringify(settings, { indent = true }) local file = io.open(filename, 'w') if not file then emu.print_error(string.format('Error saving hiscore plugin settings: error opening file "%s" for writing', filename)) return end file:write(data) file:close() end -- build menu local function populate_menu() local items = { } local setting = timed_save and _p('plugin-hiscore', 'When updated') or _p('plugin-hiscore', 'On exit') table.insert(items, { _p('plugin-hiscore', 'Hiscore Support Options'), '', 'off' }) table.insert(items, { '---', '', '' }) table.insert(items, { _p('plugin-hiscore', 'Save scores'), setting, timed_save and 'l' or 'r' }) return items end -- handle menu events local function handle_menu(index, event) if event == 'left' then timed_save = false return true elseif event == 'right' then timed_save = true return true end return false end local hiscoredata_path = "hiscore.dat"; local current_checksum = 0; local default_checksum = 0; local scores_have_been_read = false; local mem_check_passed = false; local found_hiscore_entry = false; local delaytime = 0; local function parse_table ( dsting ) local _table = {} for line in string.gmatch(dsting, '([^\n]+)') do local delay = line:match('^@delay=([.%d]*)') if delay and #delay > 0 then delaytime = emu.time() + tonumber(delay) else local cpu, mem; local cputag, space, offs, len, chk_st, chk_ed, fill = string.match(line, '^@([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),?(%x?%x?)'); cpu = manager.machine.devices[cputag]; if not cpu then error(cputag .. " device not found") end local rgnname, rgntype = space:match("([^/]*)/?([^/]*)") if rgntype == "share" then mem = manager.machine.memory.shares[rgnname] else mem = cpu.spaces[space] end if not mem then error(space .. " space not found") end _table[ #_table + 1 ] = { mem = mem, addr = tonumber(offs, 16), size = tonumber(len, 16), c_start = tonumber(chk_st, 16), c_end = tonumber(chk_ed, 16), fill = tonumber(fill, 16) }; end end return _table; end local function read_hiscore_dat () local file = io.open( hiscoredata_path, "r" ); local rm_match; if not file then file = io.open( hiscore_plugin_path .. "/hiscore.dat", "r" ); end if emu.softname() ~= "" then local soft = emu.softname():match("([^:]*)$") rm_match = '^' .. emu.romname() .. ',' .. soft .. ':'; else rm_match = '^' .. emu.romname() .. ':'; end local cluster = ""; local current_is_match = false; if file then repeat line = file:read("*l"); if line then -- remove comments line = line:gsub( '[ \t\r\n]*;.+$', '' ); -- handle lines if string.find(line, '^@') then -- data line if current_is_match then cluster = cluster .. "\n" .. line; end elseif string.find(line, rm_match) then --- match this game current_is_match = true; elseif string.find(line, '^[a-z0-9_]+:') then --- some game if current_is_match and string.len(cluster) > 0 then break; -- we're done end else --- empty line or garbage -- noop end end until not line; file:close(); end return cluster; end local function check_mem ( posdata ) if #posdata < 1 then return false; end for ri,row in ipairs(posdata) do -- must pass mem check if row["c_start"] ~= row["mem"]:read_u8(row["addr"]) then return false; end if row["c_end"] ~= row["mem"]:read_u8(row["addr"]+row["size"]-1) then return false; end end return true; end local function get_file_name() local r; if emu.softname() ~= "" then local soft = emu.softname():match("([^:]*)$") r = get_data_path() .. '/' .. emu.romname() .. "_" .. soft .. ".hi"; else r = get_data_path() .. '/' .. emu.romname() .. ".hi"; end return r; end local function write_scores ( posdata ) emu.print_verbose("hiscore: write_scores") 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()); output = io.open(get_file_name(), "wb"); end emu.print_verbose("hiscore: write_scores output") if output then for ri,row in ipairs(posdata) do t = {} for i=0,row["size"]-1 do t[i+1] = row["mem"]:read_u8(row["addr"] + i) end output:write(string.char(table.unpack(t))); end output:close(); end emu.print_verbose("hiscore: write_scores end") end local function read_scores ( posdata ) local input = io.open(get_file_name(), "rb"); if input then for ri,row in ipairs(posdata) do local str = input:read(row["size"]); for i=0,row["size"]-1 do local b = str:sub(i+1,i+1):byte(); row["mem"]:write_u8( row["addr"] + i, b ); end end input:close(); return true; end return false; end local function check_scores ( posdata ) local r = 0; for ri,row in ipairs(posdata) do for i=0,row["size"]-1 do r = r + row["mem"]:read_u8( row["addr"] + i ); end end return r; end local function init () if not scores_have_been_read then if (delaytime <= emu.time()) and check_mem( positions ) then default_checksum = check_scores( positions ); if read_scores( positions ) then emu.print_verbose( "hiscore: scores read OK" ); else -- likely there simply isn't a .hi file around yet emu.print_verbose( "hiscore: scores read FAIL" ); end scores_have_been_read = true; current_checksum = check_scores( positions ); mem_check_passed = true; else -- memory check can fail while the game is still warming up -- TODO: only allow it to fail N many times end end end local last_write_time = -10; local function tick () -- set up scores if they have been init(); -- only allow save check to run when if mem_check_passed and timed_save then -- The reason for this complicated mess is that -- MAME does expose a hook for "exit". Once it does, -- this should obviously just be done when the emulator -- shuts down (or reboots). local checksum = check_scores( positions ); if checksum ~= current_checksum and checksum ~= default_checksum then -- 5 sec grace time so we don't clobber io and cause -- latency. This would be bad as it would only ever happen -- to players currently reaching a new highscore if emu.time() > last_write_time + 5 then write_scores( positions ); current_checksum = checksum; last_write_time = emu.time(); -- emu.print_verbose( "SAVE SCORES EVENT!", last_write_time ); end end end end local function reset() -- the notifier will still be attached even if the running game has no hiscore.dat entry if mem_check_passed and found_hiscore_entry then local checksum = check_scores(positions) if checksum ~= current_checksum and checksum ~= default_checksum then write_scores(positions) end end found_hiscore_entry = false mem_check_passed = false scores_have_been_read = false; end reset_subscription = emu.add_machine_reset_notifier(function () found_hiscore_entry = false mem_check_passed = false scores_have_been_read = false; last_write_time = -10 emu.print_verbose("Starting " .. emu.gamename()) read_config(); local dat = read_hiscore_dat() if dat and dat ~= "" then emu.print_verbose( "hiscore: found hiscore.dat entry for " .. emu.romname() ); res, positions = pcall(parse_table, dat); if not res then emu.print_error("hiscore: hiscore.dat parse error " .. positions); return; end for i, row in pairs(positions) do if row.fill then for i=0,row["size"]-1 do row["mem"]:write_u8(row["addr"] + i, row.fill) end end end found_hiscore_entry = true end end) frame_subscription = emu.add_machine_frame_notifier(function () if found_hiscore_entry then tick() end end) stop_subscription = emu.add_machine_stop_notifier(function () reset() save_config() end) emu.register_prestart(function () reset() end) emu.register_menu(handle_menu, populate_menu, _p('plugin-hiscore', 'Hiscore Support')) end return exports