-- ============================================================ -- recentFiles.lua -- Features: Automatically load matching CT table based on process window title (rule / fuzzy matching) -- Version: 2.3.1 (Forced camel case splitting, stable support for mixed Chinese/English titles) -- ============================================================ local recentFiles = {} -- ★ Constants (centralized for easy maintenance) [read‑only] 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) } do local constMetatable = { __newindex = function() error("CONSTANTS table is read‑only and cannot be modified") end, __metatable = "locked", } setmetatable(CONSTANTS, constMetatable) 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 local _entries_dirty = true 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 = { "汉", "化", "版", "简", "体", "中", "文", "修", "正", "补", "丁", "加", "强", "完", "整", "测", "试", "正", "式", "发", "布", "汉", "化", "组", "繁", "体", } -- ★★★ 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 local lastPipe = title:match("^.*|%s*(.+)$") if lastPipe then title = lastPipe:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end 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 -- ============================================================ -- ★★★ 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 clearAllCaches() 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 if recentFiles.DEBUG then print("[recentFiles] Warning: failed to create memory stream, cannot get window title") end return nil end ms.Size = CONSTANTS.BUFFER_SIZE * 2 local buffer = ms.Memory if not buffer or buffer == 0 then if recentFiles.DEBUG then print("[recentFiles] Warning: memory stream buffer invalid") end 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 bytes = readBytesLocal(buffer, len * 2, true) if bytes and #bytes > 0 then local utf8Str = {} local i = 1 while i <= #bytes do local c1 = bytes[i] local c2 = bytes[i+1] if not c1 or not c2 then break end local code = c1 + c2 * 256 local consumed = 2 local ok = false if code >= 0xD800 and code <= 0xDBFF then local c3 = bytes[i+2] local c4 = bytes[i+3] if c3 and c4 then local low = c3 + c4 * 256 if low >= 0xDC00 and low <= 0xDFFF then local cp = 0x10000 + ((code - 0xD800) * 1024) + (low - 0xDC00) table.insert(utf8Str, string.char( 0xF0 + math.floor(cp / 0x40000), 0x80 + math.floor((cp % 0x40000) / 0x1000), 0x80 + math.floor((cp % 0x1000) / 0x40), 0x80 + (cp % 0x40) )) consumed = 4 ok = true end end end if not ok then if code < 0x80 then table.insert(utf8Str, string.char(code)) elseif code < 0x800 then table.insert(utf8Str, string.char( 0xC0 + math.floor(code / 64), 0x80 + (code % 64) )) else table.insert(utf8Str, string.char( 0xE0 + math.floor(code / 4096), 0x80 + math.floor((code % 4096) / 64), 0x80 + (code % 64) )) end end i = i + consumed end local caption = table.concat(utf8Str) local className = getWindowClassName(hwnd) if caption ~= "" and 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 if recentFiles.DEBUG then print("[recentFiles] getMainWindowTitle error: " .. tostring(err)) end 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 if recentFiles.DEBUG then print("[recentFiles] File does not exist: " .. tostring(path)) end 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 if recentFiles.DEBUG then print("[recentFiles] loadTable returned false, load failed") end 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" and recentFiles.DEBUG then print("[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 -- Insert spaces at lowercase+uppercase or uppercase+uppercase+lowercase boundaries 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) ★★★ local function extractWords(str, splitCamel, filterStopWords, useCache) if not str or str == "" then return {} end if not recentFiles.ENABLE_STOP_WORDS then filterStopWords = false end if recentFiles.DEBUG then print("[extractWords] Input: " .. str) end -- Step 1: Extract raw words (preserve case) 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 >= 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)) -- preserve case current = {} end end i = i + consumed end if #current > 0 then table.insert(rawWords, table.concat(current)) end -- Step 2: Force camel case splitting (ignore splitCamel parameter, always split) 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 -- Step 3: Stop‑word filtering 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 if recentFiles.DEBUG then print("[extractWords] Extracted words: " .. table.concat(words, ", ")) end 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, nil, useStopWords, false) if #titleWords == 0 then return {} end if recentFiles.DEBUG then print("[DEBUG] Raw title: " .. title) print("[DEBUG] Title length (chars): " .. #title) print("[DEBUG] Title bytes: " .. table.concat({string.byte(title, 1, #title)}, ", ")) 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 filename = extractFileName(entry.path) local name = filename:gsub("%.[Cc][Tt]$", "", 1) local nameWords = extractWords(name, nil, useStopWords, false) 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 -- ★★★ New: Full containment bonus (all filename words are in title) ★★★ if commonCount == #nameWords and commonCount > 0 then -- Use Dice coefficient, friendly to short filenames score = (2 * commonCount) / (titleLen + #nameWords) else -- Original scoring logic 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 if recentFiles.DEBUG then print("[DEBUG] Filename: " .. name) print(" titleWords: " .. table.concat(titleWords, ", ")) print(" nameWords: " .. table.concat(nameWords, ", ")) print(" commonCount: " .. commonCount .. " lcs: " .. lcs .. " titleLen: " .. titleLen) print(" Score: " .. string.format("%.4f", score)) end if score >= recentFiles.FUZZY_THRESHOLD then table.insert(candidates, {entry = entry, score = score}) end end end end end if recentFiles.DEBUG then print("[DEBUG] Candidate list (" .. #candidates .. " items):") for _, item in ipairs(candidates) do local fname = extractFileName(item.entry.path) print(" " .. fname .. " score: " .. string.format("%.4f", item.score)) 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 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 and recentFiles.DEBUG then print("[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() clearAllCaches() _entries_dirty = false 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() clearAllCaches() _entries_dirty = false end) if not ok then print("[recentFiles] Failed to save recent file list: " .. tostring(err)) _entries_dirty = true 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() clearAllCaches() _entries_dirty = false 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() clearAllCaches() _entries_dirty = false end return end end recentFiles.AddEntry(process, normPath, rule) end function recentFiles.ClearAll() recentFiles.Entries = {} recentFiles.Save() _processIndex = {} clearAllCaches() recentFiles.CurrentFilePath = nil updateTitleBar(nil, nil) _entries_dirty = false end function recentFiles.GetEntriesForProcess(process) if _entries_dirty then recentFiles.Load() end 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) -- ============================================================ 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, paths, rules) 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 bindSelectionEvents(form, listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER) local btnLoad = createButton(form) btnLoad.Caption = "Load" btnLoad.setSize(90, 36) btnLoad.setPosition(200, 470) btnLoad.OnClick = function() 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 btnSetRule = createButton(form) btnSetRule.Caption = "Set Auto‑Match Rule" btnSetRule.setSize(310, 36) btnSetRule.setPosition(10, 428) btnSetRule.Enabled = false btnSetRule.OnClick = function() 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 btnDelete = createButton(form) btnDelete.Caption = "Delete Selected CT Records" btnDelete.setSize(376, 36) btnDelete.setPosition(414, 428) btnDelete.Enabled = false btnDelete.OnClick = function() 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 local btnCancel = createButton(form) btnCancel.Caption = "Cancel" btnCancel.setSize(90, 36) btnCancel.setPosition(510, 470) btnCancel.OnClick = function() closeFormAndActivateCE(form) end return btnLoad, btnSetRule, btnDelete, btnCancel 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 = {} 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, paths, rules) 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, btnDelete local btnLoad, btnSetRuleRef, btnDeleteRef, btnCancel = bindSelectionEvents(form, listBox, items, paths, rules, displayToOriginal, searchEdit, PLACEHOLDER) btnSetRule = btnSetRuleRef btnDelete = btnDeleteRef 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 if recentFiles.DEBUG then print("[recentFiles] Attached process has exited, stopping re‑match") end 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 if recentFiles.DEBUG then print("[recentFiles] Current title: " .. title) end local matched = {} local matchType = "rule" matched = matchByRule(entries, title) if #matched == 0 then if recentFiles.DEBUG then print("[recentFiles] Rule match found nothing, trying fuzzy match") end matched = getFuzzyMatch(entries, title) matchType = "fuzzy" end if #matched > 0 then if recentFiles.DEBUG then print("[recentFiles] Matched " .. #matched .. " candidate(s), selecting best") end 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 if recentFiles.DEBUG then print("[recentFiles] Best candidate is the current file and address list is not empty, skipping reload") end return else if recentFiles.DEBUG then print("[recentFiles] Best candidate is the current file but address list is empty, forcing reload") end end end local ok, reason = loadTableAndRecord(best.path, false, false, "Auto re‑match (version priority)", matchType, true) if ok then if recentFiles.DEBUG then print("[recentFiles] Auto re‑match succeeded: " .. extractFileName(best.path)) end else if recentFiles.DEBUG then print("[recentFiles] Auto re‑match failed: " .. tostring(reason)) end end end return end if recentFiles.AUTO_REMATCH_LOAD_LATEST_AS_FALLBACK then if recentFiles.DEBUG then print("[recentFiles] No rule/fuzzy match, using fallback to load latest record") end 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 if recentFiles.DEBUG then print("[recentFiles] Latest record is the current file (" .. extractFileName(latest.path) .. "), address list not empty, no reload needed") end return else if recentFiles.DEBUG then print("[recentFiles] Latest record is the current file but address list is empty, forcing reload") end end end local ok, reason = loadTableAndRecord(latest.path, false, false, "Auto re‑match (latest record fallback)", "latest", true) if ok then if recentFiles.DEBUG then print("[recentFiles] Auto re‑match (latest record fallback) succeeded: " .. extractFileName(latest.path)) end else if recentFiles.DEBUG then print("[recentFiles] Auto re‑match (latest record fallback) failed: " .. tostring(reason)) end end else if recentFiles.DEBUG then print("[recentFiles] Latest record is invalid or file does not exist") end end else if recentFiles.DEBUG then print("[recentFiles] No match and fallback not enabled, not loading") end 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 if recentFiles.DEBUG then print("[recentFiles] Attached process has exited, stopping re‑match timer") end 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, nil, useStopWords, false) if #nameWords == 0 then return false end local titleWords = extractWords(title, nil, useStopWords, false) 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 = { mainForm.Save1, mainForm.miSave, } 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 clearAllCaches() stopRematchTimer() if pid ~= 0 and recentFiles.ENABLE_AUTO_REMATCH then if isProcessAlive() then startRematchTimer() else if recentFiles.DEBUG then print("[recentFiles] Newly attached process seems invalid, timer not started") end 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 if recentFiles.DEBUG then print("[recentFiles] Current process invalid, timer not started") end end end else if recentFiles.DEBUG then print("[recentFiles] Script already installed, skipping duplicate mount.") end end