--====================================================================-- -- smart_open.lua (place into Cheat Engine's autorun folder) -- Ver 0.0.8 -- Author‌: AI -- -- Features: -- 1. Record the open history of CT tables -- 2. Auto-open the most recently used CT table on startup -- 3. Smart Open Search: fuzzy search for CT tables -- 4. On first run, pop up a dialog asking the user to choose the CT table save directory, -- stored in smart_open_config.lua -- 5. Menu "Smart Open 设置扫描目录..." allows re-selection at any time -- -- Debug: see smart_open_debug.log under the CE directory --====================================================================-- local HISTORY_PATH = getCheatEngineDir()..'smart_open_history.lua' local CONFIG_PATH = getCheatEngineDir()..'smart_open_config.lua' local DEBUG_PATH = getCheatEngineDir()..'smart_open_debug.log' local MARKER_PATH = getTempFolder()..'ce_smart_open_marker.tmp' --========================= User configuration =========================-- local HALF_LIFE_DAYS = 10 local CONFIRM_COUNT = 2 local CONFIRM_TOTAL = 3 local W_MATCH = 100 local W_FREC = 10 local MAX_SHOWN = 100 local HOTKEY_KEYS = {VK_CONTROL, VK_MENU, VK_O} -- Ctrl+Alt+O -- Used only as the "default directory when the first-run dialog appears" -- and as a "fallback when the user cancels". -- After the user chooses, the actual scan directory is stored in -- smart_open_config.lua and this no longer takes effect. local DEFAULT_SCAN_ROOTS = { getCheatEngineDir(), -- Example of setting a CT table directory: -- 'D:\\GAME\\My Cheat Tables\\', } --======================================================================-- local DEBUG = true local function debug(msg) if not DEBUG then return end local f = io.open(DEBUG_PATH, 'a') if f then f:write(os.date('%H:%M:%S ')..msg..'\n') f:close() end end local function serialize(v, indent) indent = indent or '' local t = type(v) if t == 'number' or t == 'boolean' then return tostring(v) end if t == 'string' then return string.format('%q', v) end if t == 'table' then local parts = {} for k, val in pairs(v) do local key if type(k) == 'number' then key = '['..k..']' else key = '['..string.format('%q', k)..']' end parts[#parts+1] = indent..' '..key..' = '..serialize(val, indent..' ')..',' end return '{\n'..table.concat(parts, '\n')..'\n'..indent..'}' end return 'nil' end local function loadHistory() local h = {} if fileExists(HISTORY_PATH) then local f = io.open(HISTORY_PATH, 'r') if f then local s = f:read('*a'); f:close() local fn = loadstring or load if fn then local chunk = fn(s) if type(chunk) == 'function' then local ok, res = pcall(chunk) if ok and type(res) == 'table' then h = res else debug('loadHistory: chunk execution failed -> '..tostring(res)) end else debug('loadHistory: syntax error, chunk is nil') end end end end return h end local function saveHistory(h) local f = io.open(HISTORY_PATH, 'w') if f then f:write('return '..serialize(h)); f:close() end end local function loadConfig() local cfg = {} if fileExists(CONFIG_PATH) then local f = io.open(CONFIG_PATH, 'r') if f then local s = f:read('*a'); f:close() local fn = loadstring or load if fn then local chunk = fn(s) if type(chunk) == 'function' then local ok, res = pcall(chunk) if ok and type(res) == 'table' then cfg = res else debug('loadConfig: chunk execution failed -> '..tostring(res)) end else debug('loadConfig: syntax error, chunk is nil') end end end end return cfg end local function saveConfig(cfg) local f = io.open(CONFIG_PATH, 'w') if f then f:write('return '..serialize(cfg)); f:close() end end local function freqNorm(count) return math.sqrt(math.min(count, 10)) / math.sqrt(10) end local function recencyFactor(last, now) local dtDays = math.max(0, now - (last or 0)) / 86400 return math.pow(0.5, dtDays / HALF_LIFE_DAYS) end local function frecencyScore(entry, now) return 10000 * freqNorm(entry.count or 1) * recencyFactor(entry.last or 0, now) end local SCORE_GAP_LEADING = -0.005 local SCORE_GAP_TRAILING = -0.005 local SCORE_GAP_INNER = -0.01 local SCORE_MATCH_CONSECUTIVE = 1.0 local SCORE_MATCH_WORD_START = 0.9 local SCORE_MATCH_CAPITAL = 0.7 local SCORE_MATCH_DOT = 0.6 local SCORE_MIN = -1e9 local function charBonus(str, j) if j == 1 then return SCORE_MATCH_WORD_START end local c = str:sub(j, j) local p = str:sub(j-1, j-1) if p:find('[%s_%-/\\]') then return SCORE_MATCH_WORD_START end if p:find('%l') and c:find('%u') then return SCORE_MATCH_CAPITAL end if p == '.' then return SCORE_MATCH_DOT end return 0 end local function fzy_match(queryLower, str, strLower) local m, n = #queryLower, #strLower if m == 0 then return 0 end if m > 64 or n > 1024 then return nil end do local qi = 1 for si = 1, n do if queryLower:sub(qi, qi) == strLower:sub(si, si) then qi = qi + 1 if qi > m then break end end end if qi <= m then return nil end end local D, M = {}, {} do local rowD, rowM = {}, {} D[1], M[1] = rowD, rowM local prev = SCORE_GAP_LEADING for j = 1, n do if queryLower:sub(1, 1) == strLower:sub(j, j) then local s = prev + charBonus(str, j) rowD[j] = s rowM[j] = math.max(s, (rowM[j-1] or SCORE_MIN) + SCORE_GAP_TRAILING) else rowD[j] = SCORE_MIN rowM[j] = (rowM[j-1] or SCORE_MIN) + SCORE_GAP_TRAILING end prev = prev + SCORE_GAP_LEADING end end for i = 2, m do local rowD, rowM = {}, {} D[i], M[i] = rowD, rowM rowD[1] = SCORE_MIN rowM[1] = SCORE_MIN local gap = SCORE_GAP_INNER local prevRowM = M[i-1] for j = 2, n do if queryLower:sub(i, i) == strLower:sub(j, j) then local s1 = gap + charBonus(str, j) local s2 = prevRowM[j-1] + SCORE_MATCH_CONSECUTIVE local s = (s1 > s2) and s1 or s2 rowD[j] = s rowM[j] = math.max(s, rowM[j-1] + SCORE_GAP_TRAILING) else rowD[j] = SCORE_MIN rowM[j] = rowM[j-1] + SCORE_GAP_TRAILING end gap = gap + SCORE_GAP_LEADING end end return M[m][n] end local suppressTableLoadRecord = false local function recordOpen(path) if not path or path == '' then return end local h = loadHistory() local e = h[path] if not e then e = {count=0, last=0} h[path] = e end e.count = (e.count or 0) + 1 e.last = os.time() saveHistory(h) debug('recordOpen: '..path..' count='..e.count) end local SCAN_ROOTS = nil local function ensureScanRoots() if SCAN_ROOTS then return true end local cfg = loadConfig() if type(cfg.scan_roots) == 'table' and #cfg.scan_roots > 0 then SCAN_ROOTS = cfg.scan_roots debug('ensureScanRoots: loaded '..#SCAN_ROOTS..' directories from config') return true end return false end local function applyFallbackScanRoots() SCAN_ROOTS = DEFAULT_SCAN_ROOTS debug('applyFallbackScanRoots: using in-script default directories') end local function askUserForScanRoot() local sd = createSelectDirectoryDialog(nil) pcall(function() sd.Title = 'Smart Open - Select CT Table Save Directory' end) pcall(function() sd.InitialDir = getCheatEngineDir() end) local ok = false pcall(function() ok = sd.Execute() end) if ok and sd.FileName and sd.FileName ~= '' then local chosen = sd.FileName if chosen:sub(-1) ~= '\\' and chosen:sub(-1) ~= '/' then chosen = chosen..'\\' end local cfg = loadConfig() cfg.scan_roots = { chosen } saveConfig(cfg) SCAN_ROOTS = cfg.scan_roots debug('askUserForScanRoot: user chose '..chosen) return true end debug('askUserForScanRoot: user cancelled') return false end local function buildCandidates() local cands = {} local seen = {} local now = os.time() for path, e in pairs(loadHistory()) do if fileExists(path) then seen[path:lower()] = true cands[#cands+1] = { path = path, name = path:lower(), frecency = frecencyScore(e, now), last = e.last or 0, count = e.count or 0, } end end local roots = SCAN_ROOTS or DEFAULT_SCAN_ROOTS -- fallback, prevents nil error for _, root in ipairs(roots) do local ok, files = pcall(getFileList, root, '*.CT', true) if ok and files then for _, f in ipairs(files) do local key = f:lower() if not seen[key] then seen[key] = true cands[#cands+1] = { path = f, name = f:lower(), frecency = 0, last = 0, count = 0, } end end else debug('buildCandidates: scan failed '..tostring(root)..' -> '..tostring(files)) end end debug('buildCandidates: total '..#cands..' candidates') return cands end local prevOnTableLoad = onTableLoad function onTableLoad(before) if prevOnTableLoad then pcall(prevOnTableLoad, before) end if before then return end if suppressTableLoadRecord then return end local path = nil pcall(function() local od = MainForm.OpenDialog1 if od and od.FileName and od.FileName ~= '' and fileExists(od.FileName) then path = od.FileName end if not path then local sd = MainForm.SaveDialog1 if sd and sd.FileName and sd.FileName ~= '' and fileExists(sd.FileName) then path = sd.FileName end end end) if path then recordOpen(path) debug('onTableLoad: recorded '..path) else debug('onTableLoad: failed to obtain file path') end end local searchForm = nil local searchEdit = nil local resultList = nil local statusLabel = nil local candidates = {} local shownResults = {} local function doUpdateResults() if not resultList then return end local now = os.time() local q = '' if searchEdit then local ok, val = pcall(function() return searchEdit.Text end) if ok and val then q = val end end q = q:lower() shownResults = {} if q == '' then local withFrec = {} for _, c in ipairs(candidates) do if c.frecency > 0 then withFrec[#withFrec+1] = c end end if #withFrec > 0 then table.sort(withFrec, function(a,b) if a.frecency ~= b.frecency then return a.frecency > b.frecency end return a.last > b.last end) for i = 1, math.min(#withFrec, MAX_SHOWN) do shownResults[#shownResults+1] = withFrec[i] end else local byPath = {} for _, c in ipairs(candidates) do byPath[#byPath+1] = c end table.sort(byPath, function(a,b) return a.path:lower() < b.path:lower() end) for i = 1, math.min(#byPath, MAX_SHOWN) do shownResults[#shownResults+1] = byPath[i] end end else local arr = {} for _, c in ipairs(candidates) do local ms = fzy_match(q, c.path, c.name) if ms and ms > 0 then arr[#arr+1] = {c=c, final = ms * W_MATCH + c.frecency * W_FREC} end end table.sort(arr, function(a,b) if a.final ~= b.final then return a.final > b.final end return a.c.last > b.c.last end) for i = 1, math.min(#arr, MAX_SHOWN) do shownResults[#shownResults+1] = arr[i].c end end local items = resultList.getItems() items.clear() for i, c in ipairs(shownResults) do local days = math.floor((now - c.last) / 86400) local tag = (c.count > 0) and string.format(' (x%d, %d days ago)', c.count, days) or '' items.add(extractFileName(c.path)..tag..' <- '..extractFilePath(c.path)) end if statusLabel then statusLabel.setCaption(string.format( '%d/%d candidates | Enter/Double-click open · Esc close', #shownResults, #candidates)) end debug('doUpdateResults: q="'..q..'" shown='..#shownResults..'/'..#candidates) end local function openSelected() if not resultList then return end local idx = resultList.getItemIndex() if not idx or idx < 0 then idx = 0 end local c = shownResults[idx + 1] if c then recordOpen(c.path) suppressTableLoadRecord = true loadTable(c.path) suppressTableLoadRecord = false if searchForm then searchForm.hide() end end end local function openSearch() if not ensureScanRoots() then applyFallbackScanRoots() end candidates = buildCandidates() if searchForm ~= nil then doUpdateResults() searchForm.show() if searchEdit then searchEdit.setFocus() end return end searchForm = createForm(true) searchForm.Caption = 'Smart Open - Fuzzy Search CT Tables' searchForm.Width = 720 searchForm.Height = 460 pcall(function() searchForm.setBorderStyle(bsSizeable) end) pcall(function() setProperty(searchForm, 'Position', poScreenCenter) end) searchForm.setOnClose(function(sender) pcall(function() sender.hide() end) return caNone end) searchEdit = createEdit(searchForm) searchEdit.Align = alTop searchEdit.Height = 26 searchEdit.Text = '' searchEdit.setOnChange(function(sender) doUpdateResults() end) searchEdit.setOnKeyDown(function(sender, key, shift) if key == VK_RETURN then openSelected() elseif key == VK_ESCAPE then searchForm.hide() end end) statusLabel = createLabel(searchForm) statusLabel.Align = alBottom statusLabel.Height = 20 resultList = createListBox(searchForm) resultList.Align = alClient resultList.OnClick = function(sender) if searchEdit then searchEdit.setFocus() end end pcall(function() setMethodProperty(resultList, 'OnDblClick', function(sender) openSelected() end) end) local btn = createButton(searchForm) btn.Caption = 'Open Selected (Enter)' btn.Align = alBottom btn.Height = 28 btn.OnClick = openSelected doUpdateResults() searchEdit.setFocus() end -- Attach to CE main menu local function addMenu() local mm = getMainForm().Menu if not mm then return end local mi = createMenuItem(mm) mi.Caption = 'Smart Open Search...' mi.OnClick = openSearch mm.Items.add(mi) local mi2 = createMenuItem(mm) mi2.Caption = 'Smart Open - Set Scan Directory...' mi2.OnClick = function(sender) if askUserForScanRoot() then showMessage('Scan directory updated:\n'..(SCAN_ROOTS and SCAN_ROOTS[1] or '')) else showMessage('Not modified') end end mm.Items.add(mi2) createHotkey(function() openSearch() end, HOTKEY_KEYS[1], HOTKEY_KEYS[2], HOTKEY_KEYS[3]) end local function alreadyRanThisSession() local f = io.open(MARKER_PATH, 'r') if not f then return false end local t = tonumber(f:read('*a') or '0'); f:close() return (os.time() - (t or 0)) < 10 end local function markSession() local f = io.open(MARKER_PATH, 'w') if f then f:write(tostring(os.time())); f:close() end end local function autoOpenOnStartup() if alreadyRanThisSession() then return end markSession() local now = os.time() local h = loadHistory() local arr = {} for path, e in pairs(h) do if fileExists(path) then arr[#arr+1] = {path=path, e=e, s=frecencyScore(e, now)} end end if #arr == 0 then return end table.sort(arr, function(a,b) if a.s ~= b.s then return a.s > b.s end return (a.e.last or 0) > (b.e.last or 0) end) local total = 0 for _, e in pairs(h) do total = total + (e.count or 0) end local top = arr[1] if (top.e.count or 0) >= CONFIRM_COUNT or total >= CONFIRM_TOTAL then suppressTableLoadRecord = true loadTable(top.path) suppressTableLoadRecord = false showMessage('Smart Open: auto-opened '..extractFileName(top.path)) else local list = createStringlist() for i = 1, math.min(#arr, 5) do list.add(extractFileName(arr[i].path)) end local idx = showSelectionList('Smart Open', 'Detected recently used CT tables, please select one (cancel = do not open):', list, false) if idx and idx >= 0 and arr[idx+1] then recordOpen(arr[idx+1].path) suppressTableLoadRecord = true loadTable(arr[idx+1].path) suppressTableLoadRecord = false end list.destroy() end end local function ensureScanRootsOnStartup() if ensureScanRoots() then debug('ensureScanRootsOnStartup: config exists, skipping popup') return end createTimer(1500, function() if ensureScanRoots() then return end -- second check, prevents duplicates if not askUserForScanRoot() then applyFallbackScanRoots() end debug('ensureScanRootsOnStartup: done, SCAN_ROOTS count='..(#SCAN_ROOTS or 0)) end) end --===================== Script entry point =====================-- addMenu() autoOpenOnStartup() ensureScanRootsOnStartup() debug('=== smart_open loaded ===')