-- A general architecture for free-wheeling, live programs: -- on startup: -- scan both the app directory and the save directory for files with numeric prefixes -- from the largest numeric prefix found, obtain a manifest -- load all files (which must start with a numeric prefix) from the manifest) -- -- then start drawing frames on screen and reacting to events -- -- events from keyboard and mouse are handled as the app desires -- -- on incoming messages to a specific file, however, the app must: -- save the message's value to a specific, unused numeric prefix -- execute the value -- -- if a game encounters an error: -- find the previous version of the definition in the 'head' numeric prefix -- decrement 'head' -- namespace for these functions live = {} -- state for these functions Live = {} -- a namespace of frameworky callbacks -- these will be modified live on = {} -- ========= on startup, load the version at head function live.initialize(arg) -- version control Live.Head = 0 Live.Next_version = 1 Live.History = {} -- array of filename roots corresponding to each numeric prefix Live.Manifest = {} -- mapping from roots to numeric prefixes as of version Live.Head live.load_files_so_far() Live.Previous_read = 0 if on.load then on.load() end end function live.load_files_so_far() print('new edits will go to ' .. love.filesystem.getSaveDirectory()) local files = {} live.append_files_with_numeric_prefix('', files) table.sort(files) live.check_integrity(files) live.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files) table.sort(files) live.check_integrity(files) Live.History = live.load_history(files) Live.Next_version = #Live.History + 1 local head_string = love.filesystem.read('head') Live.Head = tonumber(head_string) if Live.Head > 0 then Live.Manifest = json.decode(love.filesystem.read(live.versioned_manifest(Live.Head))) end live.load_everything_in_manifest() end function live.append_files_with_numeric_prefix(dir, files) for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do if file:match('^%d') then table.insert(files, file) end end end function live.check_integrity(files) local manifest_found, file_found = false, false local expected_index = 1 for _,file in ipairs(files) do for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do -- only runs once local index = tonumber(numeric_prefix) -- skip files without numeric prefixes if index ~= nil then if index < expected_index then print(index, expected_index) end assert(index >= expected_index) if index > expected_index then assert(index == expected_index+1) assert(manifest_found and file_found) expected_index = index manifest_found, file_found = false, false end assert(index == expected_index) if root == 'manifest' then assert(not manifest_found) manifest_found = true else assert(not file_found) file_found = true end end end end end function live.load_history(files) local result = {} for _,file in ipairs(files) do for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do -- only runs once local index = tonumber(numeric_prefix) -- skip if index ~= nil then if root ~= 'manifest' then assert(index == #result+1) table.insert(result, root) end end end end return result end function live.load_everything_in_manifest() local files_to_load = {} for k,v in pairs(Live.Manifest) do if k ~= 'parent' then local root, index = k, v local filename = live.versioned_filename(index, root) table.insert(files_to_load, filename) end end table.sort(files_to_load) for _,filename in ipairs(files_to_load) do local buf = love.filesystem.read(filename) assert(buf and buf ~= '') live.eval(buf) end end function live.versioned_filename(index, root) return ('%04d-%s'):format(index, root) end function live.versioned_manifest(index) return ('%04d-manifest'):format(index) end -- ========= on each frame, check for messages and alter the app as needed function live.update(dt) if Current_time - Live.Previous_read > 0.1 then local buf = live.receive() if buf then live.run(buf) if on.code_change then on.code_change() end end Live.Previous_read = Current_time end end -- look for a message from outside, and return nil if there's nothing function live.receive() local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app') if f == nil then return nil end local result = f:read('*a') f:close() if result == '' then return nil end -- empty file == no message print('<='..color(--[[bold]]1, --[[blue]]4)) print(result) print(reset_terminal()) -- we can't unlink files, so just clear them local clear = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app', 'w') clear:close() return result end function live.send(msg) local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w') if f == nil then return end f:write(msg) f:close() print('=>'..color(0, --[[green]]2)) print(msg) print(reset_terminal()) end function live.send_run_time_error(msg) local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w') if f == nil then return end f:write(msg) f:close() print('=>'..color(0, --[[red]]1)) print(msg) print(reset_terminal()) end -- args: -- format: 0 for normal, 1 for bold -- color: 0-15 function color(format, color) return ('\027[%d;%dm'):format(format, 30+color) end function reset_terminal() return '\027[m' end -- define or undefine top-level bindings function live.run(buf) local cmd = buf:match('^%S+') assert(cmd) print('command is '..cmd) if cmd == 'QUIT' then love.event.quit(1) elseif cmd == 'MANIFEST' then live.send(json.encode(Live.Manifest)) elseif cmd == 'DELETE' then local binding = buf:match('^%S+%s+(%S+)') Live.Manifest[binding] = nil live.eval(binding..' = nil') local next_filename = live.versioned_filename(Live.Next_version, binding) love.filesystem.write(next_filename, '') table.insert(Live.History, binding) Live.Manifest.parent = Live.Head local manifest_filename = live.versioned_manifest(Live.Next_version) love.filesystem.write(manifest_filename, json.encode(Live.Manifest)) Live.Head = Live.Next_version love.filesystem.write('head', tostring(Live.Head)) Live.Next_version = Live.Next_version + 1 elseif cmd == 'GET' then local binding = buf:match('^%S+%s+(%S+)') live.send(live.get_binding(binding)) -- other commands go here else local binding = cmd local next_filename = live.versioned_filename(Live.Next_version, binding) love.filesystem.write(next_filename, buf) table.insert(Live.History, binding) Live.Manifest[binding] = Live.Next_version Live.Manifest.parent = Live.Head local manifest_filename = live.versioned_manifest(Live.Next_version) love.filesystem.write(manifest_filename, json.encode(Live.Manifest)) Live.Head = Live.Next_version love.filesystem.write('head', tostring(Live.Head)) Live.Next_version = Live.Next_version + 1 local status, err = live.eval(buf) if not status then -- roll back Live.Head = Live.Manifest.parent local previous_manifest_filename = live.versioned_manifest(Live.Head) Live.Manifest = json.decode(love.filesystem.read(previous_manifest_filename)) -- throw an error live.send('ERROR '..tostring(err)) end live.send('ok') end end function live.get_binding(name) if Live.Manifest[name] then return love.filesystem.read(live.versioned_filename(Live.Manifest[name], name)) end end -- Wrapper for Lua's weird evaluation model. -- Lua is persnickety about expressions vs statements, so we need to do some -- extra work to get the result of an evaluation. -- return values: -- all well -> true, ... -- load failed -> nil, error message -- run failed -> false, error message function live.eval(buf) -- We assume a program is either correct with 'return' prefixed xor not. -- Is this correct? Who knows! But the Lua REPL does this as well. local f = load('return '..buf, 'REPL') if f then return pcall(f) end local f, err = load(buf, 'REPL') if f then return pcall(f) else return nil, err end end -- ========= on error, pause the app and wait for messages -- return nil to continue the event loop, non-nil to quit function live.handle_error(err) local msg = tostring(err) -- draw a pause indicator on screen love.graphics.setColor(1,0,0) love.graphics.rectangle('fill', 10,10, 3,10) love.graphics.rectangle('fill', 16,10, 3,10) love.graphics.present() -- print stack trace here just in case we ran the app through a terminal local stack_trace = debug.traceback('Error: ' .. tostring(msg), --[[stack frame]]2):gsub('\n[^\n]+$', '') print(stack_trace) print('Look in the driver for options to investigate further.') print("(You probably can't close the app window at this point. If you don't have the driver set up, you might need to force-quit.)") -- send stack trace to driver and wait for a response live.send_run_time_error(stack_trace) local buf repeat buf = live.receive() until buf if buf == 'QUIT' then return true end live.run(buf) end