--[[ Hot Reload Manager v3.31 Fixes: - Added pcall protection for enumStructureForms to improve version compatibility - Added pcall fault tolerance for cleanAllDuplicateMenus to prevent cleanup failures from interrupting reload Retained: all features from v3.30 ]] local CONFIG = { HOTKEY = "Ctrl+Shift+R", SYNTAX_CHECK = true, TOAST_DURATION = 3000, AUTO_CLEAN_DUPLICATE_MENUS = true, } -- ========== Key code mapping table ========== local KEY_MAP = { ['ctrl'] = VK_CONTROL, ['shift'] = VK_SHIFT, ['alt'] = VK_MENU, ['f1'] = VK_F1, ['f2'] = VK_F2, ['f3'] = VK_F3, ['f4'] = VK_F4, ['f5'] = VK_F5, ['f6'] = VK_F6, ['f7'] = VK_F7, ['f8'] = VK_F8, ['f9'] = VK_F9, ['f10'] = VK_F10, ['f11'] = VK_F11, ['f12'] = VK_F12, ['a'] = VK_A, ['b'] = VK_B, ['c'] = VK_C, ['d'] = VK_D, ['e'] = VK_E, ['f'] = VK_F, ['g'] = VK_G, ['h'] = VK_H, ['i'] = VK_I, ['j'] = VK_J, ['k'] = VK_K, ['l'] = VK_L, ['m'] = VK_M, ['n'] = VK_N, ['o'] = VK_O, ['p'] = VK_P, ['q'] = VK_Q, ['r'] = VK_R, ['s'] = VK_S, ['t'] = VK_T, ['u'] = VK_U, ['v'] = VK_V, ['w'] = VK_W, ['x'] = VK_X, ['y'] = VK_Y, ['z'] = VK_Z, ['0'] = VK_0, ['1'] = VK_1, ['2'] = VK_2, ['3'] = VK_3, ['4'] = VK_4, ['5'] = VK_5, ['6'] = VK_6, ['7'] = VK_7, ['8'] = VK_8, ['9'] = VK_9, ['space'] = VK_SPACE, ['enter'] = VK_RETURN, ['tab'] = VK_TAB, ['esc'] = VK_ESCAPE, ['back'] = VK_BACK, ['delete'] = VK_DELETE, ['insert'] = VK_INSERT, ['home'] = VK_HOME, ['end'] = VK_END, ['pageup'] = VK_PRIOR, ['pagedown'] = VK_NEXT, ['up'] = VK_UP, ['down'] = VK_DOWN, ['left'] = VK_LEFT, ['right'] = VK_RIGHT, } -- ========== Global state ========== if not _G.reloadManager then _G.reloadManager = { scripts = {}, menuCaption = '&Hot Reload', hotkeyObj = nil, } end local RM = _G.reloadManager -- ========== Helper functions ========== local function getMainItems() return getMainForm().Menu.Items end local function deleteTopMenus(caption) local items = getMainItems() for i = items.Count - 1, 0, -1 do local item = items.Item[i] if item.Caption == caption then items.Delete(i) end end end local function getSettingsObj() local s = getSettings('ReloadManager') if not s.Value then s.Value = {} end return s end local function saveCheckState(path, checked) local s = getSettingsObj() if checked then s.Value[path] = 'true' else s.Value[path] = nil end end local function loadCheckState(path) local s = getSettingsObj() return (s.Value[path] == 'true') end local function savePathList() local paths = {} for _, e in ipairs(RM.scripts) do paths[#paths + 1] = e.path end getSettingsObj().Value['PathList'] = table.concat(paths, '\n') end local function loadPathList() local str = getSettingsObj().Value['PathList'] if str and str ~= '' then local paths = {} for p in string.gmatch(str, '([^\n]+)') do if p and p ~= '' then paths[#paths + 1] = p end end return paths end return {} end local function hasNonAscii(path) for i = 1, #path do if string.byte(path, i) > 127 then return true end end return false end local function fileExistsRobust(path) if fileExists(path) then return true end local f = io.open(path, 'r') if f then f:close() return true end return false end -- ========== Bottom‑right toast notification ========== local toastWindow = nil local function showToast(msg, duration) duration = duration or CONFIG.TOAST_DURATION if toastWindow and not toastWindow.Closed then toastWindow.Close() toastWindow = nil end local f = createForm(false) f.Width = 400 f.AlphaBlend = true f.AlphaBlendValue = 230 f.BorderStyle = bsNone f.Caption = '' f.FormStyle = fsSystemStayOnTop f.Color = clBlack local memo = createMemo(f) memo.Parent = f memo.Left = 10 memo.Top = 10 memo.Width = f.Width - 20 memo.Height = 30 memo.ReadOnly = true memo.BorderStyle = bsNone memo.Color = clBlack memo.Font.Color = clWhite memo.Font.Size = 11 memo.WordWrap = true memo.Scrollbars = ssNone memo.Lines.Text = msg local lineCount = memo.Lines.Count if lineCount == 0 then lineCount = 1 end local lineHeight = memo.Font.Size + 4 local neededHeight = lineCount * lineHeight + 20 if neededHeight < 130 then neededHeight = 130 end memo.Height = neededHeight - 20 f.Height = neededHeight local screenW = getScreenWidth() local workH = getWorkAreaHeight() f.Left = screenW - f.Width - 20 f.Top = workH - f.Height - 20 f.Show() toastWindow = f local timer = createTimer(nil) timer.Interval = duration timer.OnTimer = function() timer.Enabled = false if f and not f.Closed then f.Close() end timer = nil if toastWindow == f then toastWindow = nil end end end -- ========== Recursively clean duplicate items from any menu ========== local function cleanMenuRecursive(menuItem) if not menuItem then return 0 end local items = menuItem.Items if not items then return 0 end local total = 0 local seen = {} local toDelete = {} for i = 0, items.Count - 1 do local child = items[i] local caption = child.Caption if caption and caption ~= "" and caption ~= "-" then local norm = caption:gsub("&", ""):lower():gsub("%s+", " ") if seen[norm] then table.insert(toDelete, i) else seen[norm] = true end end end table.sort(toDelete, function(a, b) return a > b end) for _, idx in ipairs(toDelete) do items.Delete(idx) total = total + 1 end -- Recursively process submenus local count = items.Count for i = 0, count - 1 do local child = items[i] if child and child.Count and child.Count > 0 then total = total + cleanMenuRecursive(child) end end return total end -- ========== Clean all known menus (top‑level + all popup menus) ========== local function cleanAllDuplicateMenus() local total = 0 -- 1. Top‑level menu local mainMenu = getMainForm().Menu if mainMenu then total = total + cleanMenuRecursive(mainMenu) end -- 2. AddressList right‑click menu local addrList = getAddressList() if addrList and addrList.PopupMenu then total = total + cleanMenuRecursive(addrList.PopupMenu) end -- 3. FoundList right‑click menu local foundList = getMainForm().Foundlist3 if foundList and foundList.PopupMenu then total = total + cleanMenuRecursive(foundList.PopupMenu) end -- 4. Memory View popup menus local memView = getMemoryViewForm() if memView then if memView.DisassemblerView and memView.DisassemblerView.PopupMenu then total = total + cleanMenuRecursive(memView.DisassemblerView.PopupMenu) end if memView.HexadecimalView and memView.HexadecimalView.PopupMenu then total = total + cleanMenuRecursive(memView.HexadecimalView.PopupMenu) end end -- 5. Lua Engine script editor right‑click menu local luaEngine = getLuaEngine() if luaEngine and luaEngine.mScript and luaEngine.mScript.PopupMenu then total = total + cleanMenuRecursive(luaEngine.mScript.PopupMenu) end -- 6. Lua Script window editor right‑click menu (Table → Lua Script) local mainForm = getMainForm() if mainForm and mainForm.frmLuaTableScript then local frm = mainForm.frmLuaTableScript if frm and frm.mScript and frm.mScript.PopupMenu then total = total + cleanMenuRecursive(frm.mScript.PopupMenu) end end -- 7. Structure viewer popup menus (pcall protected) local ok, structForms = pcall(enumStructureForms) if ok and structForms then for _, form in ipairs(structForms) do if form and form.PopupMenu then total = total + cleanMenuRecursive(form.PopupMenu) end end end if total > 0 then -- Uncomment next line to output to console -- print('[Hot Reload] Cleaned ' .. total .. ' duplicate menu items total') end return total end -- ========== Core reload logic ========== function doReload() deleteAllRegisteredSymbols() local removeList = {} local successCount = 0 local failCount = 0 local totalSelected = 0 for _, e in ipairs(RM.scripts) do if e.checked then totalSelected = totalSelected + 1 local path = e.path local fileOk = fileExists(path) or fileExistsRobust(path) if not fileOk then print('[Hot Reload] File does not exist, will remove: ' .. path) removeList[#removeList + 1] = path failCount = failCount + 1 else local chunk, err = loadfile(path) if not chunk then local f, ioerr = io.open(path, 'r') if f then local content = f:read('*all') f:close() local loadFunc = load or loadstring chunk, err = loadFunc(content, path) else err = ioerr or 'Unable to open file' end end if chunk then local cleanup = _G.__cleanup if type(cleanup) == 'function' then local ok, msg = pcall(cleanup, path) if not ok then print('[Hot Reload] Cleanup function error: ' .. path .. ' -> ' .. tostring(msg)) end end local ok, execErr = pcall(chunk) if ok then successCount = successCount + 1 else print('[Hot Reload] Error executing script: ' .. path .. ' -> ' .. tostring(execErr)) failCount = failCount + 1 end else if CONFIG.SYNTAX_CHECK then print('[Hot Reload] Syntax error or load failure, skipping script: ' .. path) print('[Hot Reload] Error: ' .. tostring(err or 'Unknown error')) else print('[Hot Reload] File exists but cannot be loaded, possible path encoding issue.') print('[Hot Reload] Error: ' .. tostring(err or 'Unknown error')) print('[Hot Reload] Skipping this script: ' .. path) end failCount = failCount + 1 end end end end if totalSelected > 0 then local msg if failCount == 0 then msg = '✅ Reload complete: ' .. totalSelected .. ' script(s), all succeeded!' else msg = '⚠️ Reload complete: ' .. totalSelected .. ' script(s), ' .. successCount .. ' succeeded, ' .. failCount .. ' failed (see console for details)' end showToast(msg, CONFIG.TOAST_DURATION) end if #removeList > 0 then for i = #RM.scripts, 1, -1 do for _, rp in ipairs(removeList) do if RM.scripts[i].path == rp then getSettingsObj().Value[rp] = nil table.remove(RM.scripts, i) break end end end savePathList() rebuildMenu() else savePathList() end -- ★★★ Auto‑clean duplicate menus (with fault tolerance) ★★★ if CONFIG.AUTO_CLEAN_DUPLICATE_MENUS then local ok, err = pcall(cleanAllDuplicateMenus) if not ok then print('[Hot Reload] Menu cleanup failed: ' .. tostring(err)) end end end -- ========== Menu rebuild ========== function rebuildMenu() deleteTopMenus(RM.menuCaption) local mainItems = getMainItems() local mainItem = createMenuItem(nil) mainItem.Caption = RM.menuCaption mainItems.Add(mainItem) local reloadBtn = createMenuItem(mainItem) reloadBtn.Caption = '&Reload Selected Scripts' reloadBtn.OnClick = doReload mainItem.Add(reloadBtn) local sep1 = createMenuItem(mainItem) sep1.Caption = '-' mainItem.Add(sep1) for _, e in ipairs(RM.scripts) do local item = createMenuItem(mainItem) item.Caption = extractFileName(e.path) item.Hint = e.path item.Checked = e.checked item.OnClick = function(sender) sender.Checked = not sender.Checked for _, entry in ipairs(RM.scripts) do if entry.path == sender.Hint then entry.checked = sender.Checked saveCheckState(entry.path, entry.checked) break end end end mainItem.Add(item) end if #RM.scripts > 0 then local sep2 = createMenuItem(mainItem) sep2.Caption = '-' mainItem.Add(sep2) end local addBtn = createMenuItem(mainItem) addBtn.Caption = '&Add Script...' addBtn.OnClick = function() local openDlg = createOpenDialog(mainItem) openDlg.Title = 'Select Lua Script' openDlg.Filter = 'Lua Script (*.lua)|*.lua|All Files (*.*)|*.*' openDlg.Options = 'ofFileMustExist,ofHideReadOnly' local lastDir = getSettingsObj().Value['LastDir'] if not lastDir or lastDir == '' then lastDir = getAutorunPath() if not lastDir or lastDir == '' then lastDir = '' end end openDlg.InitialDir = lastDir if openDlg.Execute() then local path = openDlg.FileName getSettingsObj().Value['LastDir'] = extractFilePath(path) for _, e in ipairs(RM.scripts) do if e.path == path then showMessage('This script is already in the list: ' .. path) return end end if hasNonAscii(path) then local msg = 'The path contains Chinese or non‑ASCII characters, which may cause reload failures!\n' .. 'It is recommended to move the script to a pure‑English path and re‑add it.\n\n' .. 'Add it anyway?' local ans = messageDialog(msg, mtWarning, mbYes, mbNo) if ans ~= mrYes then return end end table.insert(RM.scripts, { path = path, checked = true }) saveCheckState(path, true) savePathList() rebuildMenu() end openDlg = nil end mainItem.Add(addBtn) local delParent = createMenuItem(mainItem) delParent.Caption = '&Delete Script' mainItem.Add(delParent) local delSingle = createMenuItem(delParent) delSingle.Caption = '&Delete Single...' delSingle.OnClick = function() if #RM.scripts == 0 then showMessage('No scripts to delete.') return end local sl = createStringlist() for _, e in ipairs(RM.scripts) do sl.add(extractFileName(e.path) .. ' [' .. e.path .. ']') end local idx, selected = showSelectionList('Delete Script', 'Please select the script to delete:', sl, false, '') if idx and idx >= 0 then local path = RM.scripts[idx + 1].path local ans = messageDialog('Are you sure you want to delete this script?\n' .. path, mtConfirmation, mbYes, mbNo) if ans == mrYes then table.remove(RM.scripts, idx + 1) getSettingsObj().Value[path] = nil savePathList() rebuildMenu() end end end delParent.Add(delSingle) local delAll = createMenuItem(delParent) delAll.Caption = '&Delete All' delAll.OnClick = function() if #RM.scripts == 0 then showMessage('No scripts to delete.') return end local ans = messageDialog( 'Are you sure you want to delete all ' .. #RM.scripts .. ' scripts?\n(This operation will not delete files on disk)', mtConfirmation, mbYes, mbNo ) if ans == mrYes then for _, e in ipairs(RM.scripts) do getSettingsObj().Value[e.path] = nil end RM.scripts = {} savePathList() rebuildMenu() end end delParent.Add(delAll) end -- ========== Hotkey parsing ========== local function parseHotkey(str) local keys = {} for part in string.gmatch(str:lower(), '[^+]+') do local trimmed = part:match('^%s*(.-)%s*$') local code = KEY_MAP[trimmed] if not code then print('[Hot Reload] Unknown key name: ' .. trimmed) return nil end table.insert(keys, code) end return keys end -- ========== Initialization ========== local function init() RM.scripts = {} local paths = loadPathList() for _, p in ipairs(paths) do local checked = loadCheckState(p) table.insert(RM.scripts, { path = p, checked = checked }) end rebuildMenu() if RM.hotkeyObj then if type(RM.hotkeyObj.destroy) == 'function' then RM.hotkeyObj:destroy() end RM.hotkeyObj = nil end if CONFIG.HOTKEY and CONFIG.HOTKEY ~= '' then local keys = parseHotkey(CONFIG.HOTKEY) if keys and #keys > 0 then local unpackFunc = table.unpack or unpack RM.hotkeyObj = createHotkey(doReload, unpackFunc(keys)) if RM.hotkeyObj then print('[Hot Reload] Hotkey registered: ' .. CONFIG.HOTKEY) else print('[Hot Reload] Hotkey registration failed, may conflict with another hotkey.') end else print('[Hot Reload] Hotkey parsing failed, check format (e.g., "Ctrl+Shift+R").') end end end init()