-- name: \\#FFFF00\\Save\\#FF8000\\States \\#FF0000\\v3.1.0 -- description: \\#ff4040\\SaveStates \\#ff8040\\v3.1.0 \\#ffff40\\by \\#40ff40\\iZePlayz\n\n\\#80ffff\\This mod adds SaveStates to SM64COOPDX!\n\n\\#ff80ff\\- D-Pad saves a slot, hold L + D-Pad to load (4 local RAM slots per player; swap in the pause menu)\n- Named slot-sets persist after restart (page 2 or /sssave /ssload)\n- Loading a named set only copies into RAM - use L+D-Pad to apply a slot\n- Safer loads: no leftover pointers, works across levels via warp\n\n\\#8080ff\\Pause menu: Mods > SaveStates\n- Page 1: AutoLoad / AutoHeal / Restore coins, Swap save/load, Undo/Redo, Tutorial\n- Page 2: Commands, named saves (Save / Load / Delete / List)\n- Pages 3-4: RAM slot info (level, area, act, coins, date)\n\n\\#80ffff\\/autoload (/al) \\#b0b0b0\\AutoLoad latest slot in this level/area/act on death\n\\#80ffff\\/autoheal (/ah) \\#b0b0b0\\Heal after loading\n\\#80ffff\\/savecoins (/sc) \\#b0b0b0\\Restore coin count on load\n\\#80ffff\\/swapcontrols (/swc) \\#b0b0b0\\Swap D-Pad save/load\n\\#80ffff\\/undoload (/ul) \\#b0b0b0\\Undo last load\n\\#80ffff\\/redoload (/rl) \\#b0b0b0\\Redo last undone load\n\\#80ffff\\/sssave /ssload /sslist /ssdel \\#b0b0b0\\Named slot-sets\n\\#80ffff\\/savestate (/ss) \\#b0b0b0\\Show slots and settings local SLOT_COUNT = 4 local SLOT_SAVE_COLOR = { [0] = "\\#ff4040\\", [1] = "\\#40ff40\\", [2] = "\\#6060ff\\", [3] = "\\#ffff40\\" } local SLOT_LOAD_COLOR = { [0] = "\\#ffa0a0\\", [1] = "\\#a0ffa0\\", [2] = "\\#a0a0ff\\", [3] = "\\#ffffa0\\" } local autoload_enabled = false local autoheal_enabled = false local savecoins_enabled = false local swap_controls_enabled = false local savestates = {} local latest_savestate = nil local save_seq = 0 local pending_snapshot = nil local pending_popup = nil local pending_is_history = false local warping_for_load = false local frames_in_target = 0 local applying_history = false local skip_history_before = false local undo_stack = {} local undo_index = 0 local MAX_UNDO = 16 local menu_autoload = nil local menu_autoheal = nil local menu_savecoins = nil local menu_swap_controls = nil local menu_named_input = nil local menu_slot_info = {} local named_input = "" local named_list_page = 1 local LIST_PER_PAGE = 8 local MENU_NAME_MAX = 63 local UNSAFE_CAMERA_MODES = { [CAMERA_MODE_NONE] = true, [CAMERA_MODE_C_UP] = true, [CAMERA_MODE_INSIDE_CANNON] = true, [CAMERA_MODE_SLIDE_HOOT] = true } local HOLD_ACTION_MAP = { [ACT_HOLD_IDLE] = ACT_IDLE, [ACT_HOLD_HEAVY_IDLE] = ACT_IDLE, [ACT_HOLD_WALKING] = ACT_WALKING, [ACT_HOLD_HEAVY_WALKING] = ACT_WALKING, [ACT_HOLD_DECELERATING] = ACT_DECELERATING, [ACT_HOLD_BEGIN_SLIDING] = ACT_BEGIN_SLIDING, [ACT_HOLD_BUTT_SLIDE] = ACT_BUTT_SLIDE, [ACT_HOLD_STOMACH_SLIDE] = ACT_STOMACH_SLIDE, [ACT_HOLD_BUTT_SLIDE_STOP] = ACT_BUTT_SLIDE_STOP, [ACT_HOLD_JUMP] = ACT_JUMP, [ACT_HOLD_FREEFALL] = ACT_FREEFALL, [ACT_HOLD_JUMP_LAND] = ACT_JUMP_LAND, [ACT_HOLD_FREEFALL_LAND] = ACT_FREEFALL_LAND, [ACT_HOLD_JUMP_LAND_STOP] = ACT_JUMP_LAND_STOP, [ACT_HOLD_FREEFALL_LAND_STOP] = ACT_FREEFALL_LAND_STOP, [ACT_HOLD_QUICKSAND_JUMP_LAND] = ACT_QUICKSAND_JUMP_LAND, [ACT_HOLD_BUTT_SLIDE_AIR] = ACT_BUTT_SLIDE_AIR, [ACT_HOLD_WATER_JUMP] = ACT_WATER_JUMP, [ACT_HOLD_WATER_IDLE] = ACT_WATER_IDLE, [ACT_HOLD_WATER_ACTION_END] = ACT_WATER_ACTION_END, [ACT_HOLD_BREASTSTROKE] = ACT_BREASTSTROKE, [ACT_HOLD_SWIMMING_END] = ACT_SWIMMING_END, [ACT_HOLD_FLUTTER_KICK] = ACT_FLUTTER_KICK, [ACT_HOLD_METAL_WATER_STANDING] = ACT_METAL_WATER_STANDING, [ACT_HOLD_METAL_WATER_WALKING] = ACT_METAL_WATER_WALKING, [ACT_HOLD_METAL_WATER_FALLING] = ACT_METAL_WATER_FALLING, [ACT_HOLD_METAL_WATER_FALL_LAND] = ACT_METAL_WATER_FALL_LAND, [ACT_HOLD_METAL_WATER_JUMP] = ACT_METAL_WATER_JUMP, [ACT_HOLD_METAL_WATER_JUMP_LAND] = ACT_METAL_WATER_JUMP_LAND, [ACT_HOLD_PANTING_UNUSED] = ACT_IDLE } local OBJECT_DEPENDENT_ACTIONS = { [ACT_GRABBED] = true, [ACT_IN_CANNON] = true, [ACT_SHOT_FROM_CANNON] = true, [ACT_RIDING_HOOT] = true, [ACT_RIDING_SHELL_GROUND] = true, [ACT_RIDING_SHELL_JUMP] = true, [ACT_RIDING_SHELL_FALL] = true, [ACT_BUBBLED] = true, [ACT_READING_NPC_DIALOG] = true, [ACT_READING_AUTOMATIC_DIALOG] = true, [ACT_READING_SIGN] = true, [ACT_WAITING_FOR_DIALOG] = true } local function nvl(value, default) if value == nil then return default end return value end local function copy_vec(v) if v == nil then return { x = 0, y = 0, z = 0 } end return { x = v.x, y = v.y, z = v.z } end local function apply_vec(dest, src) if dest == nil or src == nil then return end dest.x = src.x dest.y = src.y dest.z = src.z end local function on_off_text(enabled) return enabled and "\\#80ff80\\ON" or "\\#ff8080\\OFF" end local function controls_hint() if swap_controls_enabled then return "D-Pad = load slot, L+D-Pad = save slot" end return "D-Pad = save slot, L+D-Pad = load slot" end local function play_mario_sound(m, sound) if m ~= nil and m.marioObj ~= nil and m.marioObj.header ~= nil and m.marioObj.header.gfx ~= nil then play_sound(sound, m.marioObj.header.gfx.cameraToObject) end end local function is_state_slot_not_empty(slot) return slot ~= nil and savestates[slot] ~= nil and savestates[slot].pos ~= nil end local function pad2(n) n = math.floor(tonumber(n) or 0) if n < 10 then return "0" .. tostring(n) end return tostring(n) end local function format_saved_clock(s, withSeconds) if s == nil or s.savedHour == nil then if withSeconds == false then return "----/--/-- --:--" end return "----/--/-- --:--:--" end local year = math.floor(tonumber(s.savedYear) or 0) local month = math.floor(tonumber(s.savedMonth) or 0) local day = math.floor(tonumber(s.savedDay) or 0) local hour = math.floor(tonumber(s.savedHour) or 0) % 24 local minute = math.floor(tonumber(s.savedMinute) or 0) local second = math.floor(tonumber(s.savedSecond) or 0) -- get_date_and_time() uses tm_year (since 1900) and tm_mon (0-11) if year > 0 and year < 200 then year = year + 1900 month = month + 1 end if month < 1 then month = 1 end if month > 12 then month = 12 end if day < 1 then day = 1 end local clock = tostring(year) .. "/" .. pad2(month) .. "/" .. pad2(day) .. " " .. pad2(hour) .. ":" .. pad2(minute) if withSeconds ~= false then clock = clock .. ":" .. pad2(second) end return clock end local function snapshot_level_name(s) local course = nvl(s.course, 0) local level = nvl(s.level, 0) local area = nvl(s.area, 0) local name = get_level_name(course, level, area) if name == nil or name == "" then return "Lv" .. tostring(level) end return name end local function fit_menu_line(text) if #text <= MENU_NAME_MAX then return text end if MENU_NAME_MAX < 2 then return text:sub(1, MENU_NAME_MAX) end return text:sub(1, MENU_NAME_MAX - 1) .. "." end local function slot_info_lines(slot) local n = tostring(slot + 1) local mark = "" if latest_savestate == slot then mark = "*" end if not is_state_slot_not_empty(slot) then return { fit_menu_line("Slot " .. n .. mark .. ": empty"), "Level-, Area-, Act-, Coins-", "Date ----/--/-- --:--:--" } end local s = savestates[slot] return { fit_menu_line("Slot " .. n .. mark .. ": " .. snapshot_level_name(s)), fit_menu_line( "Level" .. tostring(nvl(s.level, "?")) .. ", Area" .. tostring(nvl(s.area, "?")) .. ", Act" .. tostring(nvl(s.act, 0)) .. ", Coins" .. tostring(nvl(s.numCoins, 0)) ), fit_menu_line("Date " .. format_saved_clock(s)) } end local function print_slot_infos(header) if header ~= nil then djui_chat_message_create("\\#40b0ff\\[\\#40ffff\\" .. header .. "\\#40b0ff\\]") end for slot = 0, SLOT_COUNT - 1 do local lines = slot_info_lines(slot) if not is_state_slot_not_empty(slot) then djui_chat_message_create("\\#808080\\" .. lines[1]) djui_chat_message_create("\\#808080\\" .. lines[2]) djui_chat_message_create("\\#808080\\" .. lines[3]) else djui_chat_message_create(SLOT_SAVE_COLOR[slot] .. lines[1]) djui_chat_message_create("\\#ffffff\\" .. lines[2]) djui_chat_message_create("\\#ffffff\\" .. lines[3]) end end djui_chat_message_create("\\#808080\\" .. controls_hint()) end local function refresh_slot_menu_labels() for slot = 0, SLOT_COUNT - 1 do local ids = menu_slot_info[slot] if ids ~= nil then local lines = slot_info_lines(slot) for i = 1, 3 do if ids[i] ~= nil then update_mod_menu_element_name(ids[i], lines[i]) end end end end end local function location_matches_snapshot(s) if s == nil then return false end local np = gNetworkPlayers[0] return np.currLevelNum == s.level and np.currAreaIndex == s.area and np.currActNum == s.act end local function find_latest_slot_here() local bestSlot = nil local bestSeq = -1 for slot = 0, SLOT_COUNT - 1 do if is_state_slot_not_empty(slot) then local s = savestates[slot] if location_matches_snapshot(s) and nvl(s.seq, 0) >= bestSeq then bestSlot = slot bestSeq = nvl(s.seq, 0) end end end return bestSlot end local function json_encode(value) local t = type(value) if value == nil then return "null" end if t == "boolean" then return value and "true" or "false" end if t == "number" then if value ~= value then return "null" end return string.format("%.10g", value) end if t == "string" then return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n") .. '"' end if t ~= "table" then return "null" end local maxIndex = 0 local count = 0 local isArray = true for k, _ in pairs(value) do count = count + 1 if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then isArray = false break end if k > maxIndex then maxIndex = k end end if isArray and count > 0 then local parts = {} for i = 1, maxIndex do parts[i] = json_encode(value[i]) end return "[" .. table.concat(parts, ",") .. "]" end local parts = {} for k, val in pairs(value) do if type(k) == "string" and val ~= nil then parts[#parts + 1] = json_encode(k) .. ":" .. json_encode(val) end end return "{" .. table.concat(parts, ",") .. "}" end local function json_decode(str) if type(str) ~= "string" or str == "" then return nil end local i = 1 local n = #str local function peek() return str:sub(i, i) end local function skip() while i <= n and str:sub(i, i):match("%s") do i = i + 1 end end local parse local function parse_string() i = i + 1 local out = {} while i <= n do local c = str:sub(i, i) if c == '"' then i = i + 1 return table.concat(out) end if c == "\\" then local nchar = str:sub(i + 1, i + 1) if nchar == "n" then out[#out + 1] = "\n" elseif nchar == '"' or nchar == "\\" or nchar == "/" then out[#out + 1] = nchar else out[#out + 1] = nchar end i = i + 2 else out[#out + 1] = c i = i + 1 end end return table.concat(out) end parse = function() skip() local c = peek() if c == "{" then i = i + 1 local obj = {} skip() if peek() == "}" then i = i + 1 return obj end while i <= n do skip() local key = parse() skip() if peek() == ":" then i = i + 1 end obj[key] = parse() skip() if peek() == "}" then i = i + 1 return obj end if peek() == "," then i = i + 1 else return obj end end return obj elseif c == "[" then i = i + 1 local arr = {} skip() if peek() == "]" then i = i + 1 return arr end local idx = 1 while i <= n do arr[idx] = parse() idx = idx + 1 skip() if peek() == "]" then i = i + 1 return arr end if peek() == "," then i = i + 1 else return arr end end return arr elseif c == '"' then return parse_string() elseif c == "t" then i = i + 4 return true elseif c == "f" then i = i + 5 return false elseif c == "n" then i = i + 4 return nil else local frag = str:sub(i) local s, e = frag:find("^-?%d+%.?%d*[eE]?[%+%-]?%d*") if s == 1 then local num = tonumber(frag:sub(s, e)) i = i + e return num end return nil end end return parse() end local function fallback_action(action) if (action & ACT_FLAG_SWIMMING) ~= 0 then return ACT_WATER_IDLE end if (action & ACT_FLAG_AIR) ~= 0 then return ACT_FREEFALL end return ACT_IDLE end local function sanitize_action(action) if action == nil then return ACT_IDLE end if HOLD_ACTION_MAP[action] ~= nil then return HOLD_ACTION_MAP[action] end if OBJECT_DEPENDENT_ACTIONS[action] then return fallback_action(action) end local group = action & ACT_GROUP_MASK if group == ACT_GROUP_CUTSCENE or group == ACT_GROUP_AUTOMATIC or group == ACT_GROUP_OBJECT then return fallback_action(action) end return action end local function clear_object_refs(m) m.heldObj = nil m.heldByObj = nil m.interactObj = nil m.riddenObj = nil m.usedObj = nil if m.marioObj ~= nil then m.marioObj.platform = nil m.marioObj.usingObj = nil end if m.statusForCamera ~= nil then m.statusForCamera.usedObj = nil m.statusForCamera.cameraEvent = 0 end end local function refresh_collision(m) local pos = m.pos local floorHeight, floor = find_floor(pos.x, pos.y, pos.z) if floor ~= nil then m.floor = floor m.floorHeight = floorHeight if floor.normal ~= nil then m.floorAngle = atan2s(floor.normal.z, floor.normal.x) end else m.floorHeight = floorHeight end local ceilHeight, ceil = find_ceil(pos.x, pos.y, pos.z) if ceil ~= nil then m.ceil = ceil m.ceilHeight = ceilHeight else m.ceilHeight = ceilHeight end m.waterLevel = find_water_level(pos.x, pos.z) end local function restore_camera(m, s) if m.area == nil or m.area.camera == nil then return end local c = m.area.camera reset_camera(c) c.cutscene = 0 local mode = s.camMode if mode ~= nil and not UNSAFE_CAMERA_MODES[mode] then set_camera_mode(c, mode, 0) end if s.camYaw ~= nil then c.yaw = s.camYaw c.nextYaw = nvl(s.camNextYaw, s.camYaw) gLakituState.yaw = c.yaw gLakituState.nextYaw = c.nextYaw gLakituState.oldYaw = c.yaw end if m.statusForCamera ~= nil then apply_vec(m.statusForCamera.pos, s.pos) apply_vec(m.statusForCamera.faceAngle, s.faceAngle) m.statusForCamera.action = sanitize_action(s.action) m.statusForCamera.cameraEvent = 0 end skip_camera_interpolation() end local function snapshot_mario(m) local np = gNetworkPlayers[0] local c = (m.area ~= nil) and m.area.camera or nil local bs = m.marioBodyState local s = { level = np.currLevelNum, course = np.currCourseNum, act = np.currActNum, area = np.currAreaIndex, action = m.action, actionArg = m.actionArg, actionState = m.actionState, actionTimer = m.actionTimer, prevAction = m.prevAction, flags = m.flags, cap = m.cap, capTimer = m.capTimer, health = m.health, hurtCounter = m.hurtCounter, healCounter = m.healCounter, invincTimer = m.invincTimer, knockbackTimer = m.knockbackTimer, wallKickTimer = m.wallKickTimer, doubleJumpTimer = m.doubleJumpTimer, squishTimer = m.squishTimer, bounceSquishTimer = m.bounceSquishTimer, fadeWarpOpacity = m.fadeWarpOpacity, peakHeight = m.peakHeight, quicksandDepth = m.quicksandDepth, forwardVel = m.forwardVel, slideVelX = m.slideVelX, slideVelZ = m.slideVelZ, slideYaw = m.slideYaw, twirlYaw = m.twirlYaw, intendedMag = m.intendedMag, intendedYaw = m.intendedYaw, framesSinceA = m.framesSinceA, framesSinceB = m.framesSinceB, particleFlags = m.particleFlags, collidedObjInteractTypes = 0, numCoins = m.numCoins, numLives = m.numLives, currentRoom = m.currentRoom, terrainSoundAddend = m.terrainSoundAddend, specialTripleJump = m.specialTripleJump, isSnoring = m.isSnoring, freeze = 0, seq = save_seq, savedAt = get_time(), pos = copy_vec(m.pos), vel = copy_vec(m.vel), faceAngle = copy_vec(m.faceAngle), angleVel = copy_vec(m.angleVel), wallNormal = copy_vec(m.wallNormal), nonInstantWarpPos = copy_vec(m.nonInstantWarpPos), camMode = c and c.mode or nil, camYaw = c and c.yaw or nil, camNextYaw = c and c.nextYaw or nil, camPos = c and copy_vec(c.pos) or nil, camFocus = c and copy_vec(c.focus) or nil, lakituPos = copy_vec(gLakituState.pos), lakituFocus = copy_vec(gLakituState.focus) } if m.area ~= nil then s.numRedCoins = m.area.numRedCoins s.numSecrets = m.area.numSecrets end local dt = get_date_and_time() if dt ~= nil then -- tm_year is years since 1900, tm_mon is 0-11 s.savedYear = dt.year + 1900 s.savedMonth = dt.month + 1 s.savedDay = dt.day s.savedHour = dt.hour s.savedMinute = dt.minute s.savedSecond = dt.second end if bs ~= nil then s.body = { capState = bs.capState, eyeState = bs.eyeState, handState = bs.handState, punchState = bs.punchState, modelState = bs.modelState, grabPos = bs.grabPos, wingFlutter = bs.wingFlutter, action = bs.action, headAngle = copy_vec(bs.headAngle), torsoAngle = copy_vec(bs.torsoAngle) } end return s end local function apply_snapshot(m, s) if m == nil or s == nil then return false end clear_object_refs(m) apply_vec(m.pos, s.pos) apply_vec(m.vel, s.vel) apply_vec(m.faceAngle, s.faceAngle) apply_vec(m.angleVel, s.angleVel) apply_vec(m.wallNormal, s.wallNormal) apply_vec(m.nonInstantWarpPos, s.nonInstantWarpPos) if m.marioObj ~= nil and m.marioObj.header ~= nil and m.marioObj.header.gfx ~= nil then apply_vec(m.marioObj.header.gfx.pos, s.pos) apply_vec(m.marioObj.header.gfx.angle, s.faceAngle) end refresh_collision(m) local action = sanitize_action(s.action) drop_and_set_mario_action(m, action, nvl(s.actionArg, 0)) m.actionState = nvl(s.actionState, 0) m.actionTimer = nvl(s.actionTimer, 0) m.prevAction = nvl(s.prevAction, action) apply_vec(m.vel, s.vel) m.forwardVel = nvl(s.forwardVel, 0) m.slideVelX = nvl(s.slideVelX, 0) m.slideVelZ = nvl(s.slideVelZ, 0) m.slideYaw = nvl(s.slideYaw, 0) m.twirlYaw = nvl(s.twirlYaw, 0) m.intendedMag = nvl(s.intendedMag, 0) m.intendedYaw = nvl(s.intendedYaw, m.faceAngle.y) m.framesSinceA = nvl(s.framesSinceA, 0xFF) m.framesSinceB = nvl(s.framesSinceB, 0xFF) m.flags = nvl(s.flags, 0) m.cap = nvl(s.cap, 0) m.capTimer = nvl(s.capTimer, 0) m.hurtCounter = autoheal_enabled and 0 or nvl(s.hurtCounter, 0) m.healCounter = nvl(s.healCounter, 0) m.health = autoheal_enabled and 0x880 or nvl(s.health, 0x880) m.invincTimer = nvl(s.invincTimer, 0) m.knockbackTimer = nvl(s.knockbackTimer, 0) m.wallKickTimer = nvl(s.wallKickTimer, 0) m.doubleJumpTimer = nvl(s.doubleJumpTimer, 0) m.squishTimer = nvl(s.squishTimer, 0) m.bounceSquishTimer = nvl(s.bounceSquishTimer, 0) m.fadeWarpOpacity = nvl(s.fadeWarpOpacity, 0xFF) m.peakHeight = nvl(s.peakHeight, s.pos.y) m.quicksandDepth = nvl(s.quicksandDepth, 0) m.numLives = nvl(s.numLives, m.numLives) m.currentRoom = nvl(s.currentRoom, 0) m.terrainSoundAddend = nvl(s.terrainSoundAddend, 0) m.specialTripleJump = nvl(s.specialTripleJump, 0) m.isSnoring = 0 m.freeze = 0 m.collidedObjInteractTypes = 0 m.skipWarpInteractionsTimer = 20 if savecoins_enabled and s.numCoins ~= nil then m.numCoins = s.numCoins gHudDisplay.coins = s.numCoins end if m.area ~= nil then if s.numRedCoins ~= nil then m.area.numRedCoins = s.numRedCoins end if s.numSecrets ~= nil then m.area.numSecrets = s.numSecrets end end if s.body ~= nil and m.marioBodyState ~= nil then local bs = m.marioBodyState local b = s.body bs.capState = nvl(b.capState, 0) bs.eyeState = nvl(b.eyeState, 0) bs.handState = nvl(b.handState, 0) bs.punchState = nvl(b.punchState, 0) bs.modelState = nvl(b.modelState, 0) bs.grabPos = nvl(b.grabPos, 0) bs.wingFlutter = nvl(b.wingFlutter, 0) bs.action = nvl(b.action, 0) apply_vec(bs.headAngle, b.headAngle) apply_vec(bs.torsoAngle, b.torsoAngle) end restore_camera(m, s) refresh_collision(m) m.particleFlags = PARTICLE_HORIZONTAL_STAR return true end local function history_truncate_redo() for i = #undo_stack, undo_index + 1, -1 do undo_stack[i] = nil end end local function history_push(s) undo_index = undo_index + 1 undo_stack[undo_index] = s while undo_index > MAX_UNDO do table.remove(undo_stack, 1) undo_index = undo_index - 1 end end local function history_before_load(m) if applying_history or skip_history_before then return end history_truncate_redo() history_push(snapshot_mario(m)) end local function history_after_load(m) if applying_history then return end history_push(snapshot_mario(m)) end local function reset_pending_load() pending_snapshot = nil pending_popup = nil pending_is_history = false warping_for_load = false frames_in_target = 0 skip_history_before = false end local function apply_or_warp(m, s, popup, is_history) if m == nil or s == nil then return false end applying_history = is_history if not location_matches_snapshot(s) then warping_for_load = true pending_snapshot = s pending_popup = popup pending_is_history = is_history skip_history_before = true local ok = warp_to_level(s.level, s.area, s.act) if not ok then reset_pending_load() applying_history = false play_mario_sound(m, SOUND_GENERAL_FLAME_OUT) djui_popup_create("\\#a0a0a0\\Failed to warp to saved level!", 1) return false end djui_popup_create("\\#ff80ff\\Warping to saved level...", 1) return false end apply_snapshot(m, s) if not is_history then history_after_load(m) end applying_history = false skip_history_before = false play_mario_sound(m, SOUND_GENERAL_GRAND_STAR_JUMP) if popup ~= nil then djui_popup_create(popup, 1) end return true end local function undo_load(m) if m == nil then return false end if pending_snapshot ~= nil then return false end if undo_index <= 1 then play_mario_sound(m, SOUND_GENERAL_FLAME_OUT) djui_popup_create("\\#a0a0a0\\Nothing to undo!", 1) return false end undo_index = undo_index - 1 return apply_or_warp(m, undo_stack[undo_index], "\\#80ffff\\[Undo Load] Restored previous state", true) end local function redo_load(m) if m == nil then return false end if pending_snapshot ~= nil then return false end if undo_index >= #undo_stack then play_mario_sound(m, SOUND_GENERAL_FLAME_OUT) djui_popup_create("\\#a0a0a0\\Nothing to redo!", 1) return false end undo_index = undo_index + 1 return apply_or_warp(m, undo_stack[undo_index], "\\#80ff80\\[Redo Load] Restored loaded state", true) end local function sync_menu_checkboxes() if menu_autoload ~= nil then update_mod_menu_element_checkbox(menu_autoload, autoload_enabled) end if menu_autoheal ~= nil then update_mod_menu_element_checkbox(menu_autoheal, autoheal_enabled) end if menu_savecoins ~= nil then update_mod_menu_element_checkbox(menu_savecoins, savecoins_enabled) end if menu_swap_controls ~= nil then update_mod_menu_element_checkbox(menu_swap_controls, swap_controls_enabled) end end local function persist_options() mod_storage_save_bool("opt_autoload", autoload_enabled) mod_storage_save_bool("opt_autoheal", autoheal_enabled) mod_storage_save_bool("opt_savecoins", savecoins_enabled) mod_storage_save_bool("opt_swap_controls", swap_controls_enabled) end local function load_persisted_options() autoload_enabled = mod_storage_load_bool("opt_autoload", false) autoheal_enabled = mod_storage_load_bool("opt_autoheal", false) savecoins_enabled = mod_storage_load_bool("opt_savecoins", false) swap_controls_enabled = mod_storage_load_bool("opt_swap_controls", false) end local function set_autoload(enabled, announce) autoload_enabled = enabled persist_options() sync_menu_checkboxes() if announce then if enabled then djui_chat_message_create("\\#80ff80\\AutoLoad latest savestate on death is now \\#00ff00\\ENABLED") else djui_chat_message_create("\\#ff8080\\AutoLoad latest savestate on death is now \\#ff0000\\DISABLED") end end end function reset_all() for slot = 0, SLOT_COUNT - 1 do savestates[slot] = nil end latest_savestate = nil save_seq = 0 undo_stack = {} undo_index = 0 applying_history = false reset_pending_load() refresh_slot_menu_labels() end function save_state_slot(m, slot, color) if m == nil then return false end save_seq = save_seq + 1 savestates[slot] = snapshot_mario(m) latest_savestate = slot m.particleFlags = PARTICLE_SPARKLES play_mario_sound(m, SOUND_GENERAL_GRAND_STAR) djui_popup_create(color .. "State saved to Slot " .. tostring(slot + 1) .. "!", 1) refresh_slot_menu_labels() return true end function load_state_slot(m, slot) if not is_state_slot_not_empty(slot) then play_mario_sound(m, SOUND_GENERAL_FLAME_OUT) djui_popup_create("\\#a0a0a0\\No State found in Slot " .. tostring(slot + 1) .. "!", 1) return false end local s = savestates[slot] history_before_load(m) latest_savestate = slot refresh_slot_menu_labels() return apply_or_warp(m, s, SLOT_LOAD_COLOR[slot] .. "State loaded from Slot " .. tostring(slot + 1) .. "!", false) end local function try_apply_pending_load(m) if pending_snapshot == nil then return false end if not location_matches_snapshot(pending_snapshot) then frames_in_target = 0 return true end frames_in_target = frames_in_target + 1 local np = gNetworkPlayers[0] local synced = np.currAreaSyncValid or frames_in_target >= 8 if frames_in_target >= 2 and synced then local s = pending_snapshot local popup = pending_popup local isHistory = pending_is_history reset_pending_load() skip_history_before = true apply_or_warp(m, s, popup, isHistory) end return true end local function get_modfs() return mod_fs_get() or mod_fs_create() end local function sanitize_save_name(name) if name == nil then return nil end name = name:match("^%s*(.-)%s*$") or "" name = name:gsub("%s+", "_") name = name:gsub("[^%w_%-]", "") if #name < 1 or #name > 32 then return nil end return name end local function named_path(name) return "named/" .. name .. ".sav" end local function named_filepath_to_name(filepath) if filepath == nil then return nil end local name = filepath:match("named/(.+)%.sav$") if name == nil then name = filepath:match("([^/]+)%.sav$") end return name end local function list_named_saves() local fs = get_modfs() if fs == nil then return {} end local names = {} for i = 0, fs.numFiles - 1 do local filename = fs:get_filename(i) local name = named_filepath_to_name(filename) if name ~= nil then names[#names + 1] = name end end table.sort(names) return names end local function count_filled_slots() local count = 0 for slot = 0, SLOT_COUNT - 1 do if is_state_slot_not_empty(slot) then count = count + 1 end end return count end local function save_named_set(name) name = sanitize_save_name(name) if name == nil then return false, "Invalid name. Use 1-32 letters, numbers, _ or -" end if count_filled_slots() == 0 then return false, "No slots to save. Save a state first." end local slots = {} for i = 1, SLOT_COUNT do slots[i] = savestates[i - 1] end local pack = { v = 1, latest = latest_savestate, seq = save_seq, slots = slots } local fs = get_modfs() if fs == nil then return false, "Could not open save storage." end local path = named_path(name) local file = fs:get_file(path) or fs:create_file(path, true) if file == nil then return false, "Could not create named save." end file:set_text_mode(true) if file.size > 0 then file:erase(file.size) end file:rewind() file:write_string(json_encode(pack)) if not fs:save() then return false, "Could not write named save to disk." end return true, "Saved current slots as '" .. name .. "'" end local function load_named_set(name) name = sanitize_save_name(name) if name == nil then return false, "Invalid name." end local fs = get_modfs() if fs == nil then return false, "Could not open save storage." end local file = fs:get_file(named_path(name)) if file == nil then return false, "No named save called '" .. name .. "'" end file:set_text_mode(true) file:rewind() local data = json_decode(file:read_string()) if type(data) ~= "table" or type(data.slots) ~= "table" then return false, "Named save '" .. name .. "' is corrupted." end for slot = 0, SLOT_COUNT - 1 do local snap = data.slots[slot + 1] if type(snap) == "table" and snap.pos ~= nil then savestates[slot] = snap else savestates[slot] = nil end end save_seq = nvl(data.seq, save_seq) latest_savestate = data.latest if latest_savestate ~= nil and not is_state_slot_not_empty(latest_savestate) then latest_savestate = find_latest_slot_here() if latest_savestate == nil then for slot = 0, SLOT_COUNT - 1 do if is_state_slot_not_empty(slot) then latest_savestate = slot break end end end end refresh_slot_menu_labels() if count_filled_slots() == 0 then return false, "Named save '" .. name .. "' has no slots." end print_slot_infos("Named save '" .. name .. "' copied to RAM slots") return true, "Named save '" .. name .. "' loaded into RAM (not applied)" end local function delete_named_set(name) name = sanitize_save_name(name) if name == nil then return false, "Invalid name." end local fs = get_modfs() if fs == nil then return false, "Could not open save storage." end local path = named_path(name) if fs:get_file(path) == nil then return false, "No named save called '" .. name .. "'" end if not fs:delete_file(path) then return false, "Could not delete '" .. name .. "'" end fs:save() return true, "Deleted named save '" .. name .. "'" end local function show_named_list(page) local names = list_named_saves() local total = #names if total == 0 then djui_chat_message_create("\\#a0a0a0\\No named saves yet. Use /sssave ") return true end local pages = math.floor((total - 1) / LIST_PER_PAGE) + 1 if page < 1 then page = 1 end if page > pages then page = pages end named_list_page = page local from = (page - 1) * LIST_PER_PAGE + 1 local to = math.min(total, page * LIST_PER_PAGE) djui_chat_message_create("\\#40b0ff\\[Named saves\\#808080\\ " .. tostring(page) .. "/" .. tostring(pages) .. "\\#40b0ff\\]") for i = from, to do djui_chat_message_create("\\#ffff80\\- " .. names[i]) end if pages > 1 then djui_chat_message_create("\\#808080\\/sslist " .. tostring(page < pages and (page + 1) or 1) .. " for more") end return true end local function announce_named(ok, message) if ok then djui_chat_message_create("\\#80ff80\\" .. message) djui_popup_create("\\#80ff80\\" .. message, 1) else djui_chat_message_create("\\#ff8080\\" .. message) local m = gMarioStates[0] play_mario_sound(m, SOUND_GENERAL_FLAME_OUT) end end function on_join(m) if m.playerIndex == 0 then reset_all() end end function on_leave(m) if m.playerIndex == 0 then reset_all() end end function on_death(m) if m.playerIndex ~= 0 then return true end if not autoload_enabled then return true end local slot = find_latest_slot_here() if slot == nil then return true end m.health = 0x880 m.hurtCounter = 0 m.numLives = m.numLives + 1 djui_popup_create("\\#ff80ff\\[AutoLoad] Loading latest state in this level...", 1) load_state_slot(m, slot) return false end function on_level_init() frames_in_target = 0 end function on_warp() frames_in_target = 0 end function on_mario_update(m) if m.playerIndex ~= 0 then return end if try_apply_pending_load(m) then return end local lHeld = (m.controller.buttonDown & L_TRIG) ~= 0 local doLoad = (swap_controls_enabled ~= lHeld) if (m.controller.buttonPressed & L_JPAD) ~= 0 then if doLoad then load_state_slot(m, 0) else save_state_slot(m, 0, SLOT_SAVE_COLOR[0]) end elseif (m.controller.buttonPressed & U_JPAD) ~= 0 then if doLoad then load_state_slot(m, 1) else save_state_slot(m, 1, SLOT_SAVE_COLOR[1]) end elseif (m.controller.buttonPressed & R_JPAD) ~= 0 then if doLoad then load_state_slot(m, 2) else save_state_slot(m, 2, SLOT_SAVE_COLOR[2]) end elseif (m.controller.buttonPressed & D_JPAD) ~= 0 then if doLoad then load_state_slot(m, 3) else save_state_slot(m, 3, SLOT_SAVE_COLOR[3]) end end end function on_cmd_autoload() set_autoload(not autoload_enabled, true) return true end function on_cmd_autoheal() autoheal_enabled = not autoheal_enabled persist_options() sync_menu_checkboxes() if autoheal_enabled then djui_chat_message_create("\\#80ff80\\AutoHeal after loading savestate is now \\#00ff00\\ENABLED") else djui_chat_message_create("\\#ff8080\\AutoHeal after loading savestate is now \\#ff0000\\DISABLED") end return true end function on_cmd_savecoins() savecoins_enabled = not savecoins_enabled persist_options() sync_menu_checkboxes() if savecoins_enabled then djui_chat_message_create("\\#80ff80\\Restore coin count on load is now \\#00ff00\\ENABLED") else djui_chat_message_create("\\#ff8080\\Restore coin count on load is now \\#ff0000\\DISABLED") end return true end function on_cmd_swap_controls() swap_controls_enabled = not swap_controls_enabled persist_options() sync_menu_checkboxes() if swap_controls_enabled then djui_chat_message_create("\\#80ff80\\Controls swapped: \\#ffffff\\D-Pad loads\\#808080\\, \\#ffffff\\L+D-Pad saves") else djui_chat_message_create("\\#80ff80\\Controls default: \\#ffffff\\D-Pad saves\\#808080\\, \\#ffffff\\L+D-Pad loads") end return true end function on_cmd_undo() undo_load(gMarioStates[0]) return true end function on_cmd_redo() redo_load(gMarioStates[0]) return true end function on_cmd_sssave(msg) local ok, message = save_named_set(msg) announce_named(ok, message) return true end function on_cmd_ssload(msg) local ok, message = load_named_set(msg) announce_named(ok, message) return true end function on_cmd_sslist(msg) local page = tonumber(msg) if page == nil or page < 1 then page = 1 else page = math.floor(page) end show_named_list(page) return true end function on_cmd_ssdel(msg) local ok, message = delete_named_set(msg) announce_named(ok, message) return true end function on_cmd_savestate() local hereSlot = find_latest_slot_here() local hereText = hereSlot ~= nil and ("\\#ffff80\\(Here: Slot " .. tostring(hereSlot + 1) .. ")") or "\\#ff8040\\(Here: none)" djui_chat_message_create("\\#40b0ff\\[\\#40ffff\\LOCAL SAVESTATE-MOD SLOTS AND SETTINGS INFOS\\#40b0ff\\]") djui_chat_message_create("\\#b0b0b0\\AutoLoad\\#808080\\: " .. on_off_text(autoload_enabled) .. " " .. hereText .. " \\#b0b0b0\\AutoHeal\\#808080\\: " .. on_off_text(autoheal_enabled)) djui_chat_message_create("\\#b0b0b0\\SaveCoins\\#808080\\: " .. on_off_text(savecoins_enabled) .. " \\#b0b0b0\\Swap controls\\#808080\\: " .. on_off_text(swap_controls_enabled)) djui_chat_message_create("\\#b0b0b0\\Undo/Redo Load\\#808080\\: " .. tostring(math.max(undo_index - 1, 0)) .. "/" .. tostring(math.max(#undo_stack - undo_index, 0)) .. " \\#808080\\" .. controls_hint()) djui_chat_message_create("\\#b0b0b0\\Named saves\\#808080\\: " .. tostring(#list_named_saves()) .. " \\#808080\\(/sssave /ssload /sslist /ssdel)") print_slot_infos(nil) return true end local function on_menu_autoload(_, value) autoload_enabled = value persist_options() end local function on_menu_autoheal(_, value) autoheal_enabled = value persist_options() end local function on_menu_savecoins(_, value) savecoins_enabled = value persist_options() end local function on_menu_swap_controls(_, value) swap_controls_enabled = value persist_options() end local function on_menu_named_input(_, value) named_input = value end local function on_menu_undo() undo_load(gMarioStates[0]) end local function on_menu_redo() redo_load(gMarioStates[0]) end local function on_menu_named_save() local ok, message = save_named_set(named_input) announce_named(ok, message) end local function on_menu_named_load() local ok, message = load_named_set(named_input) announce_named(ok, message) end local function on_menu_named_delete() local ok, message = delete_named_set(named_input) announce_named(ok, message) end local function on_menu_tutorial() djui_chat_message_create("\\#40b0ff\\[\\#40ffff\\SAVESTATES TUTORIAL\\#40b0ff\\]") if swap_controls_enabled then djui_chat_message_create("\\#ffff80\\Slots:\\#b0b0b0\\ You have 4 local RAM slots. D-Pad loads, hold L + D-Pad saves. These are lost when you leave unless you store a named set.") djui_chat_message_create("\\#ffff80\\Load a set:\\#b0b0b0\\ That only copies the 4 slots into RAM (Mario does not move). Use D-Pad to apply a slot. Later pages show the current RAM slots.") else djui_chat_message_create("\\#ffff80\\Slots:\\#b0b0b0\\ You have 4 local RAM slots. D-Pad saves, hold L + D-Pad loads. These are lost when you leave unless you store a named set.") djui_chat_message_create("\\#ffff80\\Load a set:\\#b0b0b0\\ That only copies the 4 slots into RAM (Mario does not move). Use L+D-Pad to apply a slot. Later pages show the current RAM slots.") end djui_chat_message_create("\\#ffff80\\Named sets:\\#b0b0b0\\ Page 2 - type a name, then Save / Load / Delete. Named sets persist after restart.") djui_chat_message_create("\\#ffff80\\Options:\\#b0b0b0\\ AutoLoad restores the latest slot here on death. AutoHeal / Restore coins apply on load. Undo / Redo step through loads. Swap save/load flips D-Pad vs L+D-Pad.") djui_chat_message_create("\\#80ffff\\Use the Commands / Help button to see all chat commands.") end local function on_menu_help() djui_chat_message_create("\\#40b0ff\\[\\#40ffff\\SAVESTATES COMMANDS\\#40b0ff\\]") djui_chat_message_create("\\#80ffff\\/autoload\\#808080\\ (/al) \\#b0b0b0\\AutoLoad on death in the same level/area/act") djui_chat_message_create("\\#80ffff\\/autoheal\\#808080\\ (/ah) \\#b0b0b0\\Heal after loading a state") djui_chat_message_create("\\#80ffff\\/savecoins\\#808080\\ (/sc) \\#b0b0b0\\Restore coin count on load") djui_chat_message_create("\\#80ffff\\/swapcontrols\\#808080\\ (/swc) \\#b0b0b0\\Swap D-Pad save/load") djui_chat_message_create("\\#80ffff\\/undoload\\#808080\\ (/ul) \\#b0b0b0\\Undo last load \\#80ffff\\/redoload\\#808080\\ (/rl) \\#b0b0b0\\Redo undone load") djui_chat_message_create("\\#80ffff\\/sssave\\#808080\\ \\#b0b0b0\\Save current 4 slots under a name") djui_chat_message_create("\\#80ffff\\/ssload\\#808080\\ \\#b0b0b0\\Copy a named slot-set into RAM (does not apply)") djui_chat_message_create("\\#80ffff\\/sslist\\#808080\\ [page] \\#b0b0b0\\List named saves \\#80ffff\\/ssdel\\#808080\\ \\#b0b0b0\\Delete a named save \\#80ffff\\/ss\\#808080\\ \\#b0b0b0\\Show currentslots and settings") end local function on_menu_named_list() local names = list_named_saves() local pages = 1 if #names > 0 then pages = math.floor((#names - 1) / LIST_PER_PAGE) + 1 end named_list_page = named_list_page + 1 if named_list_page > pages then named_list_page = 1 end show_named_list(named_list_page) end function on_update() if is_game_paused() then refresh_slot_menu_labels() end end hook_event(HOOK_ON_PLAYER_CONNECTED, on_join) hook_event(HOOK_ON_PLAYER_DISCONNECTED, on_leave) hook_event(HOOK_ON_DEATH, on_death) hook_event(HOOK_ON_LEVEL_INIT, on_level_init) hook_event(HOOK_ON_WARP, on_warp) hook_event(HOOK_MARIO_UPDATE, on_mario_update) hook_event(HOOK_UPDATE, on_update) hook_chat_command("autoload", "toggle AutoLoad of the latest savestate in the current level/area/act on death", on_cmd_autoload) hook_chat_command("al", "toggle AutoLoad of the latest savestate in the current level/area/act on death", on_cmd_autoload) hook_chat_command("autoheal", "toggle healing Mario after loading a savestate", on_cmd_autoheal) hook_chat_command("ah", "toggle healing Mario after loading a savestate", on_cmd_autoheal) hook_chat_command("savecoins", "toggle restoring the saved coin count when loading a state", on_cmd_savecoins) hook_chat_command("sc", "toggle restoring the saved coin count when loading a state", on_cmd_savecoins) hook_chat_command("swapcontrols", "swap D-Pad save/load (D-Pad load, L+D-Pad save)", on_cmd_swap_controls) hook_chat_command("swc", "swap D-Pad save/load (D-Pad load, L+D-Pad save)", on_cmd_swap_controls) hook_chat_command("undoload", "undo the last savestate load", on_cmd_undo) hook_chat_command("ul", "undo the last savestate load", on_cmd_undo) hook_chat_command("undo", "undo the last savestate load", on_cmd_undo) hook_chat_command("redoload", "redo the last undone savestate load", on_cmd_redo) hook_chat_command("rl", "redo the last undone savestate load", on_cmd_redo) hook_chat_command("redo", "redo the last undone savestate load", on_cmd_redo) hook_chat_command("sssave", " save current 4 slots under a name", on_cmd_sssave) hook_chat_command("saveset", " save current 4 slots under a name", on_cmd_sssave) hook_chat_command("ssload", " copy a named slot-set into RAM without applying it", on_cmd_ssload) hook_chat_command("loadset", " copy a named slot-set into RAM without applying it", on_cmd_ssload) hook_chat_command("sslist", "[page] list named saves", on_cmd_sslist) hook_chat_command("listsets", "[page] list named saves", on_cmd_sslist) hook_chat_command("ssdel", " delete a named save", on_cmd_ssdel) hook_chat_command("deleteset", " delete a named save", on_cmd_ssdel) hook_chat_command("savestate", "list infos about your local savestates and settings", on_cmd_savestate) hook_chat_command("savestates", "list infos about your local savestates and settings", on_cmd_savestate) hook_chat_command("ss", "list infos about your local savestates and settings", on_cmd_savestate) load_persisted_options() -- Page 1 (8 items): settings, undo/redo, tutorial menu_autoload = hook_mod_menu_checkbox("AutoLoad on death (current level/area/act)", autoload_enabled, on_menu_autoload) menu_autoheal = hook_mod_menu_checkbox("AutoHeal on load", autoheal_enabled, on_menu_autoheal) menu_savecoins = hook_mod_menu_checkbox("Restore coins on load", savecoins_enabled, on_menu_savecoins) menu_swap_controls = hook_mod_menu_checkbox("Swap controls (L + D-Pad = Save / D-Pad = Load)", swap_controls_enabled, on_menu_swap_controls) hook_mod_menu_button("Undo load", on_menu_undo) hook_mod_menu_button("Redo load", on_menu_redo) hook_mod_menu_button("Info / Tutorial", on_menu_tutorial) hook_mod_menu_button("Commands / Help", on_menu_help) -- Page 2 (8 items): commands + named saves hook_mod_menu_text("Named savestate sets (persist after restart)") menu_named_input = hook_mod_menu_inputbox("Name", "", 32, on_menu_named_input) hook_mod_menu_button("Save named slots", on_menu_named_save) hook_mod_menu_button("Load named slots", on_menu_named_load) hook_mod_menu_button("Delete named save", on_menu_named_delete) hook_mod_menu_button("List named saves", on_menu_named_list) hook_mod_menu_text("\\#0F0\\Mod by iZePlayz \\#0FF\\(Support/Help Discord: izeplayz)") hook_mod_menu_text(" ") -- Pages 3-4: 3 lines per slot as separate vanilla texts (8 items/page). -- A slot is never split; page 3 ends with a hint for slots 3-4. for slot = 0, SLOT_COUNT - 1 do local lines = slot_info_lines(slot) menu_slot_info[slot] = { hook_mod_menu_text(lines[1]), hook_mod_menu_text(lines[2]), hook_mod_menu_text(lines[3]) } if slot == 1 then hook_mod_menu_text(" ") hook_mod_menu_text("Slots 3 and 4 on next page") elseif slot == 3 then hook_mod_menu_text(" ") hook_mod_menu_text(" ") end end