-- ============================================================ -- recentFiles.lua -- Features: Automatically load matching CT table based on process window title (rule / fuzzy matching) -- Version: 2.4.2 (Removed CONSTANTS read-only protection, removed pipe truncation, split long functions) -- ============================================================ local recentFiles = {} -- ★ Constants (centralized for easy maintenance) local CONSTANTS = { BUFFER_SIZE = 2048, -- Window title buffer size (characters) SEARCH_DEBOUNCE_MS = 300, -- Switch CT window search box debounce delay (ms) DEFAULT_DELAY_MS = 20, -- Auto‑load delay after process switch (ms) DEFAULT_INTERVAL_MS = 5000, -- Auto re‑match timer interval (ms) } -- ★ Full‑width → half‑width mapping table (file‑level constant, avoids repeated creation) local HALFWIDTH_MAP = { ["A"] = "A", ["B"] = "B", ["C"] = "C", ["D"] = "D", ["E"] = "E", ["F"] = "F", ["G"] = "G", ["H"] = "H", ["I"] = "I", ["J"] = "J", ["K"] = "K", ["L"] = "L", ["M"] = "M", ["N"] = "N", ["O"] = "O", ["P"] = "P", ["Q"] = "Q", ["R"] = "R", ["S"] = "S", ["T"] = "T", ["U"] = "U", ["V"] = "V", ["W"] = "W", ["X"] = "X", ["Y"] = "Y", ["Z"] = "Z", ["a"] = "a", ["b"] = "b", ["c"] = "c", ["d"] = "d", ["e"] = "e", ["f"] = "f", ["g"] = "g", ["h"] = "h", ["i"] = "i", ["j"] = "j", ["k"] = "k", ["l"] = "l", ["m"] = "m", ["n"] = "n", ["o"] = "o", ["p"] = "p", ["q"] = "q", ["r"] = "r", ["s"] = "s", ["t"] = "t", ["u"] = "u", ["v"] = "v", ["w"] = "w", ["x"] = "x", ["y"] = "y", ["z"] = "z", ["0"] = "0", ["1"] = "1", ["2"] = "2", ["3"] = "3", ["4"] = "4", ["5"] = "5", ["6"] = "6", ["7"] = "7", ["8"] = "8", ["9"] = "9", [" "] = " ", -- Full‑width space to half‑width ["-"] = "-", ["―"] = "-", ["~"] = "~", ["!"] = "!", ["""] = "\"", ["#"] = "#", ["$"] = "$", ["%"] = "%", ["&"] = "&", ["'"] = "'", ["("] = "(", [")"] = ")", ["*"] = "*", ["+"] = "+", [","] = ",", ["."] = ".", ["/"] = "/", [":"] = ":", [";"] = ";", ["<"] = "<", ["="] = "=", [">"] = ">", ["?"] = "?", ["@"] = "@", ["["] = "[", ["\"] = "\\", ["]"] = "]", ["^"] = "^", ["_"] = "_", ["`"] = "`", ["{"] = "{", ["|"] = "|", ["}"] = "}", ["「"] = "「", ["」"] = "」", } -- ★ Debug log encapsulation (only outputs when DEBUG = true) local function debugLog(...) if recentFiles.DEBUG then print(...) end end -- ★ Stop‑word hash set (pre‑converted, O(1) lookup) local _stopWordSet = nil local function getStopWordSet() if not _stopWordSet then _stopWordSet = {} for _, w in ipairs(recentFiles.STOP_WORDS) do _stopWordSet[w] = true end end return _stopWordSet end -- ★ Internal cache variables (no need to modify) local _cachedRawTitle = nil local _cachedCleanTitle = nil local _processIndex = nil local _autoLoadTimer = nil local _rematchTimer = nil local _lastSignificantTitle = nil local _lastPid = 0 recentFiles.LastLoadMethod = nil -- ============================================================ -- ★★★ Configuration Area (User Adjustable) ★★★ -- ============================================================ -- History filename (located in CE installation directory) recentFiles.FileName = 'recentFiles.txt' -- Maximum number of records (older entries are automatically removed) recentFiles.NumberOfEntries = 5000 -- Whether to ignore files with .CETRAINER extension (do not record) recentFiles.IgnoreCETRAINER = false -- Whether to automatically load the latest matching CT table on process switch -- true → auto‑load; false → only show the selection list for manual choice recentFiles.AUTO_LOAD_LATEST = true -- ★★★ Enable auto‑load on process attach ★★★ -- true → enabled; false → completely disable auto‑load, only keep "Switch CT" menu for manual loading recentFiles.ENABLE_AUTO_LOAD_ON_ATTACH = true -- ★★★ Enable delayed loading ★★★ -- true → delay execution by AUTO_LOAD_DELAY milliseconds, avoiding conflict with CE's own prompt for same‑named tables -- false → execute immediately (synchronous), may override CE's own prompt recentFiles.ENABLE_AUTO_LOAD_DELAY = true -- ★★★ Delay time in milliseconds ★★★ -- Only effective when ENABLE_AUTO_LOAD_DELAY = true recentFiles.AUTO_LOAD_DELAY = 20 -- ★★★ Whether to show overlay/merge confirmation dialog on auto‑load ★★★ -- true → popup asking "Overwrite/Merge/Cancel", showing target filename -- false → direct overwrite load (silent) recentFiles.ENABLE_LOAD_CONFIRMATION = true -- ★★★ Whether to show overlay/merge confirmation dialog on manual load ★★★ -- true → manual load (Switch CT double‑click/load/drag/open button) shows script dialog (only once) -- false → direct overwrite load (silent) recentFiles.ENABLE_MANUAL_LOAD_CONFIRMATION = true -- ★★★ Enable custom save filename feature ★★★ -- true → when saving, if current CT does not match window title, generate new filename from window title -- false → use CE default save behavior (affected by SMART_SAVE) recentFiles.ENABLE_CUSTOM_SAVE = false -- ★★★ Smart save: decide overwrite or new based on title match ★★★ -- true → if current file matches window title, overwrite; otherwise create new with window title -- false → disable smart, use ENABLE_CUSTOM_SAVE decision recentFiles.SMART_SAVE = true -- ★★★ Title cache switch ★★★ -- true → cache preprocessed title for performance -- false → process each time (debug) recentFiles.ENABLE_TITLE_CACHE = true -- ★★★ Stop word filter switch ★★★ -- true → ignore STOP_WORDS list in fuzzy matching (improves precision) -- false → no filtering recentFiles.ENABLE_STOP_WORDS = true -- ★★★ Whether to filter Chinese single‑character stop words ★★★ -- true → if ENABLE_STOP_WORDS is true, also filter Chinese single‑character words (old behavior) -- false → do not filter Chinese single‑character words, preventing complete filtering of Chinese titles (recommended) recentFiles.ENABLE_CHINESE_STOP_WORDS = false -- ★★★ Fuzzy matching parameters ★★★ recentFiles.FUZZY_MIN_WORDS = 1 -- Minimum common words (set to 1 to capture a single core word) recentFiles.FUZZY_THRESHOLD = 0.4 -- Similarity threshold (0~1, higher = stricter) -- ★★★ Scoring mode ★★★ -- 0 = Original LCS/Union (denominator is length of title+filename union) -- 1 = New LCS/#titleWords (denominator is only title length; short filenames are no longer penalized) recentFiles.SCORE_MODE = 1 -- ★★★ When using the new mode, if title word count is below this value, automatically fallback to old mode to avoid ties ★★★ recentFiles.MIN_TITLE_WORDS_FOR_NEW_SCORE = 1 -- ★★★ Save matching parameters (independent of fuzzy matching, for deciding overwrite/new) ★★★ recentFiles.SAVE_MATCH_MIN_WORDS = 2 recentFiles.SAVE_MATCH_THRESHOLD = 0.6 -- ★★★ Split camel case ★★★ -- Note: this config is now forced enabled (internal ignores this value); kept only for reference recentFiles.SPLIT_CAMEL_CASE = true -- ★★★ Stop words list (only effective when ENABLE_STOP_WORDS = true) ★★★ recentFiles.STOP_WORDS = { -- Chinese complete words (generic suffixes/prefixes with little meaning) "版本", "汉化", "修正", "补丁", "中文", "简体", "繁体", "测试", "正式", "发布", "加强", "完整", "汉化组", "破解", "免安装", "绿色", "便携", "整合", "合集", "合集包", "典藏", "珍藏", "模拟器", "游戏", "设置", "配置", "工具", "助手", "修改器", "补丁包", "升级", "更新", "下载", "分享", "转存", "备份", "优化", "稳定", "流畅", "高清", "重制", "复刻", "珍藏版", "典藏版", "终极版", "年度版", "黄金版", "豪华版", "特别版", -- Chinese single characters (filtered only when ENABLE_CHINESE_STOP_WORDS = true) "汉", "化", "版", "简", "体", "中", "文", "修", "正", "补", "丁", "加", "强", "完", "整", "测", "试", "正", "式", "发", "布", "组", "繁", "体", -- English common stop words (all lowercase) "the", "of", "and", "to", "in", "for", "on", "at", "with", "without", "by", "from", "up", "down", "off", "over", "under", "etc", "vs", "a", "an", "this", "that", "these", "those", "some", "any", "all", "both", "each", "either", "neither", "every", "other", "such", "which", "who", "whom", "whose", "can", "may", "will", "shall", "should", "could", "would", "might", "must", "is", "am", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "doing", "not", "nor", "but", "or", "yet", "so", "for", "nor", "per", "via", "versus", "vs.", "inc", "ltd", "co", "corp", "llc", "edition", "version", "patch", "update", "fix", "repack", "portable", "steam", "gog", "origin", "uplay", "epic", "xbox", "playstation", "nintendo", "switch", "ps4", "ps5", } -- ★★★ Auto‑add rule on save switch ★★★ -- true → when saving a CT table, if the entry has no rule and filename match is insufficient, automatically set current window title as rule. -- false → disable this feature recentFiles.AUTO_ADD_RULE_ON_SAVE = true -- ★★★ Show current CT info in CE title bar ★★★ -- true → title bar shows " | RF:mode:CTfilename" -- false → don't show, keep CE original title recentFiles.SHOW_CT_INFO_IN_TITLE = true -- ★★★ Version priority (auto‑select highest version) ★★★ -- true → when multiple candidates exist via rule/fuzzy matching, choose the CT with highest version number -- false → fallback to earliest added order (no version sorting) recentFiles.ENABLE_VERSION_PRIORITY = true -- ★★★ Auto re‑match (load matching CT when window title changes) ★★★ recentFiles.ENABLE_AUTO_REMATCH = true recentFiles.AUTO_REMATCH_INTERVAL = CONSTANTS.DEFAULT_INTERVAL_MS recentFiles.AUTO_REMATCH_IGNORE_NUMBERS = true -- Ignore number changes (FPS/framerate etc.) recentFiles.AUTO_REMATCH_CHECK_WHEN_MANUAL = true recentFiles.AUTO_REMATCH_LOAD_LATEST_AS_FALLBACK = true -- Load latest record when no match -- ★★★ Debug output toggle ★★★ recentFiles.DEBUG = false -- Set to false after debugging to reduce console logs -- ============================================================ -- Title preprocessing function (remove dynamic content) -- ============================================================ recentFiles.TITLE_CLEANUP = function(title) if not title then return "" end -- First remove specific noise phrases (case‑insensitive) title = title:gsub("Rosalie's Mupen GUI", "", 1) title = title:gsub("%(%s*WinBuild%s*%.?%s*[%d%.]+%s*%)", "") title = title:gsub("[Vv]ersion%s*:?%s*[%d%.]+", "") title = title:gsub("[Bb]uild%s*:?%s*[%d%.]+", "") title = title:gsub("%(%s*%d+%.%d+%.%d+%.%d+%s*%)", "") title = title:gsub("[Vv]%s*%d+%.%d+%.?%d*%.?%d*", "") title = title:gsub("%s*%-%s*%d+%.%d+%.%d+[%w%.+%-]*", "") title = title:gsub("%s*%d+%.%d+%.%d+%.?%d*%s*", " ") title = title:gsub("%s*%d+%.%d+%.%d+[%-%+][%w%.+]+", "") title = title:gsub("%s*[Bb]uild%s*%d+", "") title = title:gsub("%s*[Rr]%s*%d+", "") title = title:gsub("%[%s*[Ee]mulation%s*%]", "") title = title:gsub("%[%s*[Pp]aused%s*%]", "") title = title:gsub("[%(%[【(]%s*Paused%s*[%]%)】)]", "") title = title:gsub("%s*[Mm]em%s*:?%s*%d+%s*[Mm][Bb]%s*,?", "") title = title:gsub("%s*[Cc][Pp][Uu]%s*:?%s*%d+%%%s*,?", "") title = title:gsub("%(%s*%d+%.?%d*%s*[Ff][Pp][Ss]%s*%)", "") title = title:gsub("%(%s*%d+%.?%d*%s*/%s*%d+%.?%d*%s*%)", "") title = title:gsub("%[%s*%d+%.?%d*%s*[Ff][Pp][Ss]%s*%]", "") title = title:gsub("%[%s*%d+%.?%d*%s*/%s*%d+%.?%d*%s*%]", "") title = title:gsub("[Ff][Pp][Ss]%s*:?%s*%d+%.?%d*", "") title = title:gsub("FPS%s*:?%s*%d+%.?%d*", "") title = title:gsub("([%- ])%s*(%d+%.?%d*)%%", "%1") title = title:gsub("[%(%[]%s*%d+%.?%d*%s*[KMG]?[Bb][Ii]?[Tt]?%s*[%)%]]", "") title = title:gsub("%(%s*%)", "") title = title:gsub("%[%s*%]", "") title = title:gsub("(%s*%)", "") title = title:gsub("【%s*】", "") title = title:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") return title end -- Process name normalization (remove .exe and version suffixes) recentFiles.PROCESS_NORMALIZE = function(processName) if not processName then return processName end local name = processName:gsub("%.exe$", "") name = name:gsub("[-_][0-9]+[%.]?[0-9]*$", "") if name == "" then return processName end return name end -- ============================================================ -- Internal implementation – modify only if necessary -- ============================================================ -- ★★★ Full‑width alnum → half‑width normalization ★★★ local function toHalfwidth(str) if not str or str == "" then return str end return str:gsub(".", function(c) return HALFWIDTH_MAP[c] or c end) end -- ★★★ Path normalization (supports UNC paths) ★★★ local function normalizePath(path) if not path or path == "" then return path end local unc = false if path:sub(1,2) == "\\\\" then unc = true path = path:sub(3) end path = path:gsub("\\+", "\\") path = path:gsub("/", "\\") if unc then path = "\\\\" .. path end return path end local function closeFormAndActivateCE(form) if form then form.close() synchronize(function() local app = getApplication() if app then app.bringToFront() end end) end end -- ★★★ Show currently loaded CT info in CE title bar ★★★ local function updateTitleBar(mode, filename) if not recentFiles.SHOW_CT_INFO_IN_TITLE then return end local mainForm = getMainForm() if not mainForm then return end local baseTitle = mainForm.Caption or "Cheat Engine" baseTitle = baseTitle:gsub("%s*|%s*recentFiles:[^:]*:[^|]*$", "") baseTitle = baseTitle:gsub("%s*|%s*RF:[^:]*:[^|]*$", "") if mode and filename and filename ~= "" then local modeMap = { rule = "Rule", fuzzy = "Fuzzy", latest = "Latest", manual = "Manual" } local displayMode = modeMap[mode] or mode or "Unknown" local nameWithoutExt = filename:gsub("%.[Cc][Tt]$", "", 1) mainForm.Caption = baseTitle .. " | RF:" .. displayMode .. ":" .. nameWithoutExt else mainForm.Caption = baseTitle end end -- ★★★ Get current process name (fallback from CE title bar) ★★★ local function getCurrentProcessName() local rawName = process if not rawName or rawName == "" then local pid = getOpenedProcessID() if pid ~= 0 then local procs = getProcesslist() if procs and procs[pid] then rawName = procs[pid] end end if not rawName or rawName == "" then local mainForm = getMainForm() if mainForm then local caption = mainForm.Caption if caption then rawName = caption:match("^Cheat Engine %- (.+)") end end end end if rawName and recentFiles.PROCESS_NORMALIZE then return recentFiles.PROCESS_NORMALIZE(rawName) end return rawName end -- ★★★ Get main window title (by enumerating windows) ★★★ local function getMainWindowTitle() local pid = getOpenedProcessID() if pid == 0 then return nil end local top = executeCodeLocalEx("user32.GetTopWindow", 0) if not top or top == 0 then return nil end local ms = createMemoryStream() if not ms then debugLog("[recentFiles] Warning: failed to create memory stream, cannot get window title") return nil end ms.Size = CONSTANTS.BUFFER_SIZE * 2 local buffer = ms.Memory if not buffer or buffer == 0 then debugLog("[recentFiles] Warning: memory stream buffer invalid") ms.destroy() return nil end local candidates = {} local success, err = pcall(function() local hwnd = top while hwnd and hwnd ~= 0 do local winPid = getWindowProcessID(hwnd) if winPid == pid then local len = executeCodeLocalEx("user32.GetWindowTextW", hwnd, buffer, CONSTANTS.BUFFER_SIZE) if len and len > 0 then local caption = readStringLocal(buffer, CONSTANTS.BUFFER_SIZE, true) if caption and caption ~= "" then local className = getWindowClassName(hwnd) if className and className ~= "IME" and className ~= "MSCTFIME UI" and caption ~= "Default IME" then local visibleRet = executeCodeLocalEx("user32.IsWindowVisible", hwnd) local visible = (visibleRet ~= nil and visibleRet ~= 0) table.insert(candidates, { caption = caption, visible = visible }) end end end end hwnd = executeCodeLocalEx("user32.GetWindow", hwnd, 2) if hwnd == nil then break end end end) ms.destroy() if not success then debugLog("[recentFiles] getMainWindowTitle error: " .. tostring(err)) return nil end if #candidates == 0 then return nil end local processName = getCurrentProcessName() local processLower = processName and processName:lower() or "" local best = nil local bestScore = -1 for _, c in ipairs(candidates) do local score = 0 if c.visible then score = score + 10 end if processLower ~= "" and c.caption:lower():find(processLower, 1, true) then score = score + 5 end score = score + #c.caption / 100 if score > bestScore then bestScore = score best = c.caption end end return best end local function getCurrentWindowTitle() local rawTitle = getMainWindowTitle() if not rawTitle then return "" end if recentFiles.ENABLE_TITLE_CACHE then if _cachedRawTitle == rawTitle then return _cachedCleanTitle or "" end end local cleanTitle = rawTitle if recentFiles.TITLE_CLEANUP then cleanTitle = recentFiles.TITLE_CLEANUP(rawTitle) end if recentFiles.ENABLE_TITLE_CACHE then _cachedRawTitle = rawTitle _cachedCleanTitle = cleanTitle end return cleanTitle end local function removeNumbersFromTitle(title) if not title then return "" end local stripped = title:gsub("(^|[^%w])%d+([^%w]|$)", "%1 %2") stripped = stripped:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") return stripped end local function getSignificantTitle() local raw = getMainWindowTitle() if not raw then return "" end local clean = recentFiles.TITLE_CLEANUP(raw) or "" if recentFiles.AUTO_REMATCH_IGNORE_NUMBERS then clean = removeNumbersFromTitle(clean) end return clean end -- ★★★ Safely load CT table (with confirmation dialog) ★★★ local function safeLoadTable(path, forceMerge, isManual, loadInfo, silent) if not path or not fileExists(path) then debugLog("[recentFiles] File does not exist: " .. tostring(path)) return false, "file_not_found" end local merge = forceMerge or false local showConfirm = false if silent then showConfirm = false else if isManual then if recentFiles.ENABLE_MANUAL_LOAD_CONFIRMATION then showConfirm = true end else if recentFiles.ENABLE_LOAD_CONFIRMATION then showConfirm = true end end end if showConfirm then local addrList = getAddressList() local addressListCount = 0 if addrList then addressListCount = addrList.Count end if addressListCount > 0 then local targetFileName = extractFileName(path) local infoPrefix = "" if loadInfo and loadInfo ~= "" then infoPrefix = "[" .. loadInfo .. "]\n" end local msg = string.format( "%sAbout to load CT table:\n%s\n\nChoose action:\n[Yes] Overwrite current table\n[No] Merge into current table\n[Cancel] Cancel load", infoPrefix, targetFileName ) local choice = messageDialog(msg, mtConfirmation, mbYes, mbNo, mbCancel) if choice == mrYes then merge = false elseif choice == mrNo then merge = true else return false, "cancelled" end end end local ok, result = pcall(loadTable, path, merge) if not ok then print("[recentFiles] Load CT table exception: " .. tostring(result)) return false, "load_error" end if not result then debugLog("[recentFiles] loadTable returned false, load failed") return false, "load_failed" end return true, "ok" end local function recordLoadedFile(filename) if not filename or filename == '' or not fileExists(filename) then return end if recentFiles.IgnoreCETRAINER and filename:lower():match("%.cetrainer$") then return end recentFiles.CurrentFilePath = filename local process = getCurrentProcessName() or "unknown" recentFiles.AddEntry(process, filename, "") end local function loadTableAndRecord(path, merge, isManual, loadInfo, loadMethod, silent) local ok, reason = safeLoadTable(path, merge, isManual, loadInfo, silent) if ok then recentFiles.LastLoadMethod = loadMethod or "manual" recordLoadedFile(path) local filename = extractFileName(path) updateTitleBar(loadMethod or "manual", filename) return true, "ok" else if reason ~= "cancelled" then debugLog("[recentFiles] Load failed: " .. reason) end return false, reason end end -- ★★★ Camel case splitting helper (for splitting PascalCase / camelCase) ★★★ local function splitCamelCase(str) if not str or str == "" then return str end local result = str:gsub("(%l)(%u)", "%1 %2") result = result:gsub("(%u)(%u%l)", "%1 %2") return result end -- ★★★ Core word splitting function (manual UTF‑8 parsing + forced camel case splitting + normalization) ★★★ local function extractWords(str, filterStopWords) if not str or str == "" then return {} end str = toHalfwidth(str) if not recentFiles.ENABLE_STOP_WORDS then filterStopWords = false end debugLog("[extractWords] Input: " .. str) local rawWords = {} local current = {} local len = #str local i = 1 while i <= len do local b1 = string.byte(str, i) if not b1 then break end local code, consumed if b1 < 0x80 then code = b1 consumed = 1 elseif b1 < 0xE0 then local b2 = string.byte(str, i+1) if not b2 then break end code = ((b1 & 0x1F) << 6) | (b2 & 0x3F) consumed = 2 elseif b1 < 0xF0 then local b2 = string.byte(str, i+1) local b3 = string.byte(str, i+2) if not b2 or not b3 then break end code = ((b1 & 0x0F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F) consumed = 3 else local b2 = string.byte(str, i+1) local b3 = string.byte(str, i+2) local b4 = string.byte(str, i+3) if not b2 or not b3 or not b4 then break end code = ((b1 & 0x07) << 18) | ((b2 & 0x3F) << 12) | ((b3 & 0x3F) << 6) | (b4 & 0x3F) consumed = 4 end local isWord = false if (code >= 48 and code <= 57) or (code >= 65 and code <= 90) or (code >= 97 and code <= 122) or (code >= 0x3040 and code <= 0x30FF) or (code >= 0xFF00 and code <= 0xFFEF) or (code >= 0x4E00 and code <= 0x9FFF) or (code >= 0x3400 and code <= 0x4DBF) or (code >= 0xF900 and code <= 0xFAFF) or (code >= 0x20000 and code <= 0x2A6DF) then isWord = true end if isWord then table.insert(current, string.sub(str, i, i + consumed - 1)) else if #current > 0 then table.insert(rawWords, table.concat(current)) current = {} end end i = i + consumed end if #current > 0 then table.insert(rawWords, table.concat(current)) end local words = {} for _, w in ipairs(rawWords) do local parts = splitCamelCase(w) if parts ~= w and parts ~= "" then for part in parts:gmatch("%S+") do if part ~= "" then table.insert(words, part:lower()) end end else table.insert(words, w:lower()) end end if filterStopWords and recentFiles.ENABLE_STOP_WORDS then local stopSet = getStopWordSet() local filtered = {} if recentFiles.ENABLE_CHINESE_STOP_WORDS then for _, w in ipairs(words) do local byte1 = string.byte(w) local isChineseSingle = (byte1 and byte1 >= 0xE0 and #w >= 3) if not isChineseSingle and not stopSet[w] then table.insert(filtered, w) end end else for _, w in ipairs(words) do if not stopSet[w] then table.insert(filtered, w) end end end words = filtered end debugLog("[extractWords] Extracted words: " .. table.concat(words, ", ")) return words end -- ★★★ Version extraction (supports v1.0 or 1.0 format) ★★★ local function extractVersion(filename) local name = filename:gsub("%.[Cc][Tt]$", "", 1) local versionStr = name:match("[vV]%s*(%d+%.%d+[%.%d]*)") if not versionStr then versionStr = name:match("([%d]+%.[%d]+[%.%d]*)") end if not versionStr then return nil end local parts = {} for num in versionStr:gmatch("%d+") do table.insert(parts, tonumber(num)) end return parts end local function compareVersions(a, b) if not a and not b then return 0 end if not a then return -1 end if not b then return 1 end local maxLen = math.max(#a, #b) for i = 1, maxLen do local va = a[i] or 0 local vb = b[i] or 0 if va ~= vb then return va > vb and 1 or -1 end end return 0 end local function selectBestEntry(candidates) if not candidates or #candidates == 0 then return nil end if #candidates == 1 then return candidates[1] end if recentFiles.ENABLE_VERSION_PRIORITY then table.sort(candidates, function(a, b) local va = extractVersion(extractFileName(a.path)) local vb = extractVersion(extractFileName(b.path)) local cmp = compareVersions(va, vb) if cmp ~= 0 then return cmp > 0 end return (a.index or 999999) < (b.index or 999999) end) else table.sort(candidates, function(a, b) return (a.index or 999999) < (b.index or 999999) end) end return candidates[1] end -- ★★★ LCS longest common subsequence (rolling array) ★★★ local function lcsLength(seqA, seqB) local m, n = #seqA, #seqB if m == 0 or n == 0 then return 0 end if m > n then seqA, seqB = seqB, seqA m, n = n, m end local prev = {} local curr = {} prev[0] = 0 for i = 1, n do prev[i] = 0 end for i = 1, m do curr[0] = 0 local a = seqA[i] for j = 1, n do if a == seqB[j] then curr[j] = prev[j-1] + 1 else curr[j] = math.max(prev[j], curr[j-1]) end end prev, curr = curr, prev end return prev[n] end -- ★★★ Fuzzy matching (returns entries sorted by similarity, version, then index) ★★★ local function getFuzzyMatch(entries, title) local useStopWords = recentFiles.ENABLE_STOP_WORDS local titleWords = extractWords(title, useStopWords) if #titleWords == 0 then return {} end debugLog("[DEBUG] Raw title: " .. title) debugLog("[DEBUG] Title length (chars): " .. #title) debugLog("[DEBUG] Title bytes: " .. table.concat({string.byte(title, 1, #title)}, ", ")) local filteredTitle = {} for _, w in ipairs(titleWords) do if not w:match("^%d+$") then table.insert(filteredTitle, w) end end if #filteredTitle == 0 then return {} end titleWords = filteredTitle local titleWordSet = {} for _, w in ipairs(titleWords) do titleWordSet[w] = true end local candidates = {} for _, entry in ipairs(entries) do local filename = extractFileName(entry.path) local name = filename:gsub("%.[Cc][Tt]$", "", 1) local nameWords = extractWords(name, useStopWords) if #nameWords > 0 then local filteredName = {} for _, w in ipairs(nameWords) do if not w:match("^%d+$") then table.insert(filteredName, w) end end if #filteredName > 0 then nameWords = filteredName local commonCount = 0 for _, w in ipairs(nameWords) do if titleWordSet[w] then commonCount = commonCount + 1 end end if commonCount >= (recentFiles.FUZZY_MIN_WORDS or 1) then local lcs = lcsLength(titleWords, nameWords) local score local titleLen = #titleWords if commonCount == #nameWords and commonCount > 0 then score = (2 * commonCount) / (titleLen + #nameWords) else local useNewScore = (recentFiles.SCORE_MODE == 1) and (titleLen >= (recentFiles.MIN_TITLE_WORDS_FOR_NEW_SCORE or 3)) if useNewScore then score = lcs / titleLen else local union = titleLen + #nameWords - lcs score = lcs / union end end debugLog("[DEBUG] Filename: " .. name) debugLog(" titleWords: " .. table.concat(titleWords, ", ")) debugLog(" nameWords: " .. table.concat(nameWords, ", ")) debugLog(" commonCount: " .. commonCount .. " lcs: " .. lcs .. " titleLen: " .. titleLen) debugLog(" Score: " .. string.format("%.4f", score)) if score >= recentFiles.FUZZY_THRESHOLD then table.insert(candidates, {entry = entry, score = score}) end end end end end debugLog("[DEBUG] Candidate list (" .. #candidates .. " items):") for _, item in ipairs(candidates) do local fname = extractFileName(item.entry.path) debugLog(" " .. fname .. " score: " .. string.format("%.4f", item.score)) end table.sort(candidates, function(a, b) if a.score ~= b.score then return a.score > b.score end if recentFiles.ENABLE_VERSION_PRIORITY then local va = extractVersion(extractFileName(a.entry.path)) local vb = extractVersion(extractFileName(b.entry.path)) local cmp = compareVersions(va, vb) if cmp ~= 0 then return cmp > 0 end end return (a.entry.index or 999999) < (b.entry.index or 999999) end) local result = {} for _, item in ipairs(candidates) do table.insert(result, item.entry) end return result end -- ============================================================ -- Data storage and UI -- ============================================================ recentFiles.Entries = {} recentFiles.ClearMenuItem = nil recentFiles.SwitchMenuItem = nil recentFiles.CurrentFilePath = nil recentFiles.RefreshMenuItem = nil local function buildProcessIndex() _processIndex = {} for idx, entry in ipairs(recentFiles.Entries) do entry.index = idx local p = entry.process if not _processIndex[p] then _processIndex[p] = {} end table.insert(_processIndex[p], entry) end end function recentFiles.Load() recentFiles.Entries = {} local invalidCount = 0 local file = nil local seen = {} local ok, err = pcall(function() file = io.open(getCheatEngineDir() .. recentFiles.FileName, "r") if not file then return end for line in file:lines() do local process, path, rule = line:match("^(.-)\t(.+)\t(.*)$") if process and path then rule = rule or "" local normPath = normalizePath(path) local key = process .. "\t" .. normPath if not seen[key] then seen[key] = true if fileExists(normPath) then table.insert(recentFiles.Entries, {process = process, path = normPath, rule = rule}) else invalidCount = invalidCount + 1 end else invalidCount = invalidCount + 1 end else process, path = line:match("^(.-)\t(.+)$") if process and path then local normPath = normalizePath(path) local key = process .. "\t" .. normPath if not seen[key] then seen[key] = true if fileExists(normPath) then table.insert(recentFiles.Entries, {process = process, path = normPath, rule = ""}) else invalidCount = invalidCount + 1 end else invalidCount = invalidCount + 1 end else invalidCount = invalidCount + 1 end end end end) if file then file:close() end if not ok then print("[recentFiles] Failed to load recent file list: " .. tostring(err)) end if invalidCount > 0 then debugLog("[recentFiles] Removed " .. invalidCount .. " invalid or duplicate entries") end local needSave = invalidCount > 0 while #recentFiles.Entries > recentFiles.NumberOfEntries do table.remove(recentFiles.Entries) needSave = true end if needSave then recentFiles.Save() end buildProcessIndex() end function recentFiles.Save() local ok, err = pcall(function() local file = io.open(getCheatEngineDir() .. recentFiles.FileName, "w") if not file then error("Unable to open file for writing: " .. recentFiles.FileName) end for _, entry in ipairs(recentFiles.Entries) do file:write(entry.process .. "\t" .. entry.path .. "\t" .. (entry.rule or "") .. "\n") end file:close() end) if not ok then print("[recentFiles] Failed to save recent file list: " .. tostring(err)) end end function recentFiles.AddEntry(process, path, rule) rule = rule or "" if not process or not path then return end local normPath = normalizePath(path) for i = #recentFiles.Entries, 1, -1 do local e = recentFiles.Entries[i] if e.process == process and e.path == normPath then if rule == "" and e.rule ~= "" then rule = e.rule end table.remove(recentFiles.Entries, i) break end end table.insert(recentFiles.Entries, 1, {process = process, path = normPath, rule = rule}) while #recentFiles.Entries > recentFiles.NumberOfEntries do table.remove(recentFiles.Entries) end recentFiles.Save() buildProcessIndex() end function recentFiles.UpdateRule(process, path, rule) rule = rule or "" if not process or not path or rule == "" then return end local normPath = normalizePath(path) for i, entry in ipairs(recentFiles.Entries) do if entry.process == process and entry.path == normPath then if entry.rule == "" then entry.rule = rule recentFiles.Save() buildProcessIndex() end return end end recentFiles.AddEntry(process, normPath, rule) end function recentFiles.ClearAll() recentFiles.Entries = {} recentFiles.Save() _processIndex = {} recentFiles.CurrentFilePath = nil updateTitleBar(nil, nil) end function recentFiles.GetEntriesForProcess(process) local result = {} local processLower = process:lower() local exactList = _processIndex[process] if exactList then for _, entry in ipairs(exactList) do if fileExists(entry.path) then table.insert(result, entry) end end end if #result == 0 and _processIndex then for procName, entryList in pairs(_processIndex) do local entryLower = procName:lower() if entryLower:find(processLower, 1, true) or processLower:find(entryLower, 1, true) then for _, entry in ipairs(entryList) do if fileExists(entry.path) then table.insert(result, entry) end end end end end return result end local function matchByRule(entries, currentTitle) if currentTitle == "" then return {} end local lowerTitle = currentTitle:lower() local matched = {} for _, entry in ipairs(entries) do if entry.rule and entry.rule ~= "" then if lowerTitle:find(entry.rule:lower(), 1, true) then table.insert(matched, entry) end end end table.sort(matched, function(a, b) return #a.rule > #b.rule end) return matched end -- ============================================================ -- UI related (Switch CT window) – split helper functions -- ============================================================ local function updateButtonStates(listBox, items, btnSetRule, btnDelete) if not btnSetRule or not btnDelete then return end local selectedCount = 0 for i = 0, items.Count - 1 do if listBox.Selected[i] then selectedCount = selectedCount + 1 end end btnSetRule.Enabled = (selectedCount == 1) btnDelete.Enabled = (selectedCount > 0) end local function createSelectionForm(processName) local form = createForm(true) form.Caption = "Recent CT Tables (Process: " .. processName .. ")" form.setSize(800, 510) form.centerScreen() form.OnClose = function(sender) sender.destroy() end local searchEdit = createEdit(form) searchEdit.setSize(780, 26) searchEdit.setPosition(10, 10) local PLACEHOLDER = "Search (filename/rule)..." searchEdit.Text = PLACEHOLDER searchEdit.Font.Color = 0x808080 local function onSearchEnter() if searchEdit.Text == PLACEHOLDER then searchEdit.Text = "" searchEdit.Font.Color = 0x000000 end end local function onSearchExit() if searchEdit.Text == "" then searchEdit.Text = PLACEHOLDER searchEdit.Font.Color = 0x808080 end end searchEdit.OnEnter = onSearchEnter searchEdit.OnExit = onSearchExit local listBox = createListBox(form) listBox.setSize(780, 370) listBox.setPosition(10, 50) listBox.MultiSelect = true local items = listBox.Items return form, searchEdit, listBox, items, PLACEHOLDER end local function refreshSelectionList(items, paths, rules, keyword, displayToOriginal) keyword = keyword or "" keyword = keyword:lower() items.clear() if displayToOriginal then for k in pairs(displayToOriginal) do displayToOriginal[k] = nil end end for i, p in ipairs(paths) do local display = extractFileName(p) if rules[i] ~= "" then display = display .. " [Rule: " .. rules[i] .. "]" end if keyword == "" or display:lower():find(keyword, 1, true) then local idx = items.add(display) if displayToOriginal then displayToOriginal[idx] = i end end end end local function createButtonWithClick(form, caption, x, y, width, height, onClick) local btn = createButton(form) btn.Caption = caption btn.setSize(width or 90, height or 36) btn.setPosition(x, y) btn.OnClick = onClick return btn end local function handleLoadClick(form, listBox, items, paths, displayToOriginal) local selectedIndices = {} for i = 0, items.Count - 1 do if listBox.Selected[i] then table.insert(selectedIndices, i) end end if #selectedIndices == 0 then showMessage("Please select at least one CT table") return end local loadedCount = 0 local shouldClose = true for _, idx in ipairs(selectedIndices) do local originalIdx = displayToOriginal[idx] if originalIdx then local selectedPath = paths[originalIdx] if fileExists(selectedPath) then local ok, reason = loadTableAndRecord(selectedPath, false, true, nil, "manual", false) if ok then loadedCount = loadedCount + 1 elseif reason == "cancelled" then if loadedCount > 0 then showMessage(string.format("Successfully loaded %d file(s), subsequent loading cancelled.", loadedCount)) end shouldClose = false break else showMessage(string.format("Failed to load file %s: %s", extractFileName(selectedPath), reason)) shouldClose = false break end else showMessage(string.format("File does not exist: %s", selectedPath)) shouldClose = false break end end end if shouldClose then closeFormAndActivateCE(form) end end local function handleSetRuleClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, form) local idx = listBox.ItemIndex if idx < 0 or idx >= items.Count then showMessage("Please select a CT table record to set a rule for.") return end local originalIdx = displayToOriginal[idx] if not originalIdx then showMessage("Selected record is invalid") return end local currentPath = paths[originalIdx] local currentRule = rules[originalIdx] or "" local defaultRule = getCurrentWindowTitle() if defaultRule == "" then defaultRule = "No window title detected" end if currentRule ~= "" then defaultRule = currentRule end local newRule = inputQuery("Set Rule", "Enter matching rule (window title contains this text, leave empty for process‑only match):", defaultRule) if newRule == nil then return end newRule = newRule:gsub("^%s+", ""):gsub("%s+$", "") rules[originalIdx] = newRule for _, e in ipairs(recentFiles.Entries) do if e.path == currentPath then e.rule = newRule break end end recentFiles.Save() local keyword = searchEdit and searchEdit.Text or "" if keyword == PLACEHOLDER then keyword = "" end refreshSelectionList(items, paths, rules, keyword, displayToOriginal) updateButtonStates(listBox, items, btnSetRule, btnDelete) end local function handleDeleteClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER) local selectedIndices = {} for i = 0, items.Count - 1 do if listBox.Selected[i] then table.insert(selectedIndices, i) end end if #selectedIndices == 0 then showMessage("Please select CT table records to delete (multi‑select allowed).") return end if messageDialog("Are you sure you want to delete the selected " .. #selectedIndices .. " record(s)?\n(CT files on disk will not be deleted)", mtConfirmation, mbYes, mbNo) ~= mrYes then return end local pathsToRemove = {} for _, idx in ipairs(selectedIndices) do local originalIdx = displayToOriginal[idx] if originalIdx then table.insert(pathsToRemove, paths[originalIdx]) end end for i = #recentFiles.Entries, 1, -1 do for _, p in ipairs(pathsToRemove) do if recentFiles.Entries[i].path == p then table.remove(recentFiles.Entries, i) break end end end recentFiles.Save() for i = #paths, 1, -1 do for _, p in ipairs(pathsToRemove) do if paths[i] == p then table.remove(paths, i) table.remove(rules, i) break end end end local keyword = searchEdit and searchEdit.Text or "" if keyword == PLACEHOLDER then keyword = "" end refreshSelectionList(items, paths, rules, keyword, displayToOriginal) updateButtonStates(listBox, items, btnSetRule, btnDelete) end -- ★★★ UI main function (split) ★★★ local function showSelectionDialogAlways() _cachedRawTitle = nil _cachedCleanTitle = nil local processName = getCurrentProcessName() if not processName then return end local entries = recentFiles.GetEntriesForProcess(processName) if #entries == 0 then return end local paths = {} local rules = {} for _, e in ipairs(entries) do table.insert(paths, e.path) table.insert(rules, e.rule or "") end local form, searchEdit, listBox, items, PLACEHOLDER = createSelectionForm(processName) local displayToOriginal = {} local formClosed = false local function refreshList(keyword) keyword = keyword or "" keyword = keyword:lower() refreshSelectionList(items, paths, rules, keyword, displayToOriginal) end refreshList("") local searchTimer = nil searchEdit.OnChange = function() local text = searchEdit.Text if text == "" or text == PLACEHOLDER then refreshList("") if searchTimer then searchTimer.Enabled = false searchTimer.destroy() searchTimer = nil end return end if searchTimer then searchTimer.Enabled = false searchTimer.destroy() searchTimer = nil end searchTimer = createTimer(nil, false) searchTimer.Interval = CONSTANTS.SEARCH_DEBOUNCE_MS searchTimer.OnTimer = function() if formClosed then if searchTimer then searchTimer.Enabled = false searchTimer.destroy() searchTimer = nil end return end local keyword = searchEdit.Text if keyword == PLACEHOLDER then keyword = "" end refreshSelectionList(items, paths, rules, keyword, displayToOriginal) if searchTimer then searchTimer.Enabled = false searchTimer.destroy() searchTimer = nil end end searchTimer.Enabled = true end local oldOnClose = form.OnClose form.OnClose = function(sender) formClosed = true if searchTimer then searchTimer.Enabled = false searchTimer.destroy() searchTimer = nil end if oldOnClose then oldOnClose(sender) else sender.destroy() end end local btnSetRule = createButtonWithClick(form, "Set Auto‑Match Rule", 10, 428, 310, 36, nil) local btnDelete = createButtonWithClick(form, "Delete Selected CT Records", 414, 428, 376, 36, nil) btnSetRule.Enabled = false btnDelete.Enabled = false local btnLoad = createButtonWithClick(form, "Load", 200, 470, 90, 36, function() handleLoadClick(form, listBox, items, paths, displayToOriginal) end) local btnCancel = createButtonWithClick(form, "Cancel", 510, 470, 90, 36, function() closeFormAndActivateCE(form) end) -- Set Rule button event (needs references to listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER) btnSetRule.OnClick = function() handleSetRuleClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, form) end btnDelete.OnClick = function() handleDeleteClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER) end local function updateButtons() updateButtonStates(listBox, items, btnSetRule, btnDelete) end listBox.OnSelectionChange = function() updateButtons() end listBox.OnDblClick = function() local idx = listBox.ItemIndex if idx >= 0 and idx < items.Count then local originalIdx = displayToOriginal[idx] if originalIdx then local selectedPath = paths[originalIdx] if fileExists(selectedPath) then local ok, reason = loadTableAndRecord(selectedPath, false, true, nil, "manual", false) if ok then closeFormAndActivateCE(form) else if reason ~= "cancelled" then showMessage("Failed to load file: " .. tostring(reason)) end end else showMessage("File does not exist: " .. selectedPath) end end end end updateButtons() form.showModal() end -- ============================================================ -- ★★★ Process alive detection (reliable method: query system process list) ★★★ -- ============================================================ local function isProcessAlive() local pid = getOpenedProcessID() if pid == 0 then return false end local procs = getProcesslist() if procs and procs[pid] then return true end return false end -- ============================================================ -- Auto re‑match (timer checks title changes) -- ============================================================ local function performAutoRematch() if not recentFiles.ENABLE_AUTO_REMATCH then return end if not isProcessAlive() then debugLog("[recentFiles] Attached process has exited, stopping re‑match") stopRematchTimer() return end local processName = getCurrentProcessName() if not processName then return end local entries = recentFiles.GetEntriesForProcess(processName) if #entries == 0 then return end local title = getCurrentWindowTitle() if title == "" then return end debugLog("[recentFiles] Current title: " .. title) local matched = {} local matchType = "rule" matched = matchByRule(entries, title) if #matched == 0 then debugLog("[recentFiles] Rule match found nothing, trying fuzzy match") matched = getFuzzyMatch(entries, title) matchType = "fuzzy" end if #matched > 0 then debugLog("[recentFiles] Matched " .. #matched .. " candidate(s), selecting best") local best = matched[1] if best then if recentFiles.CurrentFilePath and best.path == recentFiles.CurrentFilePath then local addrList = getAddressList() local count = (addrList and addrList.Count) or 0 if count > 0 then debugLog("[recentFiles] Best candidate is the current file and address list is not empty, skipping reload") return else debugLog("[recentFiles] Best candidate is the current file but address list is empty, forcing reload") end end local ok, reason = loadTableAndRecord(best.path, false, false, "Auto re‑match (version priority)", matchType, true) if ok then debugLog("[recentFiles] Auto re‑match succeeded: " .. extractFileName(best.path)) else debugLog("[recentFiles] Auto re‑match failed: " .. tostring(reason)) end end return end if recentFiles.AUTO_REMATCH_LOAD_LATEST_AS_FALLBACK then debugLog("[recentFiles] No rule/fuzzy match, using fallback to load latest record") local latest = entries[1] if latest and fileExists(latest.path) then if recentFiles.CurrentFilePath and latest.path == recentFiles.CurrentFilePath then local addrList = getAddressList() local count = (addrList and addrList.Count) or 0 if count > 0 then debugLog("[recentFiles] Latest record is the current file (" .. extractFileName(latest.path) .. "), address list not empty, no reload needed") return else debugLog("[recentFiles] Latest record is the current file but address list is empty, forcing reload") end end local ok, reason = loadTableAndRecord(latest.path, false, false, "Auto re‑match (latest record fallback)", "latest", true) if ok then debugLog("[recentFiles] Auto re‑match (latest record fallback) succeeded: " .. extractFileName(latest.path)) else debugLog("[recentFiles] Auto re‑match (latest record fallback) failed: " .. tostring(reason)) end else debugLog("[recentFiles] Latest record is invalid or file does not exist") end else debugLog("[recentFiles] No match and fallback not enabled, not loading") end end local function stopRematchTimer() if _rematchTimer then local timer = _rematchTimer timer.Enabled = false synchronize(function() timer.destroy() end) _rematchTimer = nil end _lastSignificantTitle = nil _lastPid = 0 end local function startRematchTimer() if not recentFiles.ENABLE_AUTO_REMATCH then return end stopRematchTimer() if not isProcessAlive() then return end _lastPid = getOpenedProcessID() _lastSignificantTitle = getSignificantTitle() local timer = createTimer(nil, false) timer.Interval = recentFiles.AUTO_REMATCH_INTERVAL or CONSTANTS.DEFAULT_INTERVAL_MS timer.OnTimer = function() if not isProcessAlive() then debugLog("[recentFiles] Attached process has exited, stopping re‑match timer") stopRematchTimer() return end local currentPid = getOpenedProcessID() if currentPid ~= _lastPid then _lastPid = currentPid _lastSignificantTitle = nil end local currentSig = getSignificantTitle() if currentSig == "" then return end if _lastSignificantTitle == nil then _lastSignificantTitle = currentSig elseif currentSig ~= _lastSignificantTitle then _lastSignificantTitle = currentSig performAutoRematch() end end timer.Enabled = true _rematchTimer = timer end -- ============================================================ -- Auto‑load on process switch and manual refresh -- ============================================================ local function showRecentSelectionDialog(manual) _cachedRawTitle = nil _cachedCleanTitle = nil if not manual and not recentFiles.ENABLE_AUTO_LOAD_ON_ATTACH then return end local processName = getCurrentProcessName() if not processName then return end local entries = recentFiles.GetEntriesForProcess(processName) if #entries == 0 then recentFiles.LastLoadMethod = "none" updateTitleBar(nil, nil) return end if recentFiles.AUTO_LOAD_LATEST then local title = getCurrentWindowTitle() if title ~= "" then local matched = matchByRule(entries, title) if #matched > 0 then local best = matched[1] if best then loadTableAndRecord(best.path, false, false, "Rule match (version priority)", "rule", false) return end end local fuzzyMatched = getFuzzyMatch(entries, title) if #fuzzyMatched > 0 then local best = fuzzyMatched[1] if best then loadTableAndRecord(best.path, false, false, "Fuzzy match (word‑order aware + version priority)", "fuzzy", false) return end end end local latest = entries[1] if latest and fileExists(latest.path) then loadTableAndRecord(latest.path, false, false, "No rule/fuzzy match, auto‑load latest record", "latest", false) end return end showSelectionDialogAlways() end -- ============================================================ -- Menu creation (add Switch CT etc. to CE main menu) -- ============================================================ function recentFiles.ShowMenu() local mainForm = getMainForm() if not mainForm then return end local mainMenu = mainForm.Menu if not mainMenu then return end if not recentFiles.SwitchMenuItem then local switchItem = createMenuItem(mainMenu) switchItem.Caption = 'Switch CT' switchItem.OnClick = function() showSelectionDialogAlways() end local pos = mainMenu.Items.Count mainMenu.Items.insert(pos, switchItem) recentFiles.SwitchMenuItem = switchItem end if not recentFiles.RefreshMenuItem then local refreshItem = createMenuItem(mainMenu) refreshItem.Caption = 'Refresh CT (re‑match)' refreshItem.OnClick = function() showRecentSelectionDialog(true) end local pos = mainMenu.Items.Count mainMenu.Items.insert(pos, refreshItem) recentFiles.RefreshMenuItem = refreshItem end if not recentFiles.ClearMenuItem then local FileMenuItem = mainMenu.Items[0] if not FileMenuItem then return end local position = FileMenuItem.Count local sep = createMenuItem(FileMenuItem) sep.Caption = '-' FileMenuItem.insert(position, sep) position = position + 1 local clearItem = createMenuItem(FileMenuItem) clearItem.Caption = 'Clear Recent File List' clearItem.OnClick = function() if messageDialog("Are you sure you want to clear all recent file records?", mtConfirmation, mbYes, mbNo) ~= mrYes then return end recentFiles.ClearAll() end FileMenuItem.insert(position, clearItem) recentFiles.ClearMenuItem = clearItem end end -- ============================================================ -- Open / Save function overrides (custom save behavior) -- ============================================================ local function openWithButtonOrMenu(sender) local dialog = getMainForm().OpenDialog1 if not dialog then print("[recentFiles] OpenDialog1 not found, cannot open file") return end if dialog:Execute() then local filename = dialog.FileName if filename ~= "" and fileExists(filename) then loadTableAndRecord(filename, false, true, nil, "manual", false) end end end local function getSuggestedFileName() local title = getCurrentWindowTitle() if title == "" then local proc = getCurrentProcessName() if proc and proc ~= "" then return proc:gsub("[\\/:*?\"<>|]", "_") .. ".CT" else return "CheatTable.CT" end end local base = title:match("^.*[\\/]([^\\/]+)$") if base then title = base end local fileName = title:gsub("[\\/:*?\"<>|]", "_") .. ".CT" return fileName end local function isCurrentFileMatchingTitle() local current = recentFiles.CurrentFilePath if not current or not fileExists(current) then return false end local title = getCurrentWindowTitle() if title == "" then return false end local name = extractFileName(current):gsub("%.[Cc][Tt]$", "", 1) local useStopWords = recentFiles.ENABLE_STOP_WORDS local nameWords = extractWords(name, useStopWords) if #nameWords == 0 then return false end local titleWords = extractWords(title, useStopWords) local filteredTitle = {} for _, w in ipairs(titleWords) do if not w:match("^%d+$") then table.insert(filteredTitle, w) end end if #filteredTitle == 0 then return false end local matchCount = 0 for _, nw in ipairs(nameWords) do for _, tw in ipairs(filteredTitle) do if nw == tw then matchCount = matchCount + 1 break end end end local minWords = recentFiles.SAVE_MATCH_MIN_WORDS or 2 local threshold = recentFiles.SAVE_MATCH_THRESHOLD or 0.6 if matchCount < minWords then return false end local score = matchCount / math.max(#filteredTitle, #nameWords) return score >= threshold end local function shouldUseWindowTitle() if not recentFiles.ENABLE_CUSTOM_SAVE then return false end if not (recentFiles.CurrentFilePath and fileExists(recentFiles.CurrentFilePath)) then return true end return not isCurrentFileMatchingTitle() end local function showSaveDialog() local defaultPath = nil local defaultDir = nil local defaultName = nil if shouldUseWindowTitle() then defaultName = getSuggestedFileName() if not defaultName or defaultName == "" then defaultName = "CheatTable.CT" end else defaultPath = recentFiles.CurrentFilePath defaultDir = extractFilePath(defaultPath) defaultName = extractFileName(defaultPath) end local saveDialog = createSaveDialog() saveDialog.Title = "Save Cheat Table" if defaultDir then saveDialog.InitialDir = defaultDir end if defaultName then saveDialog.FileName = defaultName end saveDialog.Filter = "Cheat Table (*.CT)|*.CT|All Files (*.*)|*.*" saveDialog.DefaultExt = "CT" local filePath = nil if saveDialog.Execute() then filePath = saveDialog.FileName end saveDialog.destroy() return (filePath and filePath ~= "") and filePath or nil end local function autoAddRuleIfNeeded(filePath) if not recentFiles.AUTO_ADD_RULE_ON_SAVE then return end _cachedRawTitle = nil _cachedCleanTitle = nil local proc = getCurrentProcessName() local title = getCurrentWindowTitle() if proc and title ~= "" and #title >= 3 then local procLower = proc:lower() local titleLower = title:lower() if titleLower ~= procLower then if not isCurrentFileMatchingTitle() then recentFiles.UpdateRule(proc, filePath, title) end end end end local function customSave() _cachedRawTitle = nil _cachedCleanTitle = nil local filePath = showSaveDialog() if not filePath then return end if fileExists(filePath) then local answer = messageDialog(string.format("File %s already exists.\nReplace it?", filePath), mtConfirmation, mbYes, mbNo) if answer ~= mrYes then return end end local success = saveTable(filePath) if success then recordLoadedFile(filePath) autoAddRuleIfNeeded(filePath) else messageDialog(string.format("Save failed: %s\nPlease check file path or permissions.", filePath), mtError, mbOk) end end local function replaceSaveControls() local mainForm = getMainForm() if not mainForm then return end local saveButton = mainForm.SaveButton if saveButton then saveButton.OnClick = customSave saveButton.Action = nil end local menuItems = {} if mainForm.Save1 then table.insert(menuItems, mainForm.Save1) end if mainForm.miSave then table.insert(menuItems, mainForm.miSave) end if mainForm.miSaveFile then table.insert(menuItems, mainForm.miSaveFile) end if mainForm.SaveAs1 then table.insert(menuItems, mainForm.SaveAs1) end for _, item in ipairs(menuItems) do if item then item.OnClick = customSave item.Action = nil end end local actSave = mainForm.actSave if actSave then actSave.OnExecute = customSave end end local function onFormDropFiles(sender, fileNames) if fileNames and #fileNames > 0 then local firstFile = fileNames[1] if firstFile and fileExists(firstFile) then loadTableAndRecord(firstFile, false, true, nil, "manual", false) end end end -- ============================================================ -- Installation (register events and menus) -- ============================================================ if not recentFiles._installed then recentFiles._installed = true local oldOnProcessOpened = MainForm.OnProcessOpened MainForm.OnProcessOpened = function(pid, handle, caption) _cachedRawTitle = nil _cachedCleanTitle = nil stopRematchTimer() if pid ~= 0 and recentFiles.ENABLE_AUTO_REMATCH then if isProcessAlive() then startRematchTimer() else debugLog("[recentFiles] Newly attached process seems invalid, timer not started") end end if oldOnProcessOpened then oldOnProcessOpened(pid, handle, caption) end if recentFiles.ENABLE_AUTO_LOAD_DELAY then if _autoLoadTimer then _autoLoadTimer.Enabled = false _autoLoadTimer.destroy() _autoLoadTimer = nil end local timer = createTimer(nil, false) timer.Interval = recentFiles.AUTO_LOAD_DELAY or CONSTANTS.DEFAULT_DELAY_MS timer.OnTimer = function() timer.Enabled = false timer.destroy() _autoLoadTimer = nil showRecentSelectionDialog(false) end timer.Enabled = true _autoLoadTimer = timer else showRecentSelectionDialog(false) end end local mainForm = getMainForm() if mainForm then if mainForm.LoadButton then mainForm.LoadButton.OnClick = openWithButtonOrMenu mainForm.LoadButton.Action = nil end if mainForm.Load1 then mainForm.Load1.OnClick = openWithButtonOrMenu mainForm.Load1.Action = nil end replaceSaveControls() local oldOnDropFiles = mainForm.OnDropFiles mainForm.AllowDropFiles = true mainForm.OnDropFiles = function(sender, fileNames) if oldOnDropFiles then oldOnDropFiles(sender, fileNames) end onFormDropFiles(sender, fileNames) end end recentFiles.Load() recentFiles.ShowMenu() if getOpenedProcessID() ~= 0 and recentFiles.ENABLE_AUTO_REMATCH then if isProcessAlive() then startRematchTimer() else debugLog("[recentFiles] Current process invalid, timer not started") end end else debugLog("[recentFiles] Script already installed, skipping duplicate mount.") end