-- ============================================================ -- recentFiles.lua -- Function: Automatically load matching CT table based on process window title (rule / fuzzy matching) -- Version: 2.5.8 -- Notes: -- This version does not support hot-reload (restart CE to reload). -- After modifying the script, fully close CE and restart it. -- -- [This file = v2.5.7 + Save Dialog inherits directory + Migrate Old Rules] -- -- v2.5.8 patches: -- Save Dialog: when taking the "create new from window title" branch, also inherit -- the current CT's directory. -- New menu item: Migrate Old Rules (one-time rewrite of old rules using current TITLE_CLEANUP). -- -- Actual patches effective in v2.5.7: -- H4 : safeLoadTable records loadTable return value type (DEBUG output only). -- M1 : extractVersion supports v2/v10 without decimal point (excludes 4-digit years). -- UX : Switch CT menu gives user feedback when there are no records. -- Expose : End of script sets _G.recentFiles = recentFiles, for manually calling -- debug code in the CE Lua engine. -- PROCESS_NORMALIZE: switched to string.byte byte-by-byte processing, -- no longer relies on character class patterns. -- ============================================================ local recentFiles = {} -- ★ Constants 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-rematch timer interval (ms) } -- ★ Full-width → half-width mapping table 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 logging local function debugLog(...) if recentFiles.DEBUG then print(...) end end -- ★ Stop-word cache 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 local _cachedRawTitle = nil local _cachedCleanTitle = nil local _processIndex = nil local _wordCache = nil local _autoLoadTimer = nil local _rematchTimer = nil local _lastSignificantTitle = nil local _lastPid = 0 recentFiles.LastLoadMethod = nil -- ============================================================ -- ★★★ Configuration (user adjustable) ★★★ -- ============================================================ -- History file name (located in CE installation directory) recentFiles.FileName = 'recentFiles.txt' -- Maximum number of entries (older records are removed when exceeded) recentFiles.NumberOfEntries = 3000 -- Whether to ignore .CETRAINER extension files (do not record) recentFiles.IgnoreCETRAINER = false -- Automatically load the latest matching CT table when process switches -- true → auto-load; false → only show the list window for manual selection recentFiles.AUTO_LOAD_LATEST = true -- ★★★ Enable auto-load on process attach ★★★ -- true → enabled; false → completely disable auto-load, keep only "Switch CT" menu for manual load recentFiles.ENABLE_AUTO_LOAD_ON_ATTACH = true -- ★★★ Enable delayed loading ★★★ -- true → delay by AUTO_LOAD_DELAY milliseconds to avoid conflict with CE's own prompt -- false → execute immediately (synchronous), may override CE's prompt recentFiles.ENABLE_AUTO_LOAD_DELAY = true -- ★★★ Delay time (milliseconds) ★★★ -- Only effective when ENABLE_AUTO_LOAD_DELAY = true recentFiles.AUTO_LOAD_DELAY = 20 -- Note: Manual load "Overwrite/Merge" is chosen via the dual buttons at the bottom of the Switch CT window, -- no confirmation popup. CE Lua API (loadTable) itself executes silently and cannot trigger -- CE native confirmation dialogs. Auto-load (process switch / title change) uses silent overwrite path. -- ★★★ Enable custom save file naming ★★★ -- true → when saving, if current CT does not match window title, generate new file name from title -- false → use CE's default save behavior (note: this switch controls whether Save/Save As events are taken over) recentFiles.ENABLE_CUSTOM_SAVE = true -- ★★★ Title cache switch ★★★ -- true → cache preprocessed title for better performance -- false → reprocess each time (for debugging) recentFiles.ENABLE_TITLE_CACHE = true -- ★★★ Stop-word filter switch ★★★ -- true → ignore STOP_WORDS during fuzzy matching (improves accuracy) -- false → no filtering recentFiles.ENABLE_STOP_WORDS = true -- ★★★ Fuzzy matching parameters ★★★ -- Minimum common word count. Set to 2 to reduce word-bag errors; set to 1 to catch -- single core words (but false-match probability rises). recentFiles.FUZZY_MIN_WORDS = 2 recentFiles.FUZZY_THRESHOLD = 0.4 -- Similarity threshold (0~1, higher = stricter) -- ★★★ Scoring mode ★★★ -- 0 = original LCS/Union (denominator = title+filename union length) -- 1 = new LCS/#titleWords (denominator = title length only, short filenames no longer penalized) recentFiles.SCORE_MODE = 1 -- Set to 1 means new mode is used for all non-empty titles, no fallback -- (needs to be paired with FUZZY_MIN_WORDS >= 2). recentFiles.MIN_TITLE_WORDS_FOR_NEW_SCORE = 1 -- ★★★ Save match parameters ★★★ -- Note: The following two parameters only apply to historical CTs [without rules]. -- CTs with rules use precise hit/miss judgment, not affected by these two parameters. recentFiles.SAVE_MATCH_MIN_WORDS = 2 recentFiles.SAVE_MATCH_THRESHOLD = 0.4 -- ★ Restart CE after modifying this list for changes to take effect recentFiles.STOP_WORDS = { -- Chinese full words (generic suffixes/prefixes with little meaning) "版本", "汉化版", "修正", "补丁", "中文", "简体", "繁体", "测试", "正式", "发布", "加强", "完整", "汉化组", "破解", "免安装", "绿色", "便携", "整合", "合集", "合集包", "典藏", "珍藏","汉化", "模拟器", "游戏", "设置", "配置", "工具", "助手", "修改器", "补丁包", "升级", "更新", "下载", "分享", "转存", "备份", "优化", "稳定", "流畅", "高清", "重制", "复刻", "珍藏版", "典藏版", "终极版", "年度版", "黄金版", "豪华版", "特别版","完全版", -- 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", "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 (only for saving) ★★★ -- true → when saving a CT, if the entry has no rule and filename match is insufficient, automatically set current window title as rule -- false → disable recentFiles.AUTO_ADD_RULE_ON_SAVE = true -- ★★★ Show current CT info in CE title bar ★★★ -- true → title bar shows " | RF:mode:CT filename" -- false → do not show, keep CE original title recentFiles.SHOW_CT_INFO_IN_TITLE = true -- ★★★ Version priority (automatically pick highest version) ★★★ -- true → when multiple candidates from 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-rematch (automatically 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 numeric changes (FPS/frame rate, etc.) -- ★★★ Allow fallback to latest record on process attach when rule/fuzzy match fails ★★★ -- true → when process attached and no match, load the latest record -- false → do not load when no match recentFiles.AUTO_REMATCH_LOAD_LATEST_AS_FALLBACK = false -- ★★★ Allow fallback to latest record on title change when match fails ★★★ -- true → when title changes and no match, load the latest record (may cause unintended switches) -- false → disable fallback on title change to avoid mis-switching (recommended) recentFiles.AUTO_REMATCH_FALLBACK_ON_TITLE_CHANGE = false -- ★★★ Debug output switch ★★★ recentFiles.DEBUG = false -- ============================================================ -- ★★★ Cache management functions (for external calls) ★★★ -- ============================================================ function recentFiles.ReloadStopWords() recentFiles.ReloadCaches() end function recentFiles.ReloadCaches() _stopWordSet = nil _processIndex = {} _wordCache = {} _cachedRawTitle = nil _cachedCleanTitle = nil if recentFiles.Entries and #recentFiles.Entries > 0 then recentFiles.buildProcessIndex() end end -- ============================================================ -- Title preprocessing functions -- ============================================================ local FULL_OPEN = string.char(0xEF, 0xBC, 0x88) -- ( local FULL_CLOSE = string.char(0xEF, 0xBC, 0x89) -- ) local BOX_OPEN = string.char(0xE3, 0x80, 0x90) -- 【 local BOX_CLOSE = string.char(0xE3, 0x80, 0x91) -- 】 local JIAN_TAG = string.char(0xE7, 0xAE, 0x80) -- 简 (Simplified) local FAN_TAG = string.char(0xE7, 0xB9, 0x81) -- 繁 (Traditional) local YIZANTING = string.char(0xE5, 0xB7, 0xB2, 0xE6, 0x9A, 0x82, 0xE5, 0x81, 0x9C) -- 已暂停 (Paused) local function stripLangTag(title, tag) title = title:gsub("%(%s*" .. tag .. "%s*%)", "") title = title:gsub(FULL_OPEN .. "%s*" .. tag .. "%s*" .. FULL_CLOSE, "") title = title:gsub("%[%s*" .. tag .. "%s*%]", "") title = title:gsub(BOX_OPEN .. "%s*" .. tag .. "%s*" .. BOX_CLOSE, "") return title end recentFiles.TITLE_CLEANUP = function(title) if not title then return "" end title = title:gsub("Rosalie's Mupen GUI", "", 1) -- ★ Language/region/version tags -- Order: longest first, to avoid short tags matching the content of longer tags prematurely. -- Example: process JAPAN first, then JP, finally J. -- If (J) (U) (C) damages game names, comment out the corresponding lines. -- Chinese title = stripLangTag(title, JIAN_TAG) -- Simplified title = stripLangTag(title, FAN_TAG) -- Traditional -- Full words title = stripLangTag(title, "[Jj][Aa][Pp][Aa][Nn]") -- JAPAN title = stripLangTag(title, "[Ww]orld") -- World title = stripLangTag(title, "[Uu]nl") -- Unl title = stripLangTag(title, "[Ee][Tt][Cc]") -- ETC -- Two letters title = stripLangTag(title, "[Cc][Nn]") -- CN title = stripLangTag(title, "[Jj][Pp]") -- JP title = stripLangTag(title, "[Uu][Ss][Aa]") -- USA title = stripLangTag(title, "[Uu][Ss]") -- US -- Single letters (highest risk; comment out if false positives occur) title = stripLangTag(title, "[J]") -- J title = stripLangTag(title, "[U]") -- U title = stripLangTag(title, "[C]") -- C -- Symbol markers title = stripLangTag(title, "!") -- [!] -- ★ Runtime status tags title = stripLangTag(title, "[Pp]aused") title = stripLangTag(title, YIZANTING) -- The following remain unchanged 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*[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("帧率%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 recentFiles.PROCESS_NORMALIZE = function(processName) if not processName then return processName end local name = processName:gsub("%.exe$", "") local DASH_BYTE = 0x2D -- '-' local UNDER_BYTE = 0x5F -- '_' local V_LOWER = 0x76 -- 'v' local V_UPPER = 0x56 -- 'V' local DOT_BYTE = 0x2E -- '.' local ZERO_BYTE = 0x30 -- '0' local NINE_BYTE = 0x39 -- '9' -- Scan right-to-left for the last '-' or '_' local len = #name local sepPos = 0 for i = len, 1, -1 do local b = name:byte(i) if b == DASH_BYTE or b == UNDER_BYTE then sepPos = i break end end if sepPos == 0 then return name end -- Check whether the part after sepPos forms a version number local suffix = name:sub(sepPos + 1) local slen = #suffix if slen == 0 then return name end local pos = 1 local firstByte = suffix:byte(1) local isValid = false if firstByte == V_LOWER or firstByte == V_UPPER then pos = 2 if pos > slen then return name end local hasDigit = false while pos <= slen do local b = suffix:byte(pos) if b >= ZERO_BYTE and b <= NINE_BYTE then hasDigit = true elseif b == DOT_BYTE then -- allowed else return name -- illegal character end pos = pos + 1 end isValid = hasDigit else local hasDigit = false local hasDot = false while pos <= slen do local b = suffix:byte(pos) if b >= ZERO_BYTE and b <= NINE_BYTE then hasDigit = true elseif b == DOT_BYTE then hasDot = true else return name end pos = pos + 1 end isValid = hasDigit and hasDot end if not isValid then return name end local stripped = name:sub(1, sepPos - 1) if stripped == "" then return processName end return stripped end -- ============================================================ -- Internal implementations – recommended not to modify unless necessary -- ============================================================ local function toHalfwidth(str) if not str or str == "" then return str end local result = {} local i = 1 local len = #str while i <= len do local b1 = string.byte(str, i) if not b1 then break end local char, consumed if b1 < 0x80 then char = string.sub(str, i, i); consumed = 1 elseif b1 < 0xE0 then local b2 = string.byte(str, i+1) if not b2 then char = string.sub(str, i, i); consumed = 1 else char = string.sub(str, i, i+1); consumed = 2 end 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 char = string.sub(str, i, i); consumed = 1 else char = string.sub(str, i, i+2); consumed = 3 end 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 char = string.sub(str, i, i); consumed = 1 else char = string.sub(str, i, i+3); consumed = 4 end end result[#result+1] = HALFWIDTH_MAP[char] or char i = i + consumed end return table.concat(result) end 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 getCEDataDir() local dir = getCheatEngineDir() if dir and not dir:match("[\\/]$") then dir = dir .. "\\" end return dir or "" end local function closeFormAndActivateCE(form) if form then form.close() synchronize(function() local app = getApplication() if app then app.bringToFront() end end) end end local function updateTitleBar(mode, filename) local mainForm = getMainForm() if not mainForm then return end local baseTitle = mainForm.Caption or "Cheat Engine" baseTitle = baseTitle:gsub("%s*|%s*RF:[^:]*:[^|]*$", "") if recentFiles.SHOW_CT_INFO_IN_TITLE and 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 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 and mainForm.Caption then local caption = mainForm.Caption:gsub("%s*|%s*RF:[^:]*:[^|]*$", "") rawName = caption:match("^Cheat Engine %- (.+)") end end end if rawName and recentFiles.PROCESS_NORMALIZE then return recentFiles.PROCESS_NORMALIZE(rawName) end return rawName end 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 return nil end ms.Size = CONSTANTS.BUFFER_SIZE * 2 local buffer = ms.Memory if not buffer or buffer == 0 then 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) table.insert(candidates, { caption = caption, visible = (visibleRet ~= nil and visibleRet ~= 0) }) 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] Window enumeration exception: " .. tostring(err)) return nil end if #candidates == 0 then return nil end local processName = getCurrentProcessName() local processLower = processName and processName:lower() or "" local best, bestScore = nil, -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 and _cachedRawTitle == rawTitle then return _cachedCleanTitle or "" 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 clean = getCurrentWindowTitle() if clean == "" then return "" end if recentFiles.AUTO_REMATCH_IGNORE_NUMBERS then clean = removeNumbersFromTitle(clean) end return clean end local function isProcessAlive() local pid = getOpenedProcessID() if pid == 0 then return false end local procs = getProcesslist() if not procs then return false end return procs[pid] ~= nil end 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 ok, result = pcall(loadTable, path, merge) if not ok then print("[recentFiles] Load CT table exception: " .. tostring(result)) return false, "load_error" end if recentFiles.DEBUG then debugLog(string.format("[recentFiles] loadTable(%s, %s) → type=%s value=%s", tostring(path), tostring(merge), type(result), tostring(result))) end if not result then return false, "load_failed" end return true, "ok" end local function recordLoadedFile(filename, skipSave) 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, "", skipSave) end local function loadTableAndRecord(path, merge, isManual, loadInfo, loadMethod, silent, skipSave) local ok, reason = safeLoadTable(path, merge, isManual, loadInfo, silent) if ok then recentFiles.LastLoadMethod = loadMethod or "manual" recordLoadedFile(path, skipSave) 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 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 local function extractWords(str, filterStopWords) if not str or str == "" then return {} end str = toHalfwidth(str) -- [NEW-BUG-1] Default value aligned with config, to avoid silently skipping stop-word filter -- when the second parameter is omitted. if filterStopWords == nil then filterStopWords = recentFiles.ENABLE_STOP_WORDS end if not recentFiles.ENABLE_STOP_WORDS then filterStopWords = false end 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 local valid = true if b1 < 0x80 then code = b1; consumed = 1 elseif b1 < 0xE0 then local b2 = string.byte(str, i+1) if not b2 then if #current > 0 then table.insert(rawWords, table.concat(current)); current = {} end i = i + 1; valid = false; consumed = 1 else code = ((b1 & 0x1F) << 6) | (b2 & 0x3F); consumed = 2 end 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 if #current > 0 then table.insert(rawWords, table.concat(current)); current = {} end i = i + 1; valid = false; consumed = 1 else code = ((b1 & 0x0F) << 12) | ((b2 & 0x3F) << 6) | (b3 & 0x3F); consumed = 3 end 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 if #current > 0 then table.insert(rawWords, table.concat(current)); current = {} end i = i + 1; valid = false; consumed = 1 else code = ((b1 & 0x07) << 18) | ((b2 & 0x3F) << 12) | ((b3 & 0x3F) << 6) | (b4 & 0x3F); consumed = 4 end end if valid then 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 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 = {} for _, w in ipairs(words) do if not stopSet[w] then table.insert(filtered, w) end end words = filtered end local seen, deduped = {}, {} for _, w in ipairs(words) do if not seen[w] then seen[w] = true deduped[#deduped + 1] = w end end return deduped end 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 local v = name:match("[vV](%d+)$") if v and not (v:match("^%d%d%d%d$") and tonumber(v) and tonumber(v) > 1900) then versionStr = v end 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, vb = a[i] or 0, b[i] or 0 if va ~= vb then return va > vb and 1 or -1 end end return 0 end 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, 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 local function getFuzzyMatch(entries, title) if not _wordCache then _wordCache = {} end local useStopWords = recentFiles.ENABLE_STOP_WORDS local titleWords = extractWords(title, useStopWords) if #titleWords == 0 then return {} end 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 nameWords = _wordCache[entry.path] if not nameWords then local filename = extractFileName(entry.path) local name = filename:gsub("%.[Cc][Tt]$", "", 1) nameWords = extractWords(name, useStopWords) if nameWords then local filtered = {} for _, w in ipairs(nameWords) do if not w:match("^%d+$") then table.insert(filtered, w) end end nameWords = filtered else nameWords = {} end _wordCache[entry.path] = nameWords end if #nameWords > 0 then local nameSet = {} for _, w in ipairs(nameWords) do nameSet[w] = true end local commonCount = 0 for w, _ in pairs(nameSet) 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 score = lcs / (titleLen + #nameWords - lcs) end end if score >= recentFiles.FUZZY_THRESHOLD then table.insert(candidates, { entry = entry, score = score }) end end end 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 & UI -- ============================================================ recentFiles.Entries = {} recentFiles.CurrentFilePath = nil function recentFiles.buildProcessIndex() _processIndex = {} _wordCache = {} for idx, entry in ipairs(recentFiles.Entries) do entry.index = idx local p = recentFiles.PROCESS_NORMALIZE(entry.process) or entry.process entry.process = p if not _processIndex[p] then _processIndex[p] = {} end table.insert(_processIndex[p], entry) local filename = extractFileName(entry.path) local name = filename:gsub("%.[Cc][Tt]$", "", 1) local words = extractWords(name, recentFiles.ENABLE_STOP_WORDS) if words and #words > 0 then local filtered = {} for _, w in ipairs(words) do if not w:match("^%d+$") then table.insert(filtered, w) end end _wordCache[entry.path] = filtered else _wordCache[entry.path] = {} end end end function recentFiles.Load() recentFiles.Entries = {} local invalidCount = 0 local file = nil local seen = {} local ok, err = pcall(function() local dir = getCEDataDir() local filepath = dir .. recentFiles.FileName file = io.open(filepath, "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 recentFiles.IgnoreCETRAINER and normPath:lower():match("%.cetrainer$") then invalidCount = invalidCount + 1 elseif 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 recentFiles.IgnoreCETRAINER and normPath:lower():match("%.cetrainer$") then invalidCount = invalidCount + 1 elseif 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 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 recentFiles.ReloadCaches() end function recentFiles.Save() local ok, err = pcall(function() local dir = getCEDataDir() local filepath = dir .. recentFiles.FileName local file = io.open(filepath, "w") if not file then error("Unable to open file: " .. 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] Save failed: " .. tostring(err)) end end function recentFiles.AddEntry(process, path, rule, skipSave) rule = rule or "" if not process or not path then return end process = recentFiles.PROCESS_NORMALIZE(process) or process if not _processIndex then _processIndex = {} end if not _wordCache then _wordCache = {} 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 local newEntry = { process = process, path = normPath, rule = rule, index = 0 } table.insert(recentFiles.Entries, 1, newEntry) while #recentFiles.Entries > recentFiles.NumberOfEntries do table.remove(recentFiles.Entries) end for i, e in ipairs(recentFiles.Entries) do e.index = i end if not skipSave then recentFiles.Save() end if not _processIndex[process] then _processIndex[process] = {} end for i, e in ipairs(_processIndex[process]) do if e.path == normPath then table.remove(_processIndex[process], i); break end end table.insert(_processIndex[process], 1, newEntry) local filename = extractFileName(normPath) local name = filename:gsub("%.[Cc][Tt]$", "", 1) local words = extractWords(name, recentFiles.ENABLE_STOP_WORDS) if words and #words > 0 then local filtered = {} for _, w in ipairs(words) do if not w:match("^%d+$") then table.insert(filtered, w) end end _wordCache[normPath] = filtered else _wordCache[normPath] = {} end 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 _, entry in ipairs(recentFiles.Entries) do if entry.process == process and entry.path == normPath then if entry.rule == "" then entry.rule = rule recentFiles.Save() recentFiles.buildProcessIndex() end return end end recentFiles.AddEntry(process, normPath, rule) end function recentFiles.ClearAll() recentFiles.Entries = {} recentFiles.Save() _processIndex = {} _wordCache = {} recentFiles.CurrentFilePath = nil updateTitleBar(nil, nil) end function recentFiles.MigrateRules() local toMigrate = {} local skippedEmpty = 0 for _, e in ipairs(recentFiles.Entries) do if e.rule and e.rule ~= "" then local cleaned = recentFiles.TITLE_CLEANUP(e.rule) cleaned = cleaned:gsub("^%s+", ""):gsub("%s+$", "") if cleaned ~= e.rule then if cleaned == "" then skippedEmpty = skippedEmpty + 1 else table.insert(toMigrate, { entry = e, newRule = cleaned }) end end end end if #toMigrate == 0 and skippedEmpty == 0 then showMessage("All rules are already in the latest format, no migration needed.") return end -- Preview: show at most 10 entries local previewLines = {} local previewMax = 10 for i, item in ipairs(toMigrate) do if i > previewMax then table.insert(previewLines, string.format("... and %d more", #toMigrate - previewMax)) break end table.insert(previewLines, string.format(" Old: %s\n New: %s", item.entry.rule, item.newRule)) end local previewText = (#previewLines > 0) and ("\n\nPreview (first 10):\n" .. table.concat(previewLines, "\n")) or "" local msg = string.format( "Will upgrade %d rule(s) (rewrite using current TITLE_CLEANUP).\n" .. "Skip %d (empty after cleaning, keep original rule).%s", #toMigrate, skippedEmpty, previewText) if messageDialog(msg, mtConfirmation, mbYes, mbNo) ~= mrYes then return end -- Execute for _, item in ipairs(toMigrate) do item.entry.rule = item.newRule end recentFiles.Save() showMessage(string.format("Upgraded %d rule(s) (%d skipped).", #toMigrate, skippedEmpty)) end function recentFiles.GetEntriesForProcess(process) local normProcess = recentFiles.PROCESS_NORMALIZE(process) or process local result = {} local processLower = normProcess:lower() local exactList = _processIndex[normProcess] 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 if procName:lower() == processLower 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 utf8Len(s) if not s or s == "" then return 0 end local _, count = s:gsub("[^\128-\191]", "") return count 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 local lowerRule = entry.rule:lower() if lowerTitle:find(lowerRule, 1, true) then table.insert(matched, entry) end end end table.sort(matched, function(a, b) local ruleLenA, ruleLenB = utf8Len(a.rule), utf8Len(b.rule) if ruleLenA ~= ruleLenB then return ruleLenA > ruleLenB end if recentFiles.ENABLE_VERSION_PRIORITY then 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 end return (a.index or 999999) < (b.index or 999999) end) return matched end -- ============================================================ -- UI related (Switch CT window) -- ============================================================ local function updateButtonStates(listBox, items, btnSetRule, btnDelete, btnLoadOverwrite) 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) if btnLoadOverwrite then btnLoadOverwrite.Enabled = (selectedCount <= 1) end 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 searchEdit.OnEnter = function() if searchEdit.Text == PLACEHOLDER then searchEdit.Text = "" searchEdit.Font.Color = 0x000000 end end searchEdit.OnExit = function() if searchEdit.Text == "" then searchEdit.Text = PLACEHOLDER searchEdit.Font.Color = 0x808080 end end local listBox = createListBox(form) listBox.setSize(780, 370) listBox.setPosition(10, 50) listBox.MultiSelect = true return form, searchEdit, listBox, listBox.Items, PLACEHOLDER end local function refreshSelectionList(items, paths, rules, keyword, displayToOriginal) keyword = (keyword or ""):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 -- ★★★ Load button handler (merge controls overwrite/merge) ★★★ local function handleLoadClick(form, listBox, items, paths, displayToOriginal, merge) merge = merge or false 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, shouldClose = 0, true for _, idx in ipairs(selectedIndices) do local originalIdx = displayToOriginal[idx] if originalIdx then local selectedPath = paths[originalIdx] if fileExists(selectedPath) then local useMerge = merge if loadedCount > 0 then useMerge = true end local ok, reason = loadTableAndRecord(selectedPath, useMerge, true, nil, "manual", false, true) if ok then loadedCount = loadedCount + 1 elseif reason == "cancelled" then if loadedCount > 0 then showMessage(string.format("Successfully loaded %d file(s); remainder cancelled.", loadedCount)) end shouldClose = false; break else showMessage(string.format("Failed to load %s: %s", extractFileName(selectedPath), reason)) shouldClose = false; break end else showMessage("File does not exist: " .. selectedPath); shouldClose = false; break end end end if loadedCount > 0 then recentFiles.Save() end if shouldClose then closeFormAndActivateCE(form) end end local function handleSetRuleClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, btnSetRule, btnDelete, btnLoadOverwrite) local idx = listBox.ItemIndex if idx < 0 or idx >= items.Count then showMessage("Please select a record first"); return end local originalIdx = displayToOriginal[idx] if not originalIdx then showMessage("Invalid record"); 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 to match by process name only):", defaultRule) if newRule == nil then return end newRule = newRule:gsub("^%s+", ""):gsub("%s+$", "") rules[originalIdx] = newRule local processName = getCurrentProcessName() for _, e in ipairs(recentFiles.Entries) do if e.process == processName and 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, btnLoadOverwrite) end local function handleDeleteClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, btnSetRule, btnDelete, btnLoadOverwrite) 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 records to delete"); 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 local processName = getCurrentProcessName() for i = #recentFiles.Entries, 1, -1 do for _, p in ipairs(pathsToRemove) do local entry = recentFiles.Entries[i] if entry.process == processName and entry.path == p then table.remove(recentFiles.Entries, i); break end end end recentFiles.Save() recentFiles.buildProcessIndex() 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, btnLoadOverwrite) end 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, 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 ""):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 -- ★★★ Dual-button layout: Overwrite / Merge / Cancel ★★★ local btnLoadOverwrite = createButtonWithClick(form, "Overwrite", 200, 470, 130, 36, function() handleLoadClick(form, listBox, items, paths, displayToOriginal, false) end) local btnLoadMerge = createButtonWithClick(form, "Merge", 355, 470, 110, 36, function() handleLoadClick(form, listBox, items, paths, displayToOriginal, true) end) local btnCancel = createButtonWithClick(form, "Cancel", 495, 470, 110, 36, function() closeFormAndActivateCE(form) end) btnSetRule.OnClick = function() handleSetRuleClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, btnSetRule, btnDelete, btnLoadOverwrite) end btnDelete.OnClick = function() handleDeleteClick(listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER, btnSetRule, btnDelete, btnLoadOverwrite) end local function onSelectionChanged() updateButtonStates(listBox, items, btnSetRule, btnDelete, btnLoadOverwrite) end listBox.OnSelectionChange = onSelectionChanged listBox.OnClick = onSelectionChanged -- ★ Double-click defaults to "Overwrite Load" 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) elseif reason ~= "cancelled" then showMessage("Load failed: " .. tostring(reason)) end else showMessage("File does not exist: " .. selectedPath) end end end end updateButtonStates(listBox, items, btnSetRule, btnDelete, btnLoadOverwrite) form.showModal() end -- ============================================================ -- Auto-rematch (periodic check for title changes) -- ============================================================ local stopRematchTimer, startRematchTimer local function performAutoRematch() if not recentFiles.ENABLE_AUTO_REMATCH then return end if not isProcessAlive() then 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 local allowFallback = recentFiles.AUTO_REMATCH_FALLBACK_ON_TITLE_CHANGE local matchType = "rule" local matched = matchByRule(entries, title) if #matched == 0 then debugLog("[recentFiles] Rule match no result, trying fuzzy match") matched = getFuzzyMatch(entries, title) matchType = "fuzzy" end if #matched > 0 then 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 current file, and address list not empty, not reloading") return end end loadTableAndRecord(best.path, false, false, "Auto-rematch (version priority)", matchType, true) end return end if allowFallback 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 return end end loadTableAndRecord(latest.path, false, false, "Auto-rematch (latest fallback)", "latest", true) end end end stopRematchTimer = function() if _rematchTimer then _rematchTimer.Enabled = false _rematchTimer.destroy() _rematchTimer = nil end _lastSignificantTitle = nil _lastPid = 0 end startRematchTimer = function() 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 exited, stopping rematch 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 manual then showSelectionDialogAlways(); return end if 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 loadTableAndRecord(matched[1].path, false, false, "Rule match (version priority)", "rule", true) return end local fuzzyMatched = getFuzzyMatch(entries, title) if #fuzzyMatched > 0 then loadTableAndRecord(fuzzyMatched[1].path, false, false, "Fuzzy match (word-order aware + version priority)", "fuzzy", true) return end end if recentFiles.AUTO_REMATCH_LOAD_LATEST_AS_FALLBACK then local latest = entries[1] if latest and fileExists(latest.path) then loadTableAndRecord(latest.path, false, false, "No rule/fuzzy match, loading latest record", "latest", true) end end return end showSelectionDialogAlways() end -- ============================================================ -- Menu creation -- ============================================================ function recentFiles.ShowMenu() local mainForm = getMainForm() if not mainForm then return end local mainMenu = mainForm.Menu if not mainMenu then return end local function findTopMenu(caption) for i = 0, mainMenu.Items.Count - 1 do if mainMenu.Items[i].Caption == caption then return mainMenu.Items[i] end end return nil end if not findTopMenu('Switch CT') then local switchItem = createMenuItem(mainMenu) switchItem.Caption = 'Switch CT' switchItem.OnClick = function() local procName = getCurrentProcessName() local entries = procName and recentFiles.GetEntriesForProcess(procName) or {} if #entries == 0 then showMessage("No recent CT table records for the current process.") return end showSelectionDialogAlways() end mainMenu.Items.insert(mainMenu.Items.Count, switchItem) end local FileMenuItem = mainMenu.Items[0] if FileMenuItem then local hasClear, hasMigrate = false, false for i = 0, FileMenuItem.Count - 1 do local cap = FileMenuItem[i].Caption if cap == 'Clear Recent File List' then hasClear = true end if cap == 'Migrate Old Rules' then hasMigrate = true end end if not hasClear or not hasMigrate then local exitIdx = -1 for i = 0, FileMenuItem.Count - 1 do local cap = FileMenuItem[i].Caption if cap == '退出' or cap == 'Exit' or cap == '退出(&X)' or cap == 'E&xit' then exitIdx = i; break end end local position = (exitIdx >= 0) and exitIdx or FileMenuItem.Count if not hasClear and not hasMigrate then local sep = createMenuItem(FileMenuItem) sep.Caption = '-' FileMenuItem.insert(position, sep) position = position + 1 end if not hasClear then 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) position = position + 1 end if not hasMigrate then local migItem = createMenuItem(FileMenuItem) migItem.Caption = 'Migrate Old Rules' migItem.OnClick = function() recentFiles.MigrateRules() end FileMenuItem.insert(position, migItem) end end end end -- ============================================================ -- Open/Save functions override (custom save behavior) -- ============================================================ local function openWithButtonOrMenu(sender) local dialog = getMainForm().OpenDialog1 if not dialog then print("[recentFiles] OpenDialog1 not found, unable to 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 return title:gsub("[\\/:*?\"<>|]", "_") .. ".CT" 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 processName = getCurrentProcessName() if not processName then return false end local normCurrent = normalizePath(current) local normCurrentProcess = recentFiles.PROCESS_NORMALIZE(processName) local list = _processIndex and _processIndex[normCurrentProcess] if list then local lowerTitle = title:lower() for _, e in ipairs(list) do if e.path == normCurrent and e.rule and e.rule ~= "" then local lowerRule = e.rule:lower() return lowerTitle:find(lowerRule, 1, true) ~= nil end end 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 titleSet = {} for _, ft in ipairs(filteredTitle) do titleSet[ft] = true end local nameSet = {} for _, nw in ipairs(nameWords) do nameSet[nw] = true end local matchCount = 0 for tw, _ in pairs(nameSet) do if titleSet[tw] then matchCount = matchCount + 1 end end if matchCount < (recentFiles.SAVE_MATCH_MIN_WORDS or 2) then return false end local score = matchCount / math.max(#filteredTitle, #nameWords) return score >= (recentFiles.SAVE_MATCH_THRESHOLD or 0.6) 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 defaultDir, defaultName = nil, nil if shouldUseWindowTitle() then defaultName = getSuggestedFileName() if not defaultName or defaultName == "" then defaultName = "CheatTable.CT" end local cur = recentFiles.CurrentFilePath if cur and fileExists(cur) then defaultDir = extractFilePath(cur) end else local 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 utf8Len(title) >= 3 then if title:lower() ~= proc:lower() 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 if messageDialog(string.format("File %s already exists.\nDo you want to replace it?", filePath), mtConfirmation, mbYes, mbNo) ~= mrYes then return end end if saveTable(filePath) 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() if not recentFiles.ENABLE_CUSTOM_SAVE then return end local mainForm = getMainForm() if not mainForm then return end local function override(obj, fieldName, clearAction) if not obj then return end fieldName = fieldName or "OnClick" obj[fieldName] = customSave if clearAction ~= false then pcall(function() obj.Action = nil end) end end override(mainForm.SaveButton, "OnClick") override(mainForm.Save1, "OnClick") override(mainForm.miSave, "OnClick") override(mainForm.SaveAs1, "OnClick") override(mainForm.miSaveFile, "OnClick") if mainForm.actSave then override(mainForm.actSave, "OnExecute", false) -- do not clear Action 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) -- ============================================================ 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 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() 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 recentFiles.Load() recentFiles.ShowMenu() if getOpenedProcessID() ~= 0 and recentFiles.ENABLE_AUTO_REMATCH then if isProcessAlive() then startRematchTimer() end end