-- ============================================================ -- recentFiles.lua (Rule Matching + Fuzzy Fallback) -- Ver 2.1.32 (Fixed CurrentFilePath not updating after auto‑load) -- ============================================================ local recentFiles = {} -- -------------------- Configuration -------------------- recentFiles.FileName = 'recentFiles.txt' recentFiles.NumberOfEntries = 50000 recentFiles.IgnoreCETRAINER = false recentFiles.AUTO_LOAD_LATEST = true -- ★★★ Custom save filename feature (disabled by default) ★★★ -- When enabled, saving uses a filename derived from the window title (instead of overwriting the current file) recentFiles.ENABLE_CUSTOM_SAVE = false -- ★★★ Fuzzy matching parameters (tunable) ★★★ recentFiles.FUZZY_MIN_WORDS = 2 -- Minimum common words (suggest 1~3) recentFiles.FUZZY_THRESHOLD = 0.4 -- Similarity threshold (0~1, higher = stricter) -- ★★★ CamelCase splitting (disabled by default, suitable for Chinese‑first scenarios) ★★★ -- When enabled, "MesenCE" will be split into "Mesen CE", increasing match chances. -- Recommended for English‑heavy naming with CamelCase/PascalCase. recentFiles.SPLIT_CAMEL_CASE = true recentFiles.BLACKLIST = { --"melonDS.CT", --"NDS_银河漫步.CT", } -- Title preprocessing: remove common dynamic content, generic | extraction recentFiles.TITLE_CLEANUP = function(title) if not title then return "" end -- ★★★ Generic extraction: if title contains '|', take the part after the last '|' (strip leading path/version info) ★★★ local lastPipe = title:match("^.*|%s*(.+)$") if lastPipe then title = lastPipe:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end -- ★★★ Version/Build info cleanup ★★★ 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%.+]+", "") -- ★★★ Additional version and status cleanup (optional) ★★★ title = title:gsub("%s*[Bb]uild%s*%d+", "") title = title:gsub("%s*[Rr]%s*%d+", "") -- r5678 title = title:gsub("%[%s*[Ee]mulation%s*%]", "") title = title:gsub("%[%s*[Pp]aused%s*%]", "") -- ★★★ Remove memory/CPU usage info ★★★ title = title:gsub("%s*[Mm]em%s*:?%s*%d+%s*[Mm][Bb]%s*,?", "") title = title:gsub("%s*[Cc][Pp][Uu]%s*:?%s*%d+%%%s*,?", "") -- ★★★ FPS/performance info cleanup (including square brackets) ★★★ title = title:gsub("%(%s*%d+%.?%d*%s*[Ff][Pp][Ss]%s*%)", "") -- (60 fps) title = title:gsub("%(%s*%d+%.?%d*%s*/%s*%d+%.?%d*%s*%)", "") -- (60/60) title = title:gsub("%[%s*%d+%.?%d*%s*[Ff][Pp][Ss]%s*%]", "") -- [60fps] title = title:gsub("%[%s*%d+%.?%d*%s*/%s*%d+%.?%d*%s*%]", "") -- [60/60] title = title:gsub("[Ff][Pp][Ss]%s*:?%s*%d+%.?%d*", "") -- FPS: 60 title = title:gsub("帧率%s*:?%s*%d+%.?%d*", "") -- 帧率: 60 (Chinese) title = title:gsub("[Ff][Pp][Ss]%s*%d+%.?%d*", "") -- fps60 -- Compress extra spaces title = title:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") return title end -- Process name normalisation 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 -- ------------------------------------------------- -- Helper functions local function safeLoadTable(path) if not path or not fileExists(path) then print("[recentFiles] File not found: " .. tostring(path)) return false end local success, err = pcall(function() loadTable(path) end) if not success then print("[recentFiles] Failed to load CT table: " .. err) return false end return true end -- ★★★ Improved process name retrieval: prefer 'process', fallback to PID lookup ★★★ 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 still empty, try extracting from main window title (last resort) if not rawName or rawName == "" then local caption = getMainForm().Caption if caption then rawName = caption:match("^Cheat Engine %- (.+)") end end end if rawName and recentFiles.PROCESS_NORMALIZE then return recentFiles.PROCESS_NORMALIZE(rawName) end return rawName end -- ★★★ Get the main window title of the attached process (prefer longest containing process name) ★★★ local function getMainWindowTitle() local pid = getOpenedProcessID() if pid == 0 then return nil end local all = getWindowlist() if not all or not all[pid] then return nil end local processName = getCurrentProcessName() if not processName then return nil end local blacklist = { "Default IME", "MSCTFIME UI", "GDI+ Window", "DDE Window", "MCI", "__wglDummyWindowFodder", "wglDummy", "DummyWindow", "CTrayNotifyIcon Helper Window", -- GPU-Z tray window "TrayNotifyIcon", -- Generic tray helper window "BluetoothNotificationAreaIconWindowClass", -- Windows system tray "NvContainerWindowClass", -- NVIDIA related ".NET-BroadcastEventWindow","MediaContextNotificationWindow", "SystemResourceNotifyWindow","AppBarDetectFullScreen", "TRAYAGENTWINDOW", -- Tencent PC Manager "UDiskVirtualWindow", "Window_tsvulfw_man_window_0","BroadcastListenerWindow","UxdService", "CalcMsgPumpWnd", -- Calculator "MS_WebcheckMonitor","DDE Server Window", -- Program Manager "NVOGLDC invisible", -- NVIDIA driver (e.g., LDPlayer) "CiceroUIWndFrame", -- Bilibili downloader / IME "QTrayIconMessageWindow", -- Qt tray "WinEventWindow", -- System event window "_WINDOWTOP_INTERNAL_UI_", -- Internal UI "BroadcastListenerWindow", -- Broadcast listener } local candidates = {} for _, win in ipairs(all[pid]) do local title if type(win) == "string" then title = win elseif type(win) == "table" then title = win[2] or "" else title = tostring(win) or "" end if title ~= "" then local isBlack = false for _, b in ipairs(blacklist) do if title:find(b, 1, true) then isBlack = true break end end if not isBlack then table.insert(candidates, title) end end end if #candidates == 0 then return nil end local processLower = processName:lower() local bestMatch = nil local bestLength = 0 for _, title in ipairs(candidates) do if title:lower():find(processLower, 1, true) then if #title > bestLength then bestLength = #title bestMatch = title end end end if bestMatch then return bestMatch end local best = candidates[1] for _, title in ipairs(candidates) do if #title > #best then best = title end end return best end -- ★★★ Preprocessed window title (for rule matching, fuzzy matching, and save) ★★★ local function getCurrentWindowTitle() local title = getMainWindowTitle() if not title then return "" end if recentFiles.TITLE_CLEANUP then title = recentFiles.TITLE_CLEANUP(title) end return title end local function isBlacklisted(path) if not path then return false end local baseName = extractFileName(path) for _, pattern in ipairs(recentFiles.BLACKLIST) do if baseName == pattern then return true end end return false end -- ★★★ Fixed CamelCase split – only splits ASCII alphanumeric blocks, leaves non‑English intact ★★★ local function splitCamelCase(str) local result = {} local len = #str local i = 1 while i <= len do local b = string.byte(str, i) -- If it's an ASCII letter or digit if (b >= 65 and b <= 90) or (b >= 97 and b <= 122) or (b >= 48 and b <= 57) then local start = i while i <= len do local b2 = string.byte(str, i) if not ((b2 >= 65 and b2 <= 90) or (b2 >= 97 and b2 <= 122) or (b2 >= 48 and b2 <= 57)) then break end i = i + 1 end local block = string.sub(str, start, i - 1) -- Split this pure alphanumeric block into CamelCase parts local parts = {} for word in block:gmatch("%u?%l+") do table.insert(parts, word) end local remaining = block:gsub("%u?%l+", "") if remaining ~= "" then table.insert(parts, remaining) end if #parts > 0 then table.insert(result, table.concat(parts, " ")) else table.insert(result, block) end else -- Non‑alphanumeric: keep as‑is (advance byte‑by‑byte, compatible with UTF‑8) local start = i i = i + 1 while i <= len do local b2 = string.byte(str, i) if (b2 >= 65 and b2 <= 90) or (b2 >= 97 and b2 <= 122) or (b2 >= 48 and b2 <= 57) then break end i = i + 1 end table.insert(result, string.sub(str, start, i - 1)) end end return table.concat(result, " ") end -- ★★★ Extract words using byte‑level detection (supports mixed Chinese/English, Chinese split into single characters) ★★★ -- ★★★ Supports optional CamelCase splitting (controlled by SPLIT_CAMEL_CASE) ★★★ local function extractWords(str, splitCamel) if not str or str == "" then return {} end if splitCamel == nil then splitCamel = recentFiles.SPLIT_CAMEL_CASE or false end if splitCamel then str = splitCamelCase(str) end local words = {} local len = #str local i = 1 while i <= len do local b = string.byte(str, i) if (b >= 65 and b <= 90) or (b >= 97 and b <= 122) or (b >= 48 and b <= 57) then local start = i while i <= len do local b2 = string.byte(str, i) if not ((b2 >= 65 and b2 <= 90) or (b2 >= 97 and b2 <= 122) or (b2 >= 48 and b2 <= 57)) then break end i = i + 1 end local word = string.sub(str, start, i - 1) if #word > 0 then table.insert(words, word:lower()) end elseif b >= 224 and b <= 239 then local b2 = string.byte(str, i+1) local b3 = string.byte(str, i+2) if b2 and b3 and b2 >= 128 and b2 <= 191 and b3 >= 128 and b3 <= 191 then while i <= len do local cb = string.byte(str, i) if not (cb >= 224 and cb <= 239 and string.byte(str, i+1) and string.byte(str, i+2) and string.byte(str, i+1) >= 128 and string.byte(str, i+1) <= 191 and string.byte(str, i+2) >= 128 and string.byte(str, i+2) <= 191) then break end local char = string.sub(str, i, i+2) table.insert(words, char) i = i + 3 end else i = i + 1 end else i = i + 1 end end return words end -- ★★★ Fuzzy matching (supports Chinese, uses configurable parameters, optional CamelCase splitting) ★★★ local function getFuzzyMatch(entries, title) local titleWords = extractWords(title) if #titleWords == 0 then return nil end local filteredTitleWords = {} for _, w in ipairs(titleWords) do if not w:match("^%d+$") then table.insert(filteredTitleWords, w) end end if #filteredTitleWords == 0 then return nil end titleWords = filteredTitleWords local bestEntry = nil local bestScore = 0 for _, entry in ipairs(entries) do local filename = extractFileName(entry.path) local name = filename:gsub("%.ct$", "", 1) local nameWords = extractWords(name) if #nameWords > 0 then local filteredNameWords = {} for _, w in ipairs(nameWords) do if not w:match("^%d+$") then table.insert(filteredNameWords, w) end end if #filteredNameWords > 0 then nameWords = filteredNameWords local matchCount = 0 for _, w in ipairs(nameWords) do for _, tw in ipairs(titleWords) do if w == tw then matchCount = matchCount + 1 break end end end if matchCount >= recentFiles.FUZZY_MIN_WORDS then local score = matchCount / math.max(#titleWords, #nameWords) if score > bestScore then bestScore = score bestEntry = entry end end end end end if bestEntry and bestScore >= recentFiles.FUZZY_THRESHOLD then return bestEntry end return nil end -- Data storage recentFiles.Entries = {} recentFiles.ClearMenuItem = nil recentFiles.SwitchMenuItem = nil recentFiles.CurrentFilePath = nil -- ★ Path of the currently loaded CT table ★ function recentFiles.Load() recentFiles.Entries = {} local file = io.open(getCheatEngineDir() .. recentFiles.FileName, "r") local invalidCount = 0 if file then for line in file:lines() do local process, path, rule = line:match("^(.-)\t(.+)\t(.*)$") if process and path then rule = rule or "" if fileExists(path) then table.insert(recentFiles.Entries, {process = process, path = path, rule = rule}) else invalidCount = invalidCount + 1 end else process, path = line:match("^(.-)\t(.+)$") if process and path then if fileExists(path) then table.insert(recentFiles.Entries, {process = process, path = path, rule = ""}) else invalidCount = invalidCount + 1 end else invalidCount = invalidCount + 1 end end end file:close() end if invalidCount > 0 then recentFiles.Save() end if #recentFiles.Entries > recentFiles.NumberOfEntries then for i = recentFiles.NumberOfEntries + 1, #recentFiles.Entries do recentFiles.Entries[i] = nil end recentFiles.Save() end end function recentFiles.Save() local file = io.open(getCheatEngineDir() .. recentFiles.FileName, "w") if not file then return end for _, entry in ipairs(recentFiles.Entries) do file:write(entry.process .. "\t" .. entry.path .. "\t" .. (entry.rule or "") .. "\n") end file:close() end function recentFiles.AddEntry(process, path, rule) rule = rule or "" if not process or not path then return end for i = #recentFiles.Entries, 1, -1 do local e = recentFiles.Entries[i] if e.process == process and e.path == path then if rule ~= "" then e.rule = rule end table.remove(recentFiles.Entries, i) break end end table.insert(recentFiles.Entries, 1, {process = process, path = path, rule = rule}) while #recentFiles.Entries > recentFiles.NumberOfEntries do table.remove(recentFiles.Entries) end recentFiles.Save() end function recentFiles.ClearAll() recentFiles.Entries = {} recentFiles.Save() end -- ★★★ Fix memory sync + support process name contains matching ★★★ function recentFiles.GetEntriesForProcess(process) recentFiles.Load() local result = {} local processLower = process:lower() -- 1. Exact match for _, entry in ipairs(recentFiles.Entries) do if entry.process == process then if not isBlacklisted(entry.path) and fileExists(entry.path) then table.insert(result, entry) end end end -- 2. If no exact match, try contains matching if #result == 0 then for _, entry in ipairs(recentFiles.Entries) do local entryLower = entry.process:lower() -- Process name contains entry name, or entry name contains process name if entryLower:find(processLower, 1, true) or processLower:find(entryLower, 1, true) then if not isBlacklisted(entry.path) and fileExists(entry.path) then table.insert(result, entry) end end end end return result end -- Rule matching (case‑insensitive) local function matchByRule(entries, currentTitle) if currentTitle == "" then return nil end local lowerTitle = currentTitle:lower() for _, entry in ipairs(entries) do if entry.rule and entry.rule ~= "" then if lowerTitle:find(entry.rule:lower(), 1, true) then return entry end end end return nil end -- ★★★ Record CT table (also updates CurrentFilePath) ★★★ local function onPostLoadTable(filename) if filename == '' or not fileExists(filename) then return end recentFiles.CurrentFilePath = filename -- record current file path if recentFiles.IgnoreCETRAINER and filename:sub(-3):upper() ~= '.CT' then return end local process = getCurrentProcessName() or "unknown" local existingRule = "" for _, e in ipairs(recentFiles.Entries) do if e.process == process and e.path == filename then existingRule = e.rule or "" break end end recentFiles.AddEntry(process, filename, existingRule) end -- Pop‑up dialog (button state control) — no destroy, consistent with v2.1.17 local function showSelectionDialogAlways() 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 = createForm(true) form.Caption = "Recent CT Tables (Process: " .. processName .. ")" form.setSize(610, 400) form.centerScreen() -- ★ Added: form auto‑destroys on close to prevent memory leaks ★ form.OnClose = function(sender) sender.destroy() end local listBox = createListBox(form) listBox.setSize(600, 300) listBox.setPosition(5, 50) listBox.MultiSelect = true local items = listBox.Items items.clear() for i, p in ipairs(paths) do local display = extractFileName(p) if rules[i] ~= "" then display = display .. " [Rule: " .. rules[i] .. "]" end items.add(display) end listBox.ItemIndex = 0 local updateButtons local function refreshList() items.clear() for i, p in ipairs(paths) do local display = extractFileName(p) if rules[i] ~= "" then display = display .. " [Rule: " .. rules[i] .. "]" end items.add(display) end if #paths > 0 then listBox.ItemIndex = 0 else form.hide() end if updateButtons then updateButtons() end end listBox.OnDblClick = function() local idx = listBox.ItemIndex if idx >= 0 and idx < #paths then local selectedPath = paths[idx + 1] if fileExists(selectedPath) then if safeLoadTable(selectedPath) then onPostLoadTable(selectedPath) end else showMessage("File not found: " .. selectedPath) end form.hide() end end listBox.OnSelectionChange = function() if updateButtons then updateButtons() end end local btnLoad = createButton(form) btnLoad.Caption = "Load" btnLoad.setSize(90, 34) btnLoad.setPosition(200, 358) btnLoad.OnClick = function() local idx = listBox.ItemIndex if idx >= 0 and idx < #paths then local selectedPath = paths[idx + 1] if fileExists(selectedPath) then if safeLoadTable(selectedPath) then onPostLoadTable(selectedPath) print("[recentFiles] Loaded " .. extractFileName(selectedPath)) end else showMessage("File not found: " .. selectedPath) end end form.hide() end local btnSetRule = createButton(form) btnSetRule.Caption = "Set Auto‑Match Rule" btnSetRule.setSize(310, 34) btnSetRule.setPosition(7, 8) btnSetRule.Enabled = false btnSetRule.OnClick = function() local idx = listBox.ItemIndex if idx < 0 or idx >= #paths then showMessage("Please select a CT table record to set a rule for") return end local currentPath = paths[idx + 1] local currentRule = rules[idx + 1] 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 match 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[idx + 1] = newRule for _, e in ipairs(recentFiles.Entries) do if e.path == currentPath then e.rule = newRule break end end recentFiles.Save() refreshList() print("[recentFiles] Set rule for " .. extractFileName(currentPath) .. ": " .. (newRule == "" and "(none)" or newRule)) end local btnDelete = createButton(form) btnDelete.Caption = "Delete Selected CT Record(s)" btnDelete.setSize(280, 34) btnDelete.setPosition(325, 8) 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 record(s) to delete (multi‑select allowed)") return end if messageDialog("Are you sure you want to delete " .. #selectedIndices .. " selected record(s)?\n(This will NOT delete the CT files from disk)", mtConfirmation, mbYes, mbNo) ~= mrYes then return end local pathsToRemove = {} for _, idx in ipairs(selectedIndices) do table.insert(pathsToRemove, paths[idx + 1]) 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 refreshList() end local btnCancel = createButton(form) btnCancel.Caption = "Cancel" btnCancel.setSize(90, 34) btnCancel.setPosition(320, 358) btnCancel.OnClick = function() form.hide() end updateButtons = function() 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 updateButtons() form.showModal() -- No form.destroy() – handled by OnClose end -- Auto‑load (Rule → Fuzzy → Latest) — FIX: call onPostLoadTable after every load local function showRecentSelectionDialog() local processName = getCurrentProcessName() if not processName then return end local entries = recentFiles.GetEntriesForProcess(processName) if #entries == 0 then return end if recentFiles.AUTO_LOAD_LATEST then local title = getCurrentWindowTitle() if title ~= "" then local matched = matchByRule(entries, title) if matched then if safeLoadTable(matched.path) then onPostLoadTable(matched.path) -- ★ Added print("[recentFiles] Rule match succeeded: " .. extractFileName(matched.path) .. " (Rule: " .. matched.rule .. ")") return else print("[recentFiles] Rule‑matched file failed to load: " .. matched.path) end end local fuzzyMatched = getFuzzyMatch(entries, title) if fuzzyMatched then if safeLoadTable(fuzzyMatched.path) then onPostLoadTable(fuzzyMatched.path) -- ★ Added print("[recentFiles] Fuzzy match succeeded: " .. extractFileName(fuzzyMatched.path) .. " (based on filename‑title similarity)") return else print("[recentFiles] Fuzzy‑matched file failed to load: " .. fuzzyMatched.path) end end end local latest = entries[1] if latest and fileExists(latest.path) then if safeLoadTable(latest.path) then onPostLoadTable(latest.path) -- ★ Added print("[recentFiles] Auto‑loaded latest (no rule/fuzzy match): " .. extractFileName(latest.path)) end else print("[recentFiles] Latest file does not exist: " .. tostring(latest and latest.path)) end return end showSelectionDialogAlways() end -- Menu creation function recentFiles.ShowMenu() if not recentFiles.SwitchMenuItem then local mainMenu = getMainForm().Menu if mainMenu 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 end if recentFiles.ClearMenuItem then return end local FileMenuItem = getMainForm().Menu.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 -- Event hooks local oldOnProcessOpened = MainForm.OnProcessOpened MainForm.OnProcessOpened = function(pid, handle, caption) if oldOnProcessOpened then oldOnProcessOpened(pid, handle, caption) end showRecentSelectionDialog() end -- Custom open function: avoid recording on cancel, added nil check 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 if safeLoadTable(filename) then onPostLoadTable(filename) end end end end -- Replace CE's "Open" button and menu items getMainForm().LoadButton.OnClick = openWithButtonOrMenu getMainForm().Load1.OnClick = openWithButtonOrMenu getMainForm().LoadButton.Action = nil getMainForm().Load1.Action = nil -- ============================================================ -- ★★★ Custom Save (always replaces save controls) ★★★ -- ============================================================ 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 -- If the title contains a path separator, take only the last segment (filename part) local base = title:match("^.*[\\/]([^\\/]+)$") if base then title = base end -- Replace illegal filename characters local fileName = title:gsub("[\\/:*?\"<>|]", "_") .. ".CT" return fileName end local function customSave() local defaultPath = nil local defaultDir = nil local defaultName = nil -- If custom save is disabled, prioritise the current file path (native overwrite behaviour) if not recentFiles.ENABLE_CUSTOM_SAVE then defaultPath = recentFiles.CurrentFilePath if defaultPath and fileExists(defaultPath) then defaultDir = extractFilePath(defaultPath) defaultName = extractFileName(defaultPath) else -- If no current file, use the title‑based suggested name defaultName = getSuggestedFileName() end else -- Custom save enabled: use a new filename derived from the window title defaultName = getSuggestedFileName() 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" if saveDialog.Execute() then local filePath = saveDialog.FileName if filePath and filePath ~= "" then if fileExists(filePath) then local answer = messageDialog(string.format("File %s already exists.\nDo you want to replace it?", filePath), mtConfirmation, mbYes, mbNo) if answer ~= mrYes then return end end local success = saveTable(filePath) if success then onPostLoadTable(filePath) else messageDialog(string.format("Save failed: %s\nPlease check the file path or permissions.", filePath), mtError, mbOk) end end end saveDialog.destroy() end local function replaceSaveControls() local mainForm = getMainForm() if not mainForm then return end -- 1. Toolbar save button local saveButton = mainForm.SaveButton if saveButton then saveButton.OnClick = customSave saveButton.Action = nil end -- 2. Menu items: locate by known names local menuItems = { mainForm.miSaveFile, -- File → Save mainForm.Save1, -- possibly Save or Save As mainForm.miSave, -- another Save mainForm.SaveAs1, -- Save As (verified exists) } for _, item in ipairs(menuItems) do if item then item.OnClick = customSave item.Action = nil end end -- 3. Action object (Ctrl+S is usually bound to this) local actSave = mainForm.actSave if actSave then actSave.OnExecute = customSave actSave.OnUpdate = nil end end replaceSaveControls() -- ============================================================ -- ★★★ Handle drag‑and‑drop of CT files (chained call) ★★★ -- ============================================================ local function onFormDropFiles(sender, fileNames) if fileNames and #fileNames > 0 then local firstFile = fileNames[1] if firstFile and fileExists(firstFile) then onPostLoadTable(firstFile) end end end local mainForm = getMainForm() if mainForm then mainForm.AllowDropFiles = true local oldOnDropFiles = mainForm.OnDropFiles mainForm.OnDropFiles = function(sender, fileNames) if oldOnDropFiles then oldOnDropFiles(sender, fileNames) end onFormDropFiles(sender, fileNames) end end -- Initialisation recentFiles.Load() recentFiles.ShowMenu()