---------------------------------
-- lsnes-input-replayer v0.3.0 --
-- by partyboy1a --
---------------------------------
--
-- see the manual to get a description
-- of all the functions and tables.
--
-- You must change at least one
-- line in the config file before
-- this script starts, right at
-- the beginning.
--
-- have fun with this script!
dofile("inputreplayer-v0.3-config.lua")
-- dofile("inputreplayer-v0.3-memorywatch.lua") -- TODO: not implemented yet to be of any use.
-- dofile("tablehandling.lua") -- TODO: not implemented yet to be of any use.
if (inputreplayer.config.playercount ~= 1 and inputreplayer.config.playercount ~= 2) then
print("nothing was tested for more than 2 players, good luck.")
end
--[[
******************
* *
* input handling *
* *
******************
--]]
----- some global constants
-- required because lsnes uses rather comlicated
-- and inconsistent Lua functions for the input.
button_by_index = {
[4] = "u", [5] = "d", [6] = "l", [7] = "r",
[8] = "A", [0] = "B", [9] = "X", [1] = "Y",
[3] = "S", [2] = "s",[10] = "L",[11] = "R"
}
button_by_name = {}
for index,name in pairs(button_by_index) do
button_by_name[name] = index
end
-- Yes, reading and writing input don't work in the same way!
index_to_player_for_on_snoop = {
[0] = {[0] = 1, [1] = 6, [2] = 7, [3] = 8},
[1] = {[0] = 2, [1] = 3, [2] = 4, [3] = 5} --untested for more than 2 players!
}
player_to_index_for_setinput = {
[1] = 0, [2] = 4, [3] = 5, [4] = 6,
[5] = 7, [6] = 1, [7] = 2, [8] = 3 --untested for more than 2 players!
}
------ some global functions
-- required for saving ~80% filesize when saving to harddrive
-- TODO: not implemented right now
function compressinput(inputtable)
local ret = ""
for i = 0,11 do
if inputtable[i] == 1 then
ret = ret .. button_by_index[i]
else
ret = ret .. "."
end
end
return ret
end
function decompressinput(inputstring)
if inputstring == nil then
return nil
end
local ret = {}
for i = 1,12 do
if string.sub(inputstring, i, i) ~= "." then
ret[i-1] = 1
else
ret[i-1] = 0
end
end
ret[12] = 0; ret[13] = 0; ret[14] = 0; ret[15] = 0
return ret
end
--[[
*********************
* *
* inputreplayer API *
* v0.2.2 *
* *
*********************
--]]
-- here go all the functions which can be called from outside
-- Note: before v1.0.0 don't consider this to be stable or complete
-- Anything might change anytime.
-- Although a lot of these functions are quite trivial right now,
-- you should use these instead of directly manipulating
-- inputreplayer.state
inputreplayer.functions = {}
-- You can set which mode should be activated per player. There
-- are three modes right now: RECORD, REPLAY, INACTIVE
inputreplayer.functions.setmode = function(player, mode)
assert( mode == "RECORD"
or mode == "REPLAY"
or mode == "INACTIVE",
"invalid mode! (you entered " .. mode ..")")
inputreplayer.state.player[player].mode = mode
end
-- This will do the following:
-- If there is recorded data available for this frame,
-- the recorded input from player for frame
-- movie.currentframe() + inputreplayer.state.player[player].offset
-- will be offered to all players in the remap table. If the player in
-- the remap table is in REPLAY mode at that time, and there is no other
-- current joypad input for this player at the moment, this offer will
-- be accepted.
inputreplayer.functions.remaprecordedinput = function(player, remap)
inputreplayer.state.player[player].remaprecordedinput = remap
end
-- This will do the following:
-- If you setup a joypad remap for player, it will overwrite the input
-- from all players listed in the remap table with the input from the
-- specified player. So if you're in RECORD mode, and you have used
-- remapjoypadinput(1,{2}), and remapjoypadinput(2,{1}), all input you
-- do for player 1 will be used AND recorded for player 2 and vice versa.
inputreplayer.functions.remapjoypadinput = function(player, remap)
inputreplayer.state.player[player].remapjoypadinput = remap
end
-- If you want to apply the recorded input sooner or later than it
-- has been recorded, you can set an offset for this for each player.
-- Offset is applied before remapping is taken into account.
-- positive values will apply the recorded input sooner than it was recorded.
-- negative values will apply the recorded input later than it was recorded.
inputreplayer.functions.addoffset = function(player,frames)
inputreplayer.state.player[player].offset = inputreplayer.state.player[player].offset + frames
end
inputreplayer.functions.setoffset = function(player,frames)
inputreplayer.state.player[player].offset = frames
end
-- if you had a good try, and now want to use this one as a new reference,
-- then you can use this function.
inputreplayer.functions.setnewreference = function()
for player = 1, inputreplayer.config.playercount do
if inputreplayer.state.player[player].newinput == nil then
inputreplayer.state.player[player].newinput = {}
end
for frame, inputstring in pairs(inputreplayer.state.player[player].newinput) do
inputreplayer.state.player[player].input[frame] = inputstring
end
inputreplayer.state.player[player].newinput = {}
end
end
-- Somehow it isn't necessary to use on_snoop and on_input,
-- but this will make sure we definitely record the input
-- exactly in the way it is passed over to bsnes core.
inputreplayer.functions.on_snoop = function(nport, ncontroller, cindex, cvalue)
local n = index_to_player_for_on_snoop[nport][ncontroller]
if (inputreplayer.state.player[n].mode == "RECORD") then
local cf = movie.currentframe()
guitext = "recording frame " .. cf
if inputreplayer.state.player[n].input[cf] == nil
or type(inputreplayer.state.player[n].input[cf]) == "string" then
inputreplayer.state.player[n].input[cf] = {}
end
inputreplayer.state.player[n].input[cf][cindex] = cvalue
-- assumption: on_snoop is called exactly 16 times per frame per controller
-- first call is always cindex == 0, last call for the controller is always cindex == 15
-- on_input doesn't get called in between cindex == 0 and cindex == 15.
--
-- compressing input saves a lot of space if the recorded input is stored on harddrive
if cindex == 15 then
inputreplayer.state.player[n].input[cf] = compressinput(inputreplayer.state.player[n].input[cf])
end
else -- might be replaced by:
-- elseif (inputreplayer.state.player[n].mode == "REPLAY") then
local cf = movie.currentframe()
guitext = "recording frame " .. cf .. " (into last_attempt)"
if inputreplayer.state.player[n].newinput == nil then
inputreplayer.state.player[n].newinput = {}
end
if inputreplayer.state.player[n].newinput[cf] == nil
or type(inputreplayer.state.player[n].newinput[cf]) == "string" then
inputreplayer.state.player[n].newinput[cf] = {}
end
inputreplayer.state.player[n].newinput[cf][cindex] = cvalue
if cindex == 15 then
inputreplayer.state.player[n].newinput[cf] = compressinput(inputreplayer.state.player[n].newinput[cf])
end
end
end
local emptyjoypad = {}; for i=0,11 do emptyjoypad[i] = 0 end
-- on_input:
-- This will modify the input in the desired way. The modifications are
-- applied in the following order:
-- 1: joypads get remapped.
-- 2: offset is applied to recorded input
-- 3: recorded input gets remapped.
-- 4: now per player:
-- either the joypad input or the recorded input is used.
inputreplayer.functions.on_input = function()
local cf = movie.currentframe()
-- first step: get all joypad input from all controllers
local realjoypadinput = {}
for player = 1, inputreplayer.config.playercount do
local pnum = player_to_index_for_setinput[player]
-- if player > 1 then extraguitext = ""..pnum end
realjoypadinput[player] = {}
for i = 0, 11 do
realjoypadinput[player][i] = input.get(pnum, i)
end
end
-- second step: remap this input according to remapping rules for joypad
local remappedjoypadinput = {}
for player = 1, inputreplayer.config.playercount do
local remap = inputreplayer.state.player[player].remapjoypadinput
if remap ~= nil then
for dontcare,newplayer in ipairs(remap) do
remappedjoypadinput[newplayer] = realjoypadinput[player]
end
end
end
for player = 1, inputreplayer.config.playercount do
remappedjoypadinput[player] = remappedjoypadinput[player] or realjoypadinput[player]
end
-- third step: get all recorded input from all controllers
-- TODO: combining remapping + offset might work counter-intuitive.
-- Test if this works in the way you think it works. If not:
-- rewrite this and the next step.
local realrecordedinput = {}
for player = 1, inputreplayer.config.playercount do
local offset = inputreplayer.state.player[player].offset
realrecordedinput[player] = decompressinput(inputreplayer.state.player[player].input[cf + offset])
end
-- fourth step: remap this recorded input according to remapping rules for recorded input
local remappedrecordedinput = {}
for player = 1, inputreplayer.config.playercount do
local remap = inputreplayer.state.player[player].remaprecordedinput
if remap ~= nil then
for dontcare,newplayer in ipairs(remap) do
remappedrecordedinput[newplayer] = realrecordedinput[player]
end
end
end
for player = 1, inputreplayer.config.playercount do
remappedrecordedinput[player] = remappedrecordedinput[player] or realrecordedinput[player]
end
-- fifth step: here goes the final input!
local finalinput = {}
extraguitext = ""
-- NOW: react to this input in the right way
for player = 1, inputreplayer.config.playercount do
if inputreplayer.state.player[player].mode == "REPLAY" then
-- REPLAY mode: writes recorded input if
-- - there is no joypad input for the controller
-- (otherwise: become inactive)
-- - there is recorded input available
-- (otherwise: show error)
-- check if there was some input for the controller
local custominput = 0
for i = 0, 11 do
custominput =
custominput
+ remappedjoypadinput[player][i] -- TODO: check if that works for more than 2 players
end
custominput = (custominput > 0)
-- Yes? Then become inactive!
if custominput then
--extraguitext = "custom input used, switching to inactive"
inputreplayer.functions.setmode(player, "INACTIVE")
finalinput[player] = remappedjoypadinput[player]
-- No, but there is no recorded input available? Show a message!
elseif remappedrecordedinput[player] == nil then
print("ERROR: no input found for frame " .. cf)
finalinput[player] = emptyjoypad
-- No, and we have recorded input? Fine, use it!
else
finalinput[player] = remappedrecordedinput[player]
--[[extraguitext = "input overwritten for frame " .. cf .. ", used " ..
compressinput(finalinput[player]) .. " instead"--]]
end
else
-- All our work is done already. Neither INACTIVE nor RECORD mode tries
-- to alter the joypad input, and neither of them uses recorded data.
finalinput[player] = remappedjoypadinput[player]
end
end -- for player = 1, inputreplayer.config.playercount
-- finally: set all the input accordingly to the rules above
inputtext = {}
for player = 1, inputreplayer.config.playercount do
local pnum = player_to_index_for_setinput[player]
for i = 0, 11 do
input.set(pnum, i, finalinput[player][i])
end
-- for displaying the input on the UI, as the built-in
-- display doesn't take care of Lua modifications to
-- the input, and that can be quite confusing.
-- (there is for sure something better than this)
inputtext[player] = "player "..player..": " .. compressinput(finalinput[player])
end
end
-- Here goes the internal state of inputreplayer.
-- Not to be modified from outside.
inputreplayer.state = {}
inputreplayer.state.player = {}
for player = 1, inputreplayer.config.playercount do
inputreplayer.state.player[player] = {
input = {},
offset = 0,
remaprecordedinput = nil, -- not required, but it's here
remapjoypadinput = nil, -- to give you the right names
newinput = nil -- for the variables.
}
inputreplayer.functions.setmode(player, "RECORD")
end
--[[
*******************
* *
* menu navigation *
* *
*******************
--]]
-- built-in menu. All functions called from here should be treated like external functions,
-- they shouldn't manipulate anything directly, they should use inputreplayer.functions
-- instead.
-- Everything here should be pretty self-explanatory.
inputreplayer_ui.menu = {
[1] = {
text = "set mode",
submenu = {
[1] = {
text = "player 1: switch to REPLAY",
action = function()
inputreplayer.functions.setmode(1, "REPLAY")
end
},
[2] = {
text = "player 1: switch to INACTIVE",
action = function()
inputreplayer.functions.setmode(1, "INACTIVE")
end
},
[3] = {
text = "player 1: switch to RECORD",
action = function()
inputreplayer.functions.setmode(1, "RECORD")
end
},
[4] = {
text = "player 2: switch to REPLAY",
action = function()
inputreplayer.functions.setmode(2, "REPLAY")
end
},
[5] = {
text = "player 2: switch to INACTIVE",
action = function()
inputreplayer.functions.setmode(2, "INACTIVE")
end
},
[6] = {
text = "player 2: switch to RECORD",
action = function()
inputreplayer.functions.setmode(2, "RECORD")
end
},
[7] = {
text = "ALL: switch to REPLAY",
action = function()
inputreplayer.functions.setmode(1, "REPLAY")
inputreplayer.functions.setmode(2, "REPLAY")
end
},
[8] = {
text = "ALL: switch to INACTIVE",
action = function()
inputreplayer.functions.setmode(1, "INACTIVE")
inputreplayer.functions.setmode(2, "INACTIVE")
end
},
[9] = {
text = "ALL: switch to RECORD",
action = function()
inputreplayer.functions.setmode(1, "RECORD")
inputreplayer.functions.setmode(2, "RECORD")
end
}
} -- submenu main.setmode
}, -- main.1
[2] = {
text = "set offset (player 1)",
-- setting "goback" to false will let the menu stay where it is,
-- allowing us to execute these actions multiple times in a row.
-- (for example, to set offset to 23, you will use "+1" three times,
-- and "+10" two times). Use the menu key to leave this menu.
submenu = {
[1] = {text = "+1", action = function() inputreplayer.functions.addoffset(1, 1) end, goback=false},
[2] = {text = "+10", action = function() inputreplayer.functions.addoffset(1, 10) end, goback=false},
[3] = {text = "+100", action = function() inputreplayer.functions.addoffset(1, 100) end, goback=false},
[4] = {text = "+1000", action = function() inputreplayer.functions.addoffset(1, 1000) end, goback=false},
[5] = {text = "-1", action = function() inputreplayer.functions.addoffset(1, -1) end, goback=false},
[6] = {text = "-10", action = function() inputreplayer.functions.addoffset(1, -10) end, goback=false},
[7] = {text = "-100", action = function() inputreplayer.functions.addoffset(1, -100) end, goback=false},
[8] = {text = "-1000", action = function() inputreplayer.functions.addoffset(1, -1000) end, goback=false},
[9] = {text = "reset", action = function() inputreplayer.functions.setoffset(1, 0) end, goback=false}
}
},
[3] = {
text = "set offset (player 2)",
submenu = {
[1] = {text = "+1", action = function() inputreplayer.functions.addoffset(2, 1) end, goback=false},
[2] = {text = "+10", action = function() inputreplayer.functions.addoffset(2, 10) end, goback=false},
[3] = {text = "+100", action = function() inputreplayer.functions.addoffset(2, 100) end, goback=false},
[4] = {text = "+1000", action = function() inputreplayer.functions.addoffset(2, 1000) end, goback=false},
[5] = {text = "-1", action = function() inputreplayer.functions.addoffset(2, -1) end, goback=false},
[6] = {text = "-10", action = function() inputreplayer.functions.addoffset(2, -10) end, goback=false},
[7] = {text = "-100", action = function() inputreplayer.functions.addoffset(2, -100) end, goback=false},
[8] = {text = "-1000", action = function() inputreplayer.functions.addoffset(2, -1000) end, goback=false},
[9] = {text = "reset", action = function() inputreplayer.functions.setoffset(2, 0) end, goback=false}
}
},
[4] = {
text = "remap recorded input",
submenu = {
[1] = {
text = "delete all remappings",
action = function()
inputreplayer.functions.remaprecordedinput(1, nil)
inputreplayer.functions.remaprecordedinput(2, nil)
end
},
[2] = {
text = "1 -> 2 and 2 -> 1",
action = function()
inputreplayer.functions.remaprecordedinput(1, {2})
inputreplayer.functions.remaprecordedinput(2, {1})
end
},
[3] = {
text = "1 -> 1+2",
action = function()
inputreplayer.functions.remaprecordedinput(1, {1, 2})
end
},
[4] = {
text = "2 -> 1+2",
action = function()
inputreplayer.functions.remaprecordedinput(2, {1, 2})
end
}
} --menu.remaprecordedinput
}, -- menu.4
[5] = {
text = "remap joypad input",
submenu = {
[1] = {
text = "delete all remappings",
action = function()
inputreplayer.functions.remapjoypadinput(1, nil)
inputreplayer.functions.remapjoypadinput(2, nil)
end
},
[2] = {
text = "1 -> 2 and 2 -> 1",
action = function()
inputreplayer.functions.remapjoypadinput(1, {2})
inputreplayer.functions.remapjoypadinput(2, {1})
end
},
[3] = {
text = "1 -> 1+2",
action = function()
inputreplayer.functions.remapjoypadinput(1, {1, 2})
end
},
[4] = {
text = "2 -> 1+2",
action = function()
inputreplayer.functions.remapjoypadinput(2, {1, 2})
end
}
} --menu.remapjoypadinput
}, -- menu.5
[6] = {
text = "set new reference",
action = function()
inputreplayer.functions.setnewreference()
end
}
} -- menu
-- The "stack" solution works very good for using the menu
-- TODO: There should be no single line of code inside the menu code
-- which directly writes to inputreplayer.state. They should be completely
-- separated.
inputreplayer_ui.state = {}
inputreplayer_ui.state.menustack = {}
inputreplayer_ui.state.menustack[1] = inputreplayer_ui.menu
inputreplayer_ui.state.menustacktop = 1
-- This function is used to navigate through the menu while the menu
-- is visible. If it is hidden at the moment, nothing will happen until
-- it is explicitly opened again.
inputreplayer_ui.functions = {}
inputreplayer_ui.functions.menu_navigate = function(num)
if num == 0 then -- go back one step, or let the menu appear
if inputreplayer_ui.state.menustacktop > 0 then
inputreplayer_ui.state.menustack[inputreplayer_ui.state.menustacktop] = nil
inputreplayer_ui.state.menustacktop = inputreplayer_ui.state.menustacktop - 1
else
inputreplayer_ui.state.menustack[1] = inputreplayer_ui.menu
inputreplayer_ui.state.menustacktop = 1
end
end
local currentmenu = inputreplayer_ui.state.menustack[inputreplayer_ui.state.menustacktop]
-- extraguitext = extraguitext .. '\n' .. "menunavigate "..num -- for debugging purposes
-- currentmenu == nil:
-- menu is hidden at the moment -> ignore input
-- currentmenu[num] == nil:
-- item number num doesn't exist in the current menu -> ignore input
if currentmenu and currentmenu[num] then
if currentmenu[num].submenu then
inputreplayer_ui.state.menustacktop = inputreplayer_ui.state.menustacktop + 1
inputreplayer_ui.state.menustack[inputreplayer_ui.state.menustacktop] = currentmenu[num].submenu
elseif currentmenu[num].action then
currentmenu[num].action()
-- note:
-- currentmenu[num].goback == true
-- is NOT the same value as
-- currentmenu[num].goback ~= false
-- because if currentmenu[num].goback == nil,
-- the first statement will return false,
-- the second statement will return true.
if currentmenu[num].goback ~= false then
inputreplayer_ui.state.menustack[inputreplayer_ui.state.menustacktop] = nil
inputreplayer_ui.state.menustacktop = inputreplayer_ui.state.menustacktop - 1
end
end
end
gui.repaint()
end
-- on_paint: used to display the current state of inputreplayer
-- and to display the menus
inputreplayer_ui.functions.on_paint = function()
-- print the menu on the left side of the screen, at the bottom.
local lgap = inputreplayer_ui.config.lgap
gui.left_gap(lgap)
local currentmenu = inputreplayer_ui.state.menustack[inputreplayer_ui.state.menustacktop]
if currentmenu ~= nil then
for i,item in ipairs(currentmenu) do
gui.text(-lgap,200+i*20,i .. ": " .. item.text)
end
gui.text(-lgap + 120, 180,extraguitext) -- for debugging purposes
end
-- display current internal state at the left side of the screen, at the top.
gui.text(-lgap, 0, "mode player 1: " .. inputreplayer.state.player[1].mode)
gui.text(-lgap, 20, "offset player 1: " .. inputreplayer.state.player[1].offset)
gui.text(-lgap, 40, "mode player 2: " .. inputreplayer.state.player[2].mode)
gui.text(-lgap, 60, "offset player 2: " .. inputreplayer.state.player[2].offset)
function temp(t) local s = ""; for dontcare,n in ipairs(t) do s = s .. n .. "; " end; return s; end
for player = 1, inputreplayer.config.playercount do
gui.text(-lgap, 80 + 20 * (player - 1), " joypad ".. player .." -> " .. temp(inputreplayer.state.player[player].remapjoypadinput or {"default"}))
gui.text(-lgap, 120+ 20 * (player - 1), "recorded ".. player .." -> " .. temp(inputreplayer.state.player[player].remaprecordedinput or {"default"}))
end
temp = nil
-- display which input was applied to the last frame.
gui.text(-lgap, 400, inputtext[1] or "-/-")
gui.text(-lgap, 420, inputtext[2] or "-/-")
end
--[[
*********************
* *
* keyhook functions *
* *
*********************
--]]
-- Here we create one function per key to react accordingly to the key which was pressed.
-- the only place with a global variable, to let you add your custom
-- keyhook functions without having to change anything inside inputreplayer or inputreplayer_ui
keyhook = {}
assignkey = function(key,func)
keyhook[key] = func
input.keyhook(key, func ~= nil)
end
assignkey(
inputreplayer_ui.config.keys.menu,
function()
inputreplayer_ui.functions.menu_navigate(0)
end
)
for i = 1, 9 do
assignkey(
inputreplayer_ui.config.keys.item[i],
function()
inputreplayer_ui.functions.menu_navigate(i)
end
)
end
function on_keyhook(key, state)
-- check if there is a matching keyhook function
-- and if the button was pressed. (If state.last_rawval == 0 it was released)
--
-- assumption: this function is called exactly once when the key is hold down,
-- and once the key is released again.
if keyhook[key] and state.last_rawval == 1 then
-- extraguitext = "on_keyhook "..key --.. "/" .. state.last_rawval -- for debugging purposes
keyhook[key]()
end
end
extraguitext = "" -- for debugging purposes
inputtext = {} -- to display the input the script caused.
function on_paint()
inputreplayer_ui.functions.on_paint()
end
function on_snoop(nport, ncontroller, cindex, cvalue)
inputreplayer.functions.on_snoop(nport, ncontroller, cindex, cvalue)
end
function on_input()
inputreplayer.functions.on_input()
end
print("inputreplayer v0.3.0 ready")