local ffi = require("ffi") local mimgui = require("mimgui") local sampev = require("lib.samp.events") local encoding = require("encoding") encoding.default = "CP1251" local u8 = encoding.UTF8 local vkeys = require("vkeys") if not mimgui.PushStyleVar then mimgui.PushStyleVar = function(idx, val) if type(val) == "number" then return mimgui.PushStyleVarFloat(idx, val) else return mimgui.PushStyleVarVec2(idx, val) end end end ffi.cdef[[ typedef unsigned long DWORD; typedef int BOOL; typedef void* LPVOID; typedef void* HANDLE; typedef void* HMODULE; typedef void* HWND; typedef long LONG; typedef struct { LONG left; LONG top; LONG right; LONG bottom; } RECT; typedef struct { LONG x; LONG y; } POINT; BOOL __stdcall VirtualProtect(LPVOID lpAddress, size_t dwSize, DWORD flNewProtect, DWORD* lpflOldProtect); LPVOID __stdcall VirtualAlloc(LPVOID lpAddress, size_t dwSize, DWORD flAllocationType, DWORD flProtect); BOOL __stdcall VirtualFree(LPVOID lpAddress, size_t dwSize, DWORD dwFreeType); HMODULE __stdcall GetModuleHandleA(const char* lpModuleName); BOOL __stdcall FlushInstructionCache(HANDLE hProcess, const void* lpBaseAddress, size_t dwSize); HANDLE __stdcall GetCurrentProcess(void); HWND __stdcall GetForegroundWindow(void); HWND __stdcall FindWindowA(const char* lpClassName, const char* lpWindowName); BOOL __stdcall GetClientRect(HWND hWnd, RECT* lpRect); BOOL __stdcall GetCursorPos(POINT* lpPoint); BOOL __stdcall ScreenToClient(HWND hWnd, POINT* lpPoint); short __stdcall GetAsyncKeyState(int vKey); short __stdcall GetKeyState(int vKey); int __stdcall MultiByteToWideChar(uint32_t CodePage, uint32_t dwFlags, const char* lpMultiByteStr, int cbMultiByte, wchar_t* lpWideCharStr, int cchWideChar); BOOL __stdcall CreateDirectoryW(const wchar_t* lpPathName, void* lpSecurityAttributes); typedef struct { int Format; int Type; uint32_t Usage; int Pool; int MultiSampleType; uint32_t MultiSampleQuality; uint32_t Width; uint32_t Height; } D3DSURFACE_DESC; int __stdcall D3DXSaveSurfaceToFileA( const char* pDestFile, int DestFormat, void* pSrcSurface, const void* pSrcPalette, const RECT* pSrcRect ); int __stdcall D3DXSaveSurfaceToFileW( const wchar_t* pDestFile, int DestFormat, void* pSrcSurface, const void* pSrcPalette, const RECT* pSrcRect ); #pragma pack(push, 1) typedef struct { uint32_t m_timestamp; char m_szPrefix[28]; char m_szText[144]; char unk_1[64]; uint32_t m_nType; uint32_t m_textColor; uint32_t m_prefixColor; } CChatEntry; typedef struct { int m_nPageSize; uint32_t m_szLastMessage; int m_nMode; bool m_bTimestamp; bool m_bDoesLogExist; char pad_[3]; char m_szLogPath[261]; uint32_t m_pGameUi; uint32_t m_pEditbox; uint32_t m_pScrollbar; uint32_t m_textColor; uint32_t m_infoColor; uint32_t m_debugColor; long m_nWindowBottom; CChatEntry m_entry[100]; uint32_t m_pFontRenderer; void* m_pTextSprite; void* m_pSprite; uint32_t m_pDevice; uint32_t m_bRenderToSurface; uint32_t m_pRenderToSurface; uint32_t m_pTexture; uint32_t m_pSurface; uint32_t m_displayMode[4]; uint32_t pad_2[2]; int m_bRedraw; uint32_t m_nScrollbarPos; long m_nCharHeight; long m_nTimestampWidth; } CChat; #pragma pack(pop) ]] local PAGE_EXECUTE_READWRITE = 0x40 local MEM_RELEASE = 0x8000 local MEM_COMMIT_RESERVE = 0x3000 local knownOriginalBytes = { [0x63D70] = { 0x55, 0x8B, 0xEC, 0x83, 0xE4, 0xF8 }, [0x638A0] = { 0x55, 0x8B, 0xEC, 0x83, 0xE4, 0xF8 }, [0x64010] = { 0x55, 0x56, 0x8B, 0xE9, 0x57 }, [0x64230] = { 0x56, 0x8B, 0xF1, 0x8B, 0x86, 0x1E, 0x01, 0x00, 0x00 }, [0x657E0] = { 0x83, 0xEC, 0x10, 0x56, 0x8B, 0xF1 }, } local sampAddresses = { CHATINPUT_WNDPROC = 0, CHAT_RENDER = 0, CHAT_RENDER_ENTRY = 0, CHAT_ADD_ENTRY = 0, CHAT_GET_FONTFACE = 0, COMMAND_SET_PAGESIZE = 0, COMMAND_SET_PAGESIZE_HINT = 0, FONTSIZE_VALUE = 0, RECALC_FONTSIZE = 0, GAME_SET_CURSOR_MODE = 0, CHAT_DRAW = 0, INPUT_OPEN = 0, GAME_MENU_VISIBLE = 0, GLOBAL_GAME_PTR = 0, OFFSET_GAME_MOUSEMODE = 0, CHAT_SET_PAGESIZE = 0 } local function findUniquePattern(hModule, patternStr, isCode) local baseAddr = tonumber(ffi.cast("uintptr_t", hModule)) if baseAddr == 0 or baseAddr == nil then return 0 end local base = ffi.cast("uint8_t*", baseAddr) local dos = ffi.cast("uint16_t*", base) if dos[0] ~= 0x5A4D then return 0 end -- 'MZ' local e_lfanew = tonumber(ffi.cast("int32_t*", base + 0x3C)[0]) local nt = base + e_lfanew if tonumber(ffi.cast("uint32_t*", nt)[0]) ~= 0x00004550 then return 0 end -- 'PE\0\0' local numSections = tonumber(ffi.cast("uint16_t*", nt + 6)[0]) local optHeaderSize = tonumber(ffi.cast("uint16_t*", nt + 20)[0]) local firstSection = nt + 24 + optHeaderSize local pattern = {} for token in patternStr:gmatch("%S+") do if token == "??" or token == "?" then table.insert(pattern, -1) else table.insert(pattern, tonumber(token, 16)) end end local patLen = #pattern if patLen == 0 then return 0 end local match = 0 for i = 0, numSections - 1 do local sec = firstSection + i * 40 local charact = tonumber(ffi.cast("uint32_t*", sec + 36)[0]) local isRead = (bit.band(charact, 0x40000000) ~= 0) local isExec = (bit.band(charact, 0x20000000) ~= 0) if isRead and (not isCode or isExec) then local vSize = tonumber(ffi.cast("uint32_t*", sec + 8)[0]) local vAddr = tonumber(ffi.cast("uint32_t*", sec + 12)[0]) if vSize >= patLen then local startPtr = base + vAddr local maxPos = vSize - patLen for pos = 0, maxPos do local found = true for j = 1, patLen do local exp = pattern[j] if exp ~= -1 and startPtr[pos + j - 1] ~= exp then found = false break end end if found then if match ~= 0 then return 0 end -- Ambiguous match match = tonumber(ffi.cast("uintptr_t", startPtr + pos)) end end end end end return match end local function resolveSampSymbols() local sampBase = 0 if getModuleHandle then sampBase = getModuleHandle("samp.dll") end if sampBase == 0 and getSampBase then sampBase = getSampBase() end if sampBase == 0 then local hMod = ffi.C.GetModuleHandleA("samp.dll") sampBase = tonumber(ffi.cast("uintptr_t", hMod)) end if sampBase == 0 or sampBase == nil then print("[ChatLua] Error: Failed to find samp.dll module base!") return false end sampAddresses.SAMP_BASE = sampBase local module = ffi.cast("uint8_t*", sampBase) local signatures = { { key = "CHAT_RENDER", pat = "55 8B EC 83 E4 F8 83 EC 70", code = true, optional = false }, { key = "CHAT_RENDER_ENTRY", pat = "55 8B EC 83 E4 F8 81 EC 0C 02 00 00", code = true, optional = false }, { key = "CHAT_ADD_ENTRY", pat = "55 56 8B E9 57 8D BD ?? ?? ?? ?? 8D B5 ?? ?? ?? ?? B9 9C 18 00 00", code = true, optional = false }, { key = "COMMAND_SET_PAGESIZE", pat = "51 56 8B 74 24 ?? 8B C6 8D 50 ?? EB ?? 8D 49 ?? 8A 08 40 84 C9 75 ?? 2B C2 89 44 24 ?? 74 ?? 56 E8 ?? ?? ?? ?? 8B F0 83 C4 04 83 FE ??", code = true, optional = true }, { key = "COMMAND_SET_PAGESIZE_HINT", pat = "70 61 67 65 73 69 7A 65 20 5B 31 30 2D ?? ?? 5D 20 28 6C 69 6E 65 73 29", code = false, optional = true }, { key = "FONTSIZE_VALUE", pat = "A1 ?? ?? ?? ?? 3D 00 04 00 00", code = true, optional = true }, { key = "GAME_SET_CURSOR_MODE", pat = "55 8B EC 8B 45 ?? 83 F8 02", code = true, optional = true }, { key = "CHAT_DRAW", pat = "56 8B F1 8B 86 ?? ?? ?? ?? 85 C0 0F 84 ?? ?? ?? ?? 8B 4E", code = true, optional = false }, { key = "INPUT_OPEN", pat = "83 EC 10 56 8B F1 8B 86", code = true, optional = false }, { key = "GAME_MENU_VISIBLE", pat = "8B 0D ?? ?? ?? ?? 33 C0 85 C9 0F 95 C0", code = true, optional = true } } local function unhookIfPatched(addr, origBytes) if addr == 0 or addr == nil then return end local pBytes = ffi.cast("uint8_t*", addr) if pBytes[0] == 0xE9 or pBytes[0] == 0xFF then local oldProt = ffi.new("DWORD[1]") local pVoid = ffi.cast("void*", addr) ffi.C.VirtualProtect(pVoid, #origBytes, PAGE_EXECUTE_READWRITE, oldProt) for i = 1, #origBytes do pBytes[i - 1] = origBytes[i] end ffi.C.VirtualProtect(pVoid, #origBytes, oldProt[0], oldProt) ffi.C.FlushInstructionCache(ffi.C.GetCurrentProcess(), pVoid, #origBytes) end end for _, sig in ipairs(signatures) do local addr = findUniquePattern(module, sig.pat, sig.code) if addr == 0 then -- Fallback if function was previously hooked before script reload if sig.key == "CHAT_RENDER" then local bodyAddr = findUniquePattern(module, "83 EC 70 53 55 56 8B F1 8B 9E 1E 01 00 00", true) if bodyAddr ~= 0 then addr = bodyAddr - 6 elseif sampBase ~= 0 then addr = sampBase + 0x63D70 end if addr ~= 0 then unhookIfPatched(addr, { 0x55, 0x8B, 0xEC, 0x83, 0xE4, 0xF8 }) end elseif sig.key == "CHAT_RENDER_ENTRY" then local bodyAddr = findUniquePattern(module, "81 EC 0C 02 00 00 53 8B D9 8B 83 A2 63 00 00", true) if bodyAddr ~= 0 then addr = bodyAddr - 6 elseif sampBase ~= 0 then addr = sampBase + 0x638A0 end if addr ~= 0 then unhookIfPatched(addr, { 0x55, 0x8B, 0xEC, 0x83, 0xE4, 0xF8 }) end elseif sig.key == "CHAT_ADD_ENTRY" then local bodyAddr = findUniquePattern(module, "8D BD ?? ?? ?? ?? 8D B5 ?? ?? ?? ?? B9 9C 18 00 00", true) if bodyAddr ~= 0 then addr = bodyAddr - 5 elseif sampBase ~= 0 then addr = sampBase + 0x64010 end if addr ~= 0 then unhookIfPatched(addr, { 0x55, 0x56, 0x8B, 0xE9, 0x57 }) end elseif sig.key == "CHAT_DRAW" then if sampBase ~= 0 then addr = sampBase + 0x64230 end if addr ~= 0 then unhookIfPatched(addr, { 0x56, 0x8B, 0xF1, 0x8B, 0x86, 0x1E, 0x01, 0x00, 0x00 }) end elseif sig.key == "INPUT_OPEN" then if sampBase ~= 0 then addr = sampBase + 0x657E0 end if addr ~= 0 then unhookIfPatched(addr, { 0x83, 0xEC, 0x10, 0x56, 0x8B, 0xF1 }) end end end if addr == 0 then if sig.optional then print(string.format("[ChatLua] Notice: Optional signature %s not found (fallback active)", sig.key)) else print(string.format("[ChatLua] Error: Failed required signature %s (%s)", sig.key, sig.pat)) return false end else local relOff = (sampBase ~= 0) and (addr - sampBase) or 0 if knownOriginalBytes[relOff] then unhookIfPatched(addr, knownOriginalBytes[relOff]) end sampAddresses[sig.key] = addr end end local gameRef = findUniquePattern(module, "8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 85 C0 74 ?? C7 86 D6 63 00 00 00 00 00 00", true) if gameRef == 0 then print("[ChatLua] Error: Failed signature gameReference") return false end sampAddresses.GLOBAL_GAME_PTR = ffi.cast("uint32_t*", gameRef + 2)[0] local inputRef = findUniquePattern(module, "8B 15 ?? ?? ?? ?? 8B 82 E0 14 00 00", true) if inputRef ~= 0 then sampAddresses.GLOBAL_INPUT_PTR = ffi.cast("uint32_t*", inputRef + 2)[0] end local cursorWrite = findUniquePattern(module, "C7 46 ?? 02 00 00 00 5F 5E 5D C2 08 00", true) if cursorWrite == 0 then print("[ChatLua] Error: Failed signature cursorModeWrite") return false end sampAddresses.OFFSET_GAME_MOUSEMODE = ffi.cast("uint8_t*", cursorWrite + 2)[0] if sampAddresses.OFFSET_GAME_MOUSEMODE == 0 then print("[ChatLua] Error: Invalid mouse mode offset") return false end if sampAddresses.COMMAND_SET_PAGESIZE ~= 0 then local pCall = ffi.cast("uint8_t*", sampAddresses.COMMAND_SET_PAGESIZE + 0x3F) if pCall[0] == 0xE8 then local rel = ffi.cast("int32_t*", pCall + 1)[0] sampAddresses.CHAT_SET_PAGESIZE = (sampAddresses.COMMAND_SET_PAGESIZE + 0x3F + 5) + rel end end if sampAddresses.CHAT_SET_PAGESIZE == 0 and sampBase ~= 0 then sampAddresses.CHAT_SET_PAGESIZE = sampBase + 0x636D0 end if sampAddresses.COMMAND_SET_PAGESIZE_HINT == 0 and sampBase ~= 0 then sampAddresses.COMMAND_SET_PAGESIZE_HINT = sampBase + 0xD7AC8 end if sampAddresses.FONTSIZE_VALUE == 0 and sampBase ~= 0 then sampAddresses.FONTSIZE_VALUE = sampBase + 0xB3C60 end return true end local activePatches = {} local function setPatchBytes(address, bytes) local size = #bytes local addrPtr = ffi.cast("void*", address) local pBytes = ffi.cast("uint8_t*", address) local alreadyPatched = true for i = 1, size do if pBytes[i - 1] ~= bytes[i] then alreadyPatched = false break end end if alreadyPatched then return true end local oldProt = ffi.new("DWORD[1]") if ffi.C.VirtualProtect(addrPtr, size, PAGE_EXECUTE_READWRITE, oldProt) == 0 then return false end local orig = {} for i = 1, size do table.insert(orig, pBytes[i - 1]) pBytes[i - 1] = bytes[i] end ffi.C.VirtualProtect(addrPtr, size, oldProt[0], nil) ffi.C.FlushInstructionCache(ffi.C.GetCurrentProcess(), addrPtr, size) table.insert(activePatches, { address = address, orig = orig }) return true end local scriptInitialized = false local pChat = nil local mInput = nil local gameHwnd = nil local offsetX = 0 local offsetY = 0 local dragging = false local lastMouse = { x = 0, y = 0 } local selectedEntry = -1 local selectedEntrySource = "MAIN" local selectedNrpMessage = nil local selectedChatEntryIdentity = nil local lineDragState = { pending = false, active = false, sourceChat = "IC", sourceId = -1, targetChat = "IC", -- "IC", "OOC", "tab_IC", "tab_OOC" targetId = -1, startX = 0, startY = 0, currentX = 0, currentY = 0 } local openContextMenu = false local popupActive = false local editLineActive = false local colorPopupActive = false local editLineBuffer = ffi.new("char[256]") local editPrefixBuffer = ffi.new("char[64]") local editLineColor = ffi.new("float[4]", { 1, 1, 1, 1 }) local editPrefixColor = ffi.new("float[4]", { 1, 1, 1, 1 }) local sliderIsDragging = false local sliderDragOffset = 0 local ChatEntryManager = {} ChatEntryManager.__index = ChatEntryManager function ChatEntryManager.new() return setmetatable({ entries = {} }, ChatEntryManager) end function ChatEntryManager:clear() self.entries = {} end function ChatEntryManager:observe(entryId, x1, y1, x2, y2) for _, e in ipairs(self.entries) do if e.entryId == entryId then e.x1 = math.min(e.x1, x1) e.y1 = math.min(e.y1, y1) e.x2 = math.max(e.x2, x2) e.y2 = math.max(e.y2, y2) return end end table.insert(self.entries, { entryId = entryId, x1 = x1, y1 = y1, x2 = x2, y2 = y2 }) end function ChatEntryManager:getEntryIdByScreenCoords(x, y) for _, e in ipairs(self.entries) do if x >= e.x1 and x < e.x2 and y >= e.y1 and y < e.y2 then return e.entryId end end return -1 end function ChatEntryManager:getByEntryId(entryId) for _, e in ipairs(self.entries) do if e.entryId == entryId then return e end end return nil end function ChatEntryManager:findClosestEntryByY(y) if #self.entries == 0 then return -1 end for _, e in ipairs(self.entries) do if y >= e.y1 and y <= e.y2 then return e.entryId end end local bestDist = 1e9 local bestId = -1 for _, e in ipairs(self.entries) do local midY = (e.y1 + e.y2) * 0.5 local dist = math.abs(y - midY) if dist < bestDist then bestDist = dist bestId = e.entryId end end return bestId end function ChatEntryManager:translate(dx, dy) for _, e in ipairs(self.entries) do e.x1 = e.x1 + dx e.x2 = e.x2 + dx e.y1 = e.y1 + dy e.y2 = e.y2 + dy end end local entryManager = ChatEntryManager.new() local oocEntryManager = ChatEntryManager.new() local nrpEnabled = true local nrpMode = "separate" local activeChatTab = "IC" local chatX = 930 local chatY = 10 local chatScale = 1.0 local chatRows = 10 local maxLineLength = 80 local showHelp = false local showTime = false local afkAsRp = false local visibilityMode = 0 local chatHidden = false local scrollOffset = 0 local fontText = nil local nrpMessages = {} local maxMessages = 100 local ssPlayers = {} local ssPlayersWithIds = {} local pendingDoStar = nil local pendingDoTimer = 0 local lastWasHidden = false local lastWasFromTarget = false local icBackupEntries = ffi.new("CChatEntry[100]") local oocBackupEntries = ffi.new("CChatEntry[100]") local oocEntryMessageRefs = {} local defaultChatX = 0 local defaultChatY = 0 local showSettingsWindow = ffi.new("bool[1]", false) local settingsUI = { waitingTarget = nil, cfgFontSize = ffi.new("int[1]", 0), cfgPageSize = ffi.new("int[1]", 10), cfgStatusMsg = nil, cfgStatusTimer = 0, nrpScale = ffi.new("float[1]", 1.0), nrpRows = ffi.new("int[1]", 10), nrpLength = ffi.new("int[1]", 80), nrpTime = ffi.new("bool[1]", false), nrpCk = ffi.new("bool[1]", false), nrpHelp = ffi.new("bool[1]", false), nrpEnabled = ffi.new("bool[1]", true), nrpPosX = ffi.new("int[1]", 930), nrpPosY = ffi.new("int[1]", 10), ssPlayerBuffer = ffi.new("char[64]"), } local nrpHotkeys = { toggleMod = 0x12, toggleModName = "Alt", toggleKey = 0x30, toggleKeyName = "0", hideMod = 0x12, hideModName = "Alt", hideKey = 0x31, hideKeyName = "1", modeKey = 0x76, modeKeyName = "F7", } local oocMoveDrag = { active = false, startX = 0, startY = 0, offsetX = 0, offsetY = 0 } local tabDetachDrag = { pending = false, active = false, startX = 0, startY = 0 } local function getGameHWND() -- GTA SA 1.0 US static window handle pointer (0xC97C1C) local pWnd = ffi.cast("HWND*", 0xC97C1C) if pWnd ~= nil and pWnd[0] ~= nil and pWnd[0] ~= ffi.cast("HWND", 0) then return pWnd[0] end local hwnd = ffi.C.FindWindowA("Grand theft auto San Andreas", nil) if hwnd ~= nil and hwnd ~= ffi.cast("HWND", 0) then return hwnd end hwnd = ffi.C.FindWindowA(nil, "GTA:SA:MP") if hwnd ~= nil and hwnd ~= ffi.cast("HWND", 0) then return hwnd end return ffi.C.GetForegroundWindow() end local function ARGBToFloat4(color, out) out[3] = bit.band(bit.rshift(color, 24), 0xFF) / 255.0 out[0] = bit.band(bit.rshift(color, 16), 0xFF) / 255.0 out[1] = bit.band(bit.rshift(color, 8), 0xFF) / 255.0 out[2] = bit.band(color, 0xFF) / 255.0 end local function Float4ToARGB(v) local a = bit.band(math.floor(v[3] * 255.0 + 0.5), 0xFF) local r = bit.band(math.floor(v[0] * 255.0 + 0.5), 0xFF) local g = bit.band(math.floor(v[1] * 255.0 + 0.5), 0xFF) local b = bit.band(math.floor(v[2] * 255.0 + 0.5), 0xFF) return bit.bor(bit.lshift(a, 24), bit.lshift(r, 16), bit.lshift(g, 8), b) end local thiscall0, thiscall1, thiscall2 do local thunkBuf = ffi.cast("uint8_t*", ffi.C.VirtualAlloc(nil, 128, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)) sampAddresses.thunkBuf = thunkBuf local c0 = { 0x8B, 0x44, 0x24, 0x04, 0x8B, 0x4C, 0x24, 0x08, 0xFF, 0xD0, 0xC2, 0x08, 0x00 } for i, b in ipairs(c0) do thunkBuf[i - 1] = b end thiscall0 = ffi.cast("int (__stdcall *)(uintptr_t func, uintptr_t this_ptr)", thunkBuf) local c1 = { 0x8B, 0x44, 0x24, 0x04, 0x8B, 0x4C, 0x24, 0x08, 0xFF, 0x74, 0x24, 0x0C, 0xFF, 0xD0, 0xC2, 0x0C, 0x00 } for i, b in ipairs(c1) do thunkBuf[48 + i - 1] = b end thiscall1 = ffi.cast("int (__stdcall *)(uintptr_t func, uintptr_t this_ptr, int arg1)", thunkBuf + 48) local c2 = { 0x8B, 0x44, 0x24, 0x04, 0x8B, 0x4C, 0x24, 0x08, 0xFF, 0x74, 0x24, 0x10, 0xFF, 0x74, 0x24, 0x10, 0xFF, 0xD0, 0xC2, 0x10, 0x00 } for i, b in ipairs(c2) do thunkBuf[16 + i - 1] = b end thiscall2 = ffi.cast("int (__stdcall *)(uintptr_t func, uintptr_t this_ptr, int arg1, int arg2)", thunkBuf + 16) end local function getIniPath() local configDir = getWorkingDirectory() .. "\\config" if doesDirectoryExist and not doesDirectoryExist(configDir) then pcall(createDirectory, configDir) end return configDir .. "\\ChatLua.ini" end local function setIniKeyValue(section, key, value) local cfgPath = getIniPath() local lines = {} local f = io.open(cfgPath, "r") local curSec = nil local secFound = false local keyFound = false if f then for line in f:lines() do local s = line:match("^%[([^%]]+)%]") if s then if curSec == section and not keyFound then table.insert(lines, key .. " = " .. tostring(value)) keyFound = true end curSec = s if curSec == section then secFound = true end table.insert(lines, line) else if curSec == section then local k = line:match("^([%w_]+)%s*=") if k == key then table.insert(lines, key .. " = " .. tostring(value)) keyFound = true else table.insert(lines, line) end else table.insert(lines, line) end end end f:close() end if not secFound then table.insert(lines, "[" .. section .. "]") table.insert(lines, key .. " = " .. tostring(value)) elseif not keyFound then table.insert(lines, key .. " = " .. tostring(value)) end local out = io.open(cfgPath, "w") if out then for _, l in ipairs(lines) do out:write(l .. "\n") end out:close() return true end return false end local function loadPosition() local cfgPath = getIniPath() local f = io.open(cfgPath, "r") if f then local inPos = false for line in f:lines() do local s = line:match("^%[([^%]]+)%]") if s then inPos = (s == "Position") elseif inPos then local k, v = line:match("^([%w_]+)%s*=%s*(.+)") if k == "defaultX" then defaultChatX = tonumber(v) or 0 elseif k == "defaultY" then defaultChatY = tonumber(v) or 0 end end end f:close() end offsetX = defaultChatX offsetY = defaultChatY end local function saveDefaultPosition(x, y) defaultChatX = x defaultChatY = y setIniKeyValue("Position", "defaultX", tostring(x)) setIniKeyValue("Position", "defaultY", tostring(y)) end local chatUpdate = nil local function getInput() if mInput ~= nil and mInput ~= ffi.NULL then return mInput end if sampAddresses.GLOBAL_INPUT_PTR and sampAddresses.GLOBAL_INPUT_PTR ~= 0 then local ptr = ffi.cast("uintptr_t*", sampAddresses.GLOBAL_INPUT_PTR)[0] if ptr ~= 0 then mInput = ffi.cast("void*", ptr) return mInput end end return nil end local function relocateDialog() -- Main DXUT dialog via pChat.m_pGameUi (universal across all SA:MP versions) if pChat ~= nil and pChat ~= ffi.NULL and pChat.m_pGameUi ~= 0 then local dialog = ffi.cast("uint8_t*", pChat.m_pGameUi) ffi.cast("int*", dialog + 0x116)[0] = offsetX ffi.cast("int*", dialog + 0x11A)[0] = offsetY end local input = getInput() if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) local inputDialogPtr = ffi.cast("uint8_t**", inputBytes + 4)[0] if inputDialogPtr ~= nil and inputDialogPtr ~= ffi.NULL then ffi.cast("int*", inputDialogPtr + 0x116)[0] = offsetX ffi.cast("int*", inputDialogPtr + 0x11A)[0] = offsetY end local editbox = ffi.cast("uint8_t**", inputBytes + 8)[0] if editbox ~= nil and editbox ~= ffi.NULL and pChat ~= nil then ffi.cast("int*", editbox + 8)[0] = 0x28 ffi.cast("int*", editbox + 0x0C)[0] = pChat.m_nWindowBottom local vtbl = ffi.cast("uintptr_t**", editbox)[0] if vtbl ~= nil and vtbl ~= ffi.NULL and thiscall0 ~= nil then local updateRects = vtbl[19] -- +0x4c: CDXUTEditBox::UpdateRects if updateRects ~= 0 then thiscall0(updateRects, tonumber(ffi.cast("uintptr_t", editbox))) end end end end end local function movePosition(dx, dy) if pChat == nil or gameHwnd == nil then return end local clientRect = ffi.new("RECT[1]") if ffi.C.GetClientRect(gameHwnd, clientRect) == 0 then return end local screenW = clientRect[0].right local screenH = clientRect[0].bottom local contentRight = 600 local input = getInput() if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) local editboxPtr = ffi.cast("uint8_t**", inputBytes + 8)[0] if editboxPtr ~= nil and editboxPtr ~= ffi.NULL then local editboxW = ffi.cast("int*", editboxPtr + 16)[0] contentRight = math.max(contentRight, 100 + editboxW) end end if pChat.m_pGameUi ~= 0 then local dialog = ffi.cast("uint8_t*", pChat.m_pGameUi) local dialogW = ffi.cast("int*", dialog + 0x11E)[0] if dialogW > 0 and dialogW < screenW then contentRight = math.max(contentRight, dialogW) end end local minX = -contentRight + 22 local maxX = screenW - 22 local minY = -pChat.m_nWindowBottom - 30 local maxY = screenH - 30 local nextX = math.max(minX, math.min(math.max(minX, maxX), offsetX + dx)) local nextY = math.max(minY, math.min(math.max(minY, maxY), offsetY + dy)) entryManager:translate(nextX - offsetX, nextY - offsetY) offsetX = nextX offsetY = nextY if offsetX ~= 0 or offsetY ~= 0 then pChat.m_bRenderToSurface = 0 else pChat.m_bRenderToSurface = 1 end relocateDialog() end local function isInputOpen() if not scriptInitialized then return false end local input = getInput() if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) return ffi.cast("int*", inputBytes + 0x14E0)[0] ~= 0 end return false end local function getMoveHandleRect() if pChat == nil then return 0, 0, 0, 0 end local left = 6 + offsetX local top = pChat.m_nWindowBottom + offsetY + 5 if gameHwnd ~= nil then local clientRect = ffi.new("RECT[1]") if ffi.C.GetClientRect(gameHwnd, clientRect) ~= 0 then local maxR = math.max(0, clientRect[0].right - 22) local maxB = math.max(0, clientRect[0].bottom - 30) left = math.max(0, math.min(maxR, left)) top = math.max(0, math.min(maxB, top)) end end return left, top, left + 22, top + 30 end local function isMoveHandleHovered() if gameHwnd == nil or pChat == nil then return false end local pt = ffi.new("POINT[1]") if ffi.C.GetCursorPos(pt) == 0 or ffi.C.ScreenToClient(gameHwnd, pt) == 0 then return false end local l, t, r, b = getMoveHandleRect() return pt[0].x >= l and pt[0].x <= r and pt[0].y >= t and pt[0].y <= b end local function isMouseNearChat(mx, my) if pChat == nil or pChat == ffi.NULL then return false end if dragging then return true end local hl, ht, hr, hb = getMoveHandleRect() if mx >= hl - 25 and mx <= hr + 35 and my >= ht - 25 and my <= hb + 25 then return true end local inputLeft = 38 + offsetX local inputTop = pChat.m_nWindowBottom + offsetY + 5 if mx >= inputLeft - 35 and mx <= inputLeft + 30 and my >= inputTop - 10 and my <= inputTop + 40 then return true end return false end local function getSampCursorMode() if not scriptInitialized or sampAddresses.GLOBAL_GAME_PTR == 0 or sampAddresses.OFFSET_GAME_MOUSEMODE == 0 then return 0 end local cgame = ffi.cast("uintptr_t*", sampAddresses.GLOBAL_GAME_PTR)[0] if cgame == 0 then return 0 end return ffi.cast("int*", cgame + sampAddresses.OFFSET_GAME_MOUSEMODE)[0] end local function setSampCursorMode(mode) if sampAddresses.GLOBAL_GAME_PTR ~= 0 and sampAddresses.GAME_SET_CURSOR_MODE ~= 0 then local cgame = ffi.cast("uintptr_t*", sampAddresses.GLOBAL_GAME_PTR)[0] if cgame ~= 0 and getSampCursorMode() ~= mode then thiscall2(sampAddresses.GAME_SET_CURSOR_MODE, cgame, mode, 0) return end end if isSampLoaded and isSampLoaded() and isSampAvailable and isSampAvailable() then if sampToggleCursor then pcall(sampToggleCursor, mode ~= 0) end end end local function isGTAMenuActive() if isPauseMenuActive and isPauseMenuActive() then return true end if not scriptInitialized or sampAddresses.GLOBAL_GAME_PTR == 0 or sampAddresses.GAME_MENU_VISIBLE == 0 then return false end local cgame = ffi.cast("uintptr_t*", sampAddresses.GLOBAL_GAME_PTR)[0] if cgame == 0 then return false end return thiscall0(sampAddresses.GAME_MENU_VISIBLE, cgame) ~= 0 end local function getSampFontSize() if sampAddresses.FONTSIZE_VALUE == 0 then return 12 end return ffi.cast("int(*)()", sampAddresses.FONTSIZE_VALUE)() end local function getSampFontSizeParam() return (getSampFontSize() - 20) / 2 end chatUpdate = function() if pChat == nil then return end pChat.m_bRedraw = 1 if sampAddresses.CHAT_RENDER ~= 0 then thiscall0(sampAddresses.CHAT_RENDER, tonumber(ffi.cast("uintptr_t", pChat))) end end local function sampDeleteChatLine(id) if pChat == nil or id < 0 or id >= 100 then return end local entrySize = ffi.sizeof("CChatEntry") for i = id, 1, -1 do ffi.copy(pChat.m_entry + i, pChat.m_entry + (i - 1), entrySize) end ffi.fill(pChat.m_entry, entrySize) chatUpdate() end local function sampDeleteChatLineFromBuf(buf, id) local entrySize = ffi.sizeof("CChatEntry") for i = id, 1, -1 do ffi.copy(buf + i, buf + (i - 1), entrySize) end ffi.fill(buf, entrySize) end local function sampDeleteChatLineAll() if pChat == nil or pChat == ffi.NULL then return end local entrySize = ffi.sizeof("CChatEntry") ffi.fill(pChat.m_entry, 100 * entrySize) if nrpMode == "tab" then if activeChatTab == "OOC" then if oocBackupEntries ~= nil then ffi.fill(oocBackupEntries, 100 * entrySize) end nrpMessages = {} oocEntryMessageRefs = {} else if icBackupEntries ~= nil then ffi.fill(icBackupEntries, 100 * entrySize) end end else if icBackupEntries ~= nil then ffi.fill(icBackupEntries, 100 * entrySize) end end chatUpdate() end local function syncNrpMessageOrderFromOocEntries() local orderedRefs = {} local seen = {} for i = 1, 100 do local message = oocEntryMessageRefs[i] if message and not seen[message] then seen[message] = true orderedRefs[#orderedRefs + 1] = message end end local nextRef = 1 for i, message in ipairs(nrpMessages) do if seen[message] then nrpMessages[i] = orderedRefs[nextRef] nextRef = nextRef + 1 end end end local function moveChatEntry(fromIndex, toIndex) if pChat == nil or pChat == ffi.NULL or fromIndex < 0 or fromIndex >= 100 or toIndex < 0 or toIndex >= 100 then return end if fromIndex == toIndex then return end local entrySize = ffi.sizeof("CChatEntry") local temp = ffi.new("CChatEntry") ffi.copy(temp, pChat.m_entry + fromIndex, entrySize) if fromIndex < toIndex then for i = fromIndex, toIndex - 1 do ffi.copy(pChat.m_entry + i, pChat.m_entry + (i + 1), entrySize) end else for i = fromIndex, toIndex + 1, -1 do ffi.copy(pChat.m_entry + i, pChat.m_entry + (i - 1), entrySize) end end ffi.copy(pChat.m_entry + toIndex, temp, entrySize) if nrpMode == "tab" then if activeChatTab == "OOC" then local messageRef = oocEntryMessageRefs[fromIndex + 1] if fromIndex < toIndex then for i = fromIndex, toIndex - 1 do oocEntryMessageRefs[i + 1] = oocEntryMessageRefs[i + 2] end else for i = fromIndex, toIndex + 1, -1 do oocEntryMessageRefs[i + 1] = oocEntryMessageRefs[i] end end oocEntryMessageRefs[toIndex + 1] = messageRef syncNrpMessageOrderFromOocEntries() end local backup = (activeChatTab == "OOC") and oocBackupEntries or icBackupEntries ffi.copy(backup, pChat.m_entry, 100 * entrySize) end chatUpdate() end local function toArgb(col) if not col or col == 0 then return 0xFFFFFFFF end local a = bit.band(col, 0xFF) if a < 32 then a = 0xFF end local r = bit.band(bit.rshift(col, 24), 0xFF) local g = bit.band(bit.rshift(col, 16), 0xFF) local b = bit.band(bit.rshift(col, 8), 0xFF) return bit.bor(bit.lshift(a, 24), bit.lshift(r, 16), bit.lshift(g, 8), b) end local function pushToIcBackup(color, text) local entrySize = ffi.sizeof("CChatEntry") for i = 0, 98 do ffi.copy(icBackupEntries + i, icBackupEntries + (i + 1), entrySize) end local target = icBackupEntries + 99 ffi.fill(target, entrySize) target.m_nType = 8 target.m_timestamp = os.time() local argb = toArgb(color) target.m_textColor = argb target.m_prefixColor = argb local raw = text:sub(1, 143) ffi.copy(target.m_szText, raw, #raw) target.m_szText[#raw] = 0 end local function pushArgbToIcBackup(argb, text) local entrySize = ffi.sizeof("CChatEntry") for i = 0, 98 do ffi.copy(icBackupEntries + i, icBackupEntries + (i + 1), entrySize) end local target = icBackupEntries + 99 ffi.fill(target, entrySize) target.m_nType = 8 target.m_timestamp = os.time() local col = (argb and argb ~= 0) and argb or 0xFFFFFFFF target.m_textColor = col target.m_prefixColor = col local raw = text:sub(1, 143) ffi.copy(target.m_szText, raw, #raw) target.m_szText[#raw] = 0 end local function transferOocTabToIc(entryId) if pChat == nil or pChat == ffi.NULL or entryId < 0 or entryId >= 100 then return end local entry = pChat.m_entry[entryId] if entry.m_textColor == 0 then return end local textBuf = ffi.string(entry.m_szText) if #textBuf == 0 then return end local prefix = "" if entry.m_prefixColor ~= 0 and entry.m_szPrefix[0] ~= 0 then prefix = ffi.string(entry.m_szPrefix) end local text = (#prefix > 0) and (prefix .. " " .. textBuf) or textBuf local col = entry.m_textColor for i = #nrpMessages, 1, -1 do if nrpMessages[i].text == text or nrpMessages[i].text == textBuf then table.remove(nrpMessages, i) break end end pushArgbToIcBackup(col, text) -- Remove from current OOC view (pChat.m_entry = OOC buffer right now) sampDeleteChatLine(entryId) local entrySize = ffi.sizeof("CChatEntry") ffi.copy(oocBackupEntries, pChat.m_entry, 100 * entrySize) if chatUpdate then chatUpdate() end end local function pushToOocEntries(argb, text) local col = (argb and argb ~= 0) and argb or 0xFFFFFFFF local entrySize = ffi.sizeof("CChatEntry") for i = 0, 98 do ffi.copy(oocBackupEntries + i, oocBackupEntries + (i + 1), entrySize) end for i = 1, 99 do oocEntryMessageRefs[i] = oocEntryMessageRefs[i + 1] end oocEntryMessageRefs[100] = nil local messageRef = nrpMessages[#nrpMessages] if not messageRef or messageRef.text ~= text then messageRef = nil for i = #nrpMessages, 1, -1 do if nrpMessages[i].text == text then messageRef = nrpMessages[i] break end end end oocEntryMessageRefs[100] = messageRef local target = oocBackupEntries + 99 ffi.fill(target, entrySize) target.m_nType = 8 target.m_timestamp = os.time() target.m_textColor = col target.m_prefixColor = col local raw = text:sub(1, 143) ffi.copy(target.m_szText, raw, #raw) target.m_szText[#raw] = 0 if nrpEnabled and nrpMode == "tab" and activeChatTab == "OOC" and pChat ~= nil and pChat ~= ffi.NULL then for i = 0, 98 do ffi.copy(pChat.m_entry + i, pChat.m_entry + (i + 1), entrySize) end local ct = pChat.m_entry + 99 ffi.fill(ct, entrySize) ct.m_nType = 8 ct.m_timestamp = os.time() ct.m_textColor = col ct.m_prefixColor = col ffi.copy(ct.m_szText, raw, #raw) ct.m_szText[#raw] = 0 if chatUpdate then chatUpdate() end end end local function switchChatTab(newTab) if newTab == activeChatTab then return end if pChat == nil or pChat == ffi.NULL then return end local entrySize = ffi.sizeof("CChatEntry") selectedEntry = -1 if newTab == "OOC" then ffi.copy(icBackupEntries, pChat.m_entry, 100 * entrySize) ffi.copy(pChat.m_entry, oocBackupEntries, 100 * entrySize) activeChatTab = "OOC" if chatUpdate then chatUpdate() end elseif newTab == "IC" then ffi.copy(oocBackupEntries, pChat.m_entry, 100 * entrySize) ffi.copy(pChat.m_entry, icBackupEntries, 100 * entrySize) activeChatTab = "IC" if chatUpdate then chatUpdate() end end end local function rebuildOocBackupFromMessages() local entrySize = ffi.sizeof("CChatEntry") ffi.fill(oocBackupEntries, 100 * entrySize) oocEntryMessageRefs = {} local total = #nrpMessages local startIdx = math.max(1, total - 99) local slot = 100 - (total - startIdx + 1) for i = startIdx, total do local msg = nrpMessages[i] local target = oocBackupEntries + slot oocEntryMessageRefs[slot + 1] = msg target.m_nType = 8 target.m_timestamp = msg.timestamp or os.time() local col = (msg.baseColor and msg.baseColor ~= 0) and msg.baseColor or 0xFFFFFFFF if bit.band(col, 0xFF000000) == 0 then col = bit.bor(col, 0xFF000000) end target.m_textColor = col target.m_prefixColor = col local raw = (msg.text or ""):sub(1, 143) if #raw > 0 then ffi.copy(target.m_szText, raw, #raw) target.m_szText[#raw] = 0 end slot = slot + 1 end end local function getSelectedNrpMessageIndex() if selectedEntry < 0 or (selectedEntrySource ~= "NRP" and selectedEntrySource ~= "TAB_OOC") then return nil end if selectedNrpMessage ~= nil then for i, msg in ipairs(nrpMessages) do if msg == selectedNrpMessage then return i end end return nil end if selectedEntrySource == "NRP" then return (selectedEntry > 0 and nrpMessages[selectedEntry]) and selectedEntry or nil end return nil end local function captureSelectedNrpMessage() selectedNrpMessage = nil selectedChatEntryIdentity = nil if selectedEntrySource == "NRP" then local index = getSelectedNrpMessageIndex() if index then selectedNrpMessage = nrpMessages[index] end elseif selectedEntrySource == "TAB_OOC" then selectedNrpMessage = oocEntryMessageRefs[selectedEntry + 1] end if (selectedEntrySource == "MAIN" or selectedEntrySource == "TAB_OOC") and pChat ~= nil and pChat ~= ffi.NULL and selectedEntry >= 0 and selectedEntry < 100 then selectedChatEntryIdentity = ffi.string( ffi.cast("const char*", pChat.m_entry + selectedEntry), ffi.sizeof("CChatEntry")) end end local function getSelectedChatEntryIndex() if selectedEntry < 0 or (selectedEntrySource ~= "MAIN" and selectedEntrySource ~= "TAB_OOC") or pChat == nil or pChat == ffi.NULL then return nil end if selectedEntrySource == "TAB_OOC" and selectedNrpMessage ~= nil then for i = 1, 100 do if oocEntryMessageRefs[i] == selectedNrpMessage then selectedEntry = i - 1 return i - 1 end end end if selectedChatEntryIdentity == nil then return nil end local entrySize = ffi.sizeof("CChatEntry") for i = 0, 99 do local identity = ffi.string(ffi.cast("const char*", pChat.m_entry + i), entrySize) if identity == selectedChatEntryIdentity then selectedEntry = i return i end end return nil end local function refreshOocMessageBuffer() rebuildOocBackupFromMessages() if nrpMode == "tab" and activeChatTab == "OOC" and pChat ~= nil and pChat ~= ffi.NULL then ffi.copy(pChat.m_entry, oocBackupEntries, 100 * ffi.sizeof("CChatEntry")) if chatUpdate then chatUpdate() end end end local function transferLineIcToOoc(icIndex) if pChat == nil or pChat == ffi.NULL or icIndex < 0 or icIndex >= 100 then return end local entry = pChat.m_entry[icIndex] if entry.m_textColor == 0 then return end local textBuf = ffi.string(entry.m_szText) if #textBuf == 0 then return end local prefix = "" if entry.m_prefixColor ~= 0 and entry.m_szPrefix[0] ~= 0 then prefix = ffi.string(entry.m_szPrefix) end local text = (#prefix > 0) and (prefix .. " " .. textBuf) or textBuf local col = entry.m_textColor table.insert(nrpMessages, { text = text, time = os.date('%H:%M:%S'), baseColor = col, isWelcome = false }) if #nrpMessages > maxMessages then table.remove(nrpMessages, 1) end rebuildOocBackupFromMessages() -- In tab mode pChat.m_entry is the active tab's buffer if nrpMode == "tab" then local entrySize = ffi.sizeof("CChatEntry") sampDeleteChatLine(icIndex) -- Also remove from icBackupEntries so it doesn't reappear on tab switch sampDeleteChatLineFromBuf(icBackupEntries, icIndex) -- If OOC tab is currently active, update pChat.m_entry from oocBackupEntries too if activeChatTab == "OOC" then ffi.copy(pChat.m_entry, oocBackupEntries, 100 * entrySize) end else sampDeleteChatLine(icIndex) end if chatUpdate then chatUpdate() end end local function pushToIcBuffer(buf, text, col) local entrySize = ffi.sizeof("CChatEntry") for i = 0, 98 do ffi.copy(buf + i, buf + (i + 1), entrySize) end local target = buf + 99 ffi.fill(target, entrySize) target.m_timestamp = os.time() target.m_nType = 8 target.m_textColor = col target.m_prefixColor = 0 local raw = text:sub(1, 143) if #raw > 0 then ffi.copy(target.m_szText, raw, #raw) target.m_szText[#raw] = 0 end end local function transferLineOocToIc(oocIndex) if oocIndex < 1 or oocIndex > #nrpMessages then return end local msg = nrpMessages[oocIndex] if not msg or not msg.text or #msg.text == 0 then return end local text = msg.text local col = msg.baseColor or 0xFFFFFFFF table.remove(nrpMessages, oocIndex) rebuildOocBackupFromMessages() local entrySize = ffi.sizeof("CChatEntry") if nrpMode == "tab" then if activeChatTab == "IC" then if sampAddChatMessage then sampAddChatMessage(text, col) else pushToIcBuffer(pChat.m_entry, text, col) end ffi.copy(icBackupEntries, pChat.m_entry, 100 * entrySize) else pushToIcBuffer(icBackupEntries, text, col) if pChat ~= nil and pChat ~= ffi.NULL then ffi.copy(pChat.m_entry, oocBackupEntries, 100 * entrySize) end end else if sampAddChatMessage then sampAddChatMessage(text, col) else pushToIcBuffer(pChat.m_entry, text, col) end end if chatUpdate then chatUpdate() end end local function getMainChatRect() local x1 = 25 + offsetX local charH = (pChat ~= nil and pChat.m_nCharHeight > 0) and pChat.m_nCharHeight or 18 local pageSize = (pChat ~= nil and pChat.m_nPageSize > 0) and pChat.m_nPageSize or 10 local chatH = math.max(120, pageSize * charH + 20) local y2 = (pChat ~= nil and pChat.m_nWindowBottom or 250) + offsetY + 45 local y1 = y2 - chatH - 45 local chatW = 650 local input = getInput() if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) local editboxPtr = ffi.cast("uint8_t**", inputBytes + 8)[0] if editboxPtr ~= nil and editboxPtr ~= ffi.NULL then local editboxW = ffi.cast("int*", editboxPtr + 16)[0] if editboxW > 200 then chatW = math.max(chatW, editboxW + 60) end end end local x2 = x1 + chatW return x1, y1, x2, y2 end local function isPointInsideMainChat(x, y) if pChat == nil or pChat == ffi.NULL then return false end local x1, y1, x2, y2 = getMainChatRect() return (x >= x1 - 15 and x <= x2 + 25 and y >= y1 - 15 and y <= y2 + 15) end local function isPointInsideOocChat(x, y) if not nrpEnabled or nrpMode ~= "separate" then return false end local lh = math.floor(18 * chatScale) local w = math.floor(450 * chatScale) local h = math.max(60, chatRows * lh) return (x >= chatX - 15 and x <= chatX + w + 15 and y >= chatY - 25 and y <= chatY + h + 15) end local tabRect_IC = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } local tabRect_OOC = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } local function updateTabRectsFallback() if pChat == nil or not isInputOpen() or nrpMode ~= "tab" then return end local sliderExtraW = 150.0 local sliderX = 42 + offsetX local input = getInput() local ebH = 40 if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) local editbox = ffi.cast("uint8_t**", inputBytes + 8)[0] if editbox ~= nil and editbox ~= ffi.NULL then local h = ffi.cast("int*", editbox + 20)[0] if h > 10 and h < 200 then ebH = h end end end local sliderY = pChat.m_nWindowBottom + offsetY + ebH + 4 if gameHwnd ~= nil then local clientRect = ffi.new("RECT[1]") if ffi.C.GetClientRect(gameHwnd, clientRect) ~= 0 then local maxW = clientRect[0].right local maxH = clientRect[0].bottom sliderX = math.max(0, math.min(maxW - 510 - sliderExtraW, sliderX)) sliderY = math.max(0, math.min(maxH - 45, sliderY)) end end local curX = sliderX + 8.0 local curY = sliderY + 5.0 tabRect_IC.x1 = curX tabRect_IC.y1 = curY tabRect_IC.x2 = curX + 64.0 tabRect_IC.y2 = curY + 22.0 curX = curX + 64.0 + 4.0 tabRect_OOC.x1 = curX tabRect_OOC.y1 = curY tabRect_OOC.x2 = curX + 70.0 tabRect_OOC.y2 = curY + 22.0 end local function isPointInsideTabIC(x, y) if not (nrpEnabled and nrpMode == "tab" and isInputOpen()) then return false end if tabRect_IC.x2 == 0 then updateTabRectsFallback() end return x >= (tabRect_IC.x1 - 4) and x <= (tabRect_IC.x2 + 4) and y >= (tabRect_IC.y1 - 4) and y <= (tabRect_IC.y2 + 4) end local function isPointInsideTabOOC(x, y) if not (nrpEnabled and nrpMode == "tab" and isInputOpen()) then return false end if tabRect_OOC.x2 == 0 then updateTabRectsFallback() end return x >= (tabRect_OOC.x1 - 4) and x <= (tabRect_OOC.x2 + 4) and y >= (tabRect_OOC.y1 - 4) and y <= (tabRect_OOC.y2 + 4) end local function getSampCfgPath() if getFolderPath then local myDocs = getFolderPath(0x05) if myDocs and #myDocs > 0 then local p = myDocs .. "\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg" local f = io.open(p, "r") if f then f:close(); return p end end end local userProfile = os.getenv("USERPROFILE") if userProfile and #userProfile > 0 then local candidates = { userProfile .. "\\Documents\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg", userProfile .. "\\OneDrive\\Documents\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg", userProfile .. "\\OneDrive\\Документы\\GTA San Andreas User Files\\SAMP\\sa-mp.cfg", } for _, p in ipairs(candidates) do local f = io.open(p, "r") if f then f:close(); return p end end end return nil end local function readSampCfg() local cfg = getSampCfgPath() local res = { fontsize = 0, pagesize = 10 } if cfg then local f = io.open(cfg, "r") if f then for line in f:lines() do local fs = line:match("^fontsize%s*=%s*([%-%d]+)") if fs then res.fontsize = tonumber(fs) or 0 end local ps = line:match("^pagesize%s*=%s*(%d+)") if ps then res.pagesize = tonumber(ps) or 10 end end f:close() end end return res end local function writeSampCfgValue(key, val) local cfgPath = getSampCfgPath() if not cfgPath then return false end local lines = {} local found = false local f = io.open(cfgPath, "r") if f then for line in f:lines() do if line:match("^" .. key .. "%s*=") then table.insert(lines, key .. "=" .. tostring(val)) found = true else table.insert(lines, line) end end f:close() end if not found then table.insert(lines, key .. "=" .. tostring(val)) end local out = io.open(cfgPath, "w") if out then for _, l in ipairs(lines) do out:write(l .. "\n") end out:close() return true end return false end local function readDefaultPageSize() local sampCfg = readSampCfg() if sampCfg.pagesize >= 1 and sampCfg.pagesize <= 50 then return sampCfg.pagesize end if pChat and pChat.m_nPageSize >= 1 and pChat.m_nPageSize <= 50 then return pChat.m_nPageSize end return 10 end local currentPageSize = ffi.new("int[1]", 10) local function applyPageSize(newSize) newSize = math.max(1, math.min(50, math.floor(tonumber(newSize) or 10))) if pChat == nil or pChat == ffi.NULL then return end if pChat.m_nPageSize == newSize then return end if sampAddresses.CHAT_SET_PAGESIZE ~= 0 then thiscall1(sampAddresses.CHAT_SET_PAGESIZE, tonumber(ffi.cast("uintptr_t", pChat)), newSize) chatUpdate() elseif sampProcessChatInput then sampProcessChatInput(string.format("/pagesize %d", newSize)) else pChat.m_nPageSize = newSize chatUpdate() end relocateDialog() end local function closeEditLine() editLineActive = false setSampCursorMode(0) selectedEntry = -1 selectedChatEntryIdentity = nil selectedNrpMessage = nil end local screenChatConfig = { key1 = 0x71, key2 = 0, key1Name = "F2", key2Name = "", } local d3dxLib = nil local function getD3DX() if d3dxLib ~= nil then return d3dxLib end local candidates = { "d3dx9_43", "d3dx9_42", "d3dx9_41", "d3dx9_40", "d3dx9_39", "d3dx9_38", "d3dx9_25" } for _, name in ipairs(candidates) do local ok, lib = pcall(ffi.load, name) if ok and lib then d3dxLib = lib return d3dxLib end end return nil end local function toWide(str) local len = ffi.C.MultiByteToWideChar(65001, 0, str, -1, nil, 0) if len <= 0 then len = ffi.C.MultiByteToWideChar(1251, 0, str, -1, nil, 0) end if len <= 0 then len = #str + 1 end local buf = ffi.new("wchar_t[?]", len + 2) local ret = ffi.C.MultiByteToWideChar(65001, 0, str, -1, buf, len) if ret <= 0 then ffi.C.MultiByteToWideChar(1251, 0, str, -1, buf, len) end return buf end local function releaseCom(ptr) if ptr ~= nil and ptr ~= ffi.NULL then local vtbl = ffi.cast("void***", ptr)[0] local release = ffi.cast("uint32_t(__stdcall*)(void*)", vtbl[2]) pcall(release, ptr) end end local function getKeyName(vkey) if not vkey or vkey == 0 then return "" end local reverseMap = { [0x70] = "F1", [0x71] = "F2", [0x72] = "F3", [0x73] = "F4", [0x74] = "F5", [0x75] = "F6", [0x76] = "F7", [0x77] = "F8", [0x78] = "F9", [0x79] = "F10", [0x7A] = "F11", [0x7B] = "F12", [0x10] = "Shift", [0xA0] = "LShift", [0xA1] = "RShift", [0x11] = "Ctrl", [0xA2] = "LCtrl", [0xA3] = "RCtrl", [0x12] = "Alt", [0xA4] = "LAlt", [0xA5] = "RAlt", [0x20] = "Space", [0x09] = "Tab", [0x0D] = "Enter", [0x1B] = "Esc", [0x08] = "Backspace", [0x2E] = "Delete", [0x24] = "Home", [0x23] = "End", [0x21] = "PageUp", [0x22] = "PageDown", [0x2C] = "PrintScreen", [0x2D] = "Insert", } if reverseMap[vkey] then return reverseMap[vkey] end if vkeys and vkeys.id_to_name then local name = vkeys.id_to_name(vkey) if name and #name > 0 then return name end end if vkey >= 0x30 and vkey <= 0x39 then return string.char(vkey) elseif vkey >= 0x41 and vkey <= 0x5A then return string.char(vkey) end return string.format("Key_%02X", vkey) end local function parseKeyString(str) if str == nil then return 0, "" end str = str:match("^%s*(.-)%s*$") if #str == 0 then return 0, "" end local upper = str:upper() if upper:sub(1, 3) == "VK_" then upper = upper:sub(4) end local map = { ["F1"] = 0x70, ["F2"] = 0x71, ["F3"] = 0x72, ["F4"] = 0x73, ["F5"] = 0x74, ["F6"] = 0x75, ["F7"] = 0x76, ["F8"] = 0x77, ["F9"] = 0x78, ["F10"] = 0x79, ["F11"] = 0x7A, ["F12"] = 0x7B, ["SHIFT"] = 0x10, ["LSHIFT"] = 0xA0, ["RSHIFT"] = 0xA1, ["CONTROL"] = 0x11, ["CTRL"] = 0x11, ["LCONTROL"] = 0xA2, ["LCTRL"] = 0xA2, ["RCONTROL"] = 0xA3, ["RCTRL"] = 0xA3, ["ALT"] = 0x12, ["MENU"] = 0x12, ["LALT"] = 0xA4, ["RALT"] = 0xA5, ["SPACE"] = 0x20, ["TAB"] = 0x09, ["RETURN"] = 0x0D, ["ENTER"] = 0x0D, ["ESCAPE"] = 0x1B, ["ESC"] = 0x1B, ["SNAPSHOT"] = 0x2C, ["PRINTSCREEN"] = 0x2C, ["PRINT"] = 0x2C, ["INSERT"] = 0x2D, ["DELETE"] = 0x2E, ["DEL"] = 0x2E, ["HOME"] = 0x24, ["END"] = 0x23, ["PAGEUP"] = 0x21, ["PAGEDOWN"] = 0x22, ["BACKSPACE"] = 0x08, ["BACK"] = 0x08, } if map[upper] then return map[upper], (getKeyName(map[upper]) or upper) end if #upper == 1 then return string.byte(upper), upper end local ok, vk = pcall(require, "vkeys") if ok and vk then if vk["VK_" .. upper] then return vk["VK_" .. upper], (getKeyName(vk["VK_" .. upper]) or upper) elseif vk[upper] then return vk[upper], (getKeyName(vk[upper]) or upper) end end if str:match("^0[xX]%x+$") then local hexNum = tonumber(str) if hexNum and hexNum > 0 then return hexNum, getKeyName(hexNum) end end local num = tonumber(str) if num ~= nil and num >= 8 then if num < 10 then return 0x30 + num, tostring(num) end return num, getKeyName(num) end return 0, str end local function getHotkeyString() if screenChatConfig.key2 ~= 0 and #screenChatConfig.key2Name > 0 then return screenChatConfig.key2Name .. " + " .. screenChatConfig.key1Name end return screenChatConfig.key1Name end local function saveScreenChatConfig() setIniKeyValue("ScreenChat", "key1", screenChatConfig.key1Name or "VK_F2") setIniKeyValue("ScreenChat", "key2", screenChatConfig.key2Name or "") end local function loadScreenChatConfig() local configDir = getWorkingDirectory() .. "\\config" if doesDirectoryExist and not doesDirectoryExist(configDir) then pcall(createDirectory, configDir) end local cfgPath = configDir .. "\\ChatLua.ini" local f = io.open(cfgPath, "r") if not f then local def = io.open(cfgPath, "w") if def then def:write("[ScreenChat]\n") def:write("; Клавиша для скриншота чата (по умолчанию VK_F2 или F2)\n") def:write("key1 = VK_F2\n") def:write("; Вторая клавиша для комбинации (например VK_LSHIFT, VK_LCONTROL). Оставьте пустой для одной клавиши.\n") def:write("key2 = \n") def:close() end screenChatConfig.key1 = 0x71 screenChatConfig.key2 = 0 screenChatConfig.key1Name = "F2" screenChatConfig.key2Name = "" return end local inScreenChat = false for line in f:lines() do local trimmed = line:match("^%s*(.-)%s*$") if trimmed:sub(1, 1) == "[" and trimmed:sub(-1) == "]" then inScreenChat = (trimmed:lower() == "[screenchat]") elseif inScreenChat and trimmed:sub(1, 1) ~= ";" and trimmed:sub(1, 1) ~= "#" then local k, v = trimmed:match("^([%w_]+)%s*=%s*(.*)$") if k and v then k = k:lower() if k == "key1" then local vk, name = parseKeyString(v) if vk > 0 then screenChatConfig.key1 = vk screenChatConfig.key1Name = name end elseif k == "key2" then local vk, name = parseKeyString(v) screenChatConfig.key2 = vk screenChatConfig.key2Name = name end end end end f:close() end local function getLocalPlayerNick() if isSampLoaded and isSampLoaded() and isSampAvailable and isSampAvailable() then local ok, myId = sampGetPlayerIdByCharHandle(PLAYER_PED) if ok and myId and myId >= 0 then local nick = sampGetPlayerNickname(myId) if nick and #nick > 0 then return nick:gsub('[\\/:*?"<>|]', '_') end end end local cfgPath = getSampCfgPath() if cfgPath then local f = io.open(cfgPath, "r") if f then for line in f:lines() do local name = line:match("^%s*name%s*=%s*(%S+)") if name and #name > 0 then f:close() return name:gsub('[\\/:*?"<>|]', '_') end end f:close() end end return "Player" end local function ensureDir(path) if doesDirectoryExist and doesDirectoryExist(path) then return true end local parent = path:match("^(.*)[\\/][^\\/]+$") if parent and #parent > 0 then ensureDir(parent) end if createDirectory then pcall(createDirectory, path) end local wPath = toWide(path) ffi.C.CreateDirectoryW(wPath, nil) return true end local function makeCapturePath() local gameDir = (getGameDirectory and getGameDirectory()) or (getWorkingDirectory():match("^(.*)[\\/]moonloader") or ".") local screensDir = gameDir .. "\\chat_screens" local nick = getLocalPlayerNick() local playerDir = screensDir .. "\\" .. nick ensureDir(playerDir) local stamp = os.date("%Y.%m.%d %H.%M.%S") local path = playerDir .. "\\" .. stamp .. ".png" if doesFileExist and not doesFileExist(path) then return path end local idx = 2 while true do local testPath = playerDir .. "\\" .. stamp .. "_" .. idx .. ".png" if doesFileExist and not doesFileExist(testPath) then return testPath end idx = idx + 1 end end local function copyTextureLevel0ToSystemSurface(tex) if tex == nil or tex == ffi.NULL then return nil end local texVtbl = ffi.cast("void***", tex)[0] local getLevelDesc = ffi.cast("int(__stdcall*)(void*, uint32_t, D3DSURFACE_DESC*)", texVtbl[17]) local getSurfaceLevel = ffi.cast("int(__stdcall*)(void*, uint32_t, void**)", texVtbl[18]) local desc = ffi.new("D3DSURFACE_DESC[1]") if getLevelDesc(tex, 0, desc) ~= 0 then return nil end local srcSurface = ffi.new("void*[1]") if getSurfaceLevel(tex, 0, srcSurface) ~= 0 or srcSurface[0] == nil or srcSurface[0] == ffi.NULL then return nil end local device = nil if getD3DDevicePtr then local devPtr = getD3DDevicePtr() if devPtr and devPtr ~= 0 then device = ffi.cast("void*", devPtr) end end if (device == nil or device == ffi.NULL) and pChat ~= nil and pChat.m_pDevice ~= 0 then device = ffi.cast("void*", pChat.m_pDevice) end if device == nil or device == ffi.NULL then releaseCom(srcSurface[0]) return nil end local devVtbl = ffi.cast("void***", device)[0] local getRenderTargetData = ffi.cast("int(__stdcall*)(void*, void*, void*)", devVtbl[32]) local createOffscreen = ffi.cast("int(__stdcall*)(void*, uint32_t, uint32_t, int, int, void**, void*)", devVtbl[36]) local sysSurface = ffi.new("void*[1]") -- 2 = D3DPOOL_SYSTEMMEM local hr = createOffscreen(device, desc[0].Width, desc[0].Height, desc[0].Format, 2, sysSurface, nil) if hr ~= 0 or sysSurface[0] == nil or sysSurface[0] == ffi.NULL then releaseCom(srcSurface[0]) return nil end hr = getRenderTargetData(device, srcSurface[0], sysSurface[0]) releaseCom(srcSurface[0]) if hr ~= 0 then releaseCom(sysSurface[0]) return nil end return sysSurface[0] end local function saveRectFromSurface(d3dx, surface, rc, path) if d3dx.D3DXSaveSurfaceToFileW ~= nil then local wPath = toWide(path) local hr = d3dx.D3DXSaveSurfaceToFileW(wPath, 3, surface, nil, rc) if hr == 0 then return true end end if d3dx.D3DXSaveSurfaceToFileA ~= nil then local hr = d3dx.D3DXSaveSurfaceToFileA(path, 3, surface, nil, rc) if hr == 0 then return true end end return false end local function buildChatRect(chat) if chat == nil or chat == ffi.NULL then return nil, "chat is null" end local from = chat.m_nScrollbarPos local to = math.min(100, from + chat.m_nPageSize) if to <= from then return nil, "visible range is empty" end local firstNonEmpty = nil local lastNonEmpty = nil for i = from, to - 1 do local entry = chat.m_entry[i] local hasPrefix = (entry.m_szPrefix[0] ~= 0) local hasText = (entry.m_szText[0] ~= 0 and not (entry.m_szText[0] == 32 and entry.m_szText[1] == 0)) if hasPrefix or hasText then if not firstNonEmpty then firstNonEmpty = i end lastNonEmpty = i end end if not firstNonEmpty then return nil, "visible area not found" end local charHeight = chat.m_nCharHeight if charHeight <= 0 then charHeight = 12 end local lineStep = charHeight + 1 local originX = 45 local originY = 10 pcall(function() if sampAddresses.SAMP_BASE and sampAddresses.SAMP_BASE ~= 0 then local pX = ffi.cast("int*", sampAddresses.SAMP_BASE + 0x63DB1) local pY = ffi.cast("int*", sampAddresses.SAMP_BASE + 0x63DA0) if pX[0] >= 0 and pX[0] < 500 then originX = pX[0] end if pY[0] >= 0 and pY[0] < 500 then originY = pY[0] end end end) local font = nil if chat.m_pFontRenderer ~= 0 then local pFont = ffi.cast("uintptr_t*", chat.m_pFontRenderer)[0] if pFont ~= 0 then font = ffi.cast("void*", pFont) end end local function measureText(str) if str == nil or #str == 0 then return 0 end local clean = str:gsub("{%x%x%x%x%x%x}", "") if #clean == 0 or clean == " " then return 0 end if font ~= nil then local vtbl = ffi.cast("void***", font)[0] local drawTextA = ffi.cast("int(__stdcall*)(void*, void*, const char*, int, RECT*, uint32_t, uint32_t)", vtbl[14]) local rc = ffi.new("RECT[1]") drawTextA(font, nil, clean, #clean, rc, 0x0400, 0xFFFFFFFF) local w = tonumber(rc[0].right - rc[0].left) if w > 0 then return w end end return math.floor(#clean * (charHeight * 0.58)) end local maxWidth = 0 for i = firstNonEmpty, lastNonEmpty do local entry = chat.m_entry[i] local lineWidth = 0 if entry.m_szPrefix[0] ~= 0 then lineWidth = lineWidth + measureText(ffi.string(entry.m_szPrefix)) + 5 end if entry.m_szText[0] ~= 0 then lineWidth = lineWidth + measureText(ffi.string(entry.m_szText)) end if lineWidth > maxWidth then maxWidth = lineWidth end end if chat.m_bTimestamp then maxWidth = maxWidth + math.max(0, chat.m_nTimestampWidth) + 5 end local outerPadding = 5 local rawTop = originY + (firstNonEmpty - from) * lineStep local rawBottom = rawTop + (lastNonEmpty - firstNonEmpty + 1) * lineStep local rc = ffi.new("RECT[1]") rc[0].left = math.max(0, originX - outerPadding) rc[0].top = math.max(0, rawTop - outerPadding) rc[0].right = math.max(rc[0].left + 1, originX + maxWidth + outerPadding + 6) rc[0].bottom = math.max(rc[0].top + 1, rawBottom + outerPadding) return rc end local function notifyScreenshotSuccess(fullPath) local gameDir = (getGameDirectory and getGameDirectory()) or (getWorkingDirectory():match("^(.*)[\\/]moonloader") or ".") local displayPath = fullPath if fullPath:sub(1, #gameDir) == gameDir then displayPath = fullPath:sub(#gameDir + 2) end if sampAddChatMessage then sampAddChatMessage("{44AAFF}[ScreenChat]{FFFFFF} Чат сохранён: {44AAFF}" .. displayPath, -1) end end local function takeChatScreenshot() if pChat == nil or pChat == ffi.NULL then if sampAddChatMessage then sampAddChatMessage("{44AAFF}[ScreenChat]{FFFFFF} Чат не инициализирован.", -1) end return false end local d3dx = getD3DX() if not d3dx then if sampAddChatMessage then sampAddChatMessage("{FF4444}[ScreenChat]{FFFFFF} Ошибка: библиотека DirectX (d3dx9_xx.dll) не найдена.", -1) end return false end -- Ensure m_pSurface has all entries baked cleanly at standard origin local oldRender = pChat.m_bRenderToSurface pChat.m_bRenderToSurface = 1 pChat.m_bRedraw = 1 if sampAddresses.CHAT_RENDER ~= 0 then thiscall0(sampAddresses.CHAT_RENDER, tonumber(ffi.cast("uintptr_t", pChat))) end pChat.m_bRenderToSurface = oldRender local rc, _ = buildChatRect(pChat) if not rc then if sampAddChatMessage then sampAddChatMessage("{44AAFF}[ScreenChat]{FFFFFF} Чат пуст, скриншот не создан.", -1) end return false end local targetPath = makeCapturePath() if not targetPath then if sampAddChatMessage then sampAddChatMessage("{FF4444}[ScreenChat]{FFFFFF} Не удалось создать папку для скриншотов.", -1) end return false end local surface = (pChat.m_pSurface ~= 0) and ffi.cast("void*", pChat.m_pSurface) or nil if surface ~= nil and surface ~= ffi.NULL then if saveRectFromSurface(d3dx, surface, rc, targetPath) then notifyScreenshotSuccess(targetPath) return true end end local texture = (pChat.m_pTexture ~= 0) and ffi.cast("void*", pChat.m_pTexture) or nil if texture ~= nil and texture ~= ffi.NULL then local sysSurface = copyTextureLevel0ToSystemSurface(texture) if sysSurface ~= nil then local ok = saveRectFromSurface(d3dx, sysSurface, rc, targetPath) releaseCom(sysSurface) if ok then notifyScreenshotSuccess(targetPath) return true end end end if sampAddChatMessage then sampAddChatMessage("{FF4444}[ScreenChat]{FFFFFF} Ошибка сохранения скриншота в файл.", -1) end return false end local hookMemory = ffi.cast("uint8_t*", ffi.C.VirtualAlloc(nil, 16384, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE)) local hookMemOffset = 0 local function allocHookBytes(count) if hookMemory == nil or hookMemory == ffi.NULL or (hookMemOffset + count > 16384) then return nil end local ptr = hookMemory + hookMemOffset hookMemOffset = hookMemOffset + count return ptr end local installedHooks = {} local function createHook(targetAddr, stolenBytesCount, bridgeFunc) if targetAddr == nil or targetAddr == 0 then return false end local bridgePtr = allocHookBytes(64) local trampPtr = allocHookBytes(32) if bridgePtr == nil or trampPtr == nil then print("[ChatLua] Error: allocHookBytes failed (out of memory)") return false end local pTarget = ffi.cast("uint8_t*", targetAddr) local relOff = (sampAddresses.SAMP_BASE and sampAddresses.SAMP_BASE ~= 0) and (targetAddr - sampAddresses.SAMP_BASE) or 0 local known = knownOriginalBytes[relOff] local oldProt = ffi.new("DWORD[1]") local targetVoid = ffi.cast("void*", targetAddr) ffi.C.VirtualProtect(targetVoid, stolenBytesCount, PAGE_EXECUTE_READWRITE, oldProt) local origBytes = {} if pTarget[0] == 0xE9 and known then -- Target was already hooked by a previous instance! Use known clean original bytes! for i = 0, stolenBytesCount - 1 do local b = known[i + 1] or 0x90 table.insert(origBytes, b) trampPtr[i] = b end else for i = 0, stolenBytesCount - 1 do table.insert(origBytes, pTarget[i]) trampPtr[i] = pTarget[i] end end trampPtr[stolenBytesCount] = 0xE9 -- JMP rel32 local relJumpFromTramp = (targetAddr + stolenBytesCount) - (tonumber(ffi.cast("uintptr_t", trampPtr)) + stolenBytesCount + 5) ffi.cast("int32_t*", trampPtr + stolenBytesCount + 1)[0] = relJumpFromTramp bridgeFunc(bridgePtr, tonumber(ffi.cast("uintptr_t", trampPtr))) -- Patch target: JMP bridgePtr + NOPs pTarget[0] = 0xE9 local relJumpToBridge = tonumber(ffi.cast("uintptr_t", bridgePtr)) - (targetAddr + 5) ffi.cast("int32_t*", pTarget + 1)[0] = relJumpToBridge for i = 5, stolenBytesCount - 1 do pTarget[i] = 0x90 -- NOP end ffi.C.VirtualProtect(targetVoid, stolenBytesCount, oldProt[0], nil) ffi.C.FlushInstructionCache(ffi.C.GetCurrentProcess(), targetVoid, stolenBytesCount) local bytesToSave = known or origBytes table.insert(installedHooks, { address = targetAddr, orig = bytesToSave }) return true end -- Callbacks anchored to prevent Lua GC local callbacksAnchor = {} local function getPlayerNameById(id) if not id or id < 0 or id > 1000 then return nil end local name = nil pcall(function() local r = sampGetPlayerNickname(id); if r and type(r) == 'string' and #r > 0 and #r < 30 then name = r end end) return name end local function getLocalPlayerInfo() local name, id = nil, nil pcall(function() local _, pid = sampGetPlayerIdByCharHandle(PLAYER_PED); id = pid; if id then name = sampGetPlayerNickname(id) end end) return name, id end local function addSystemMsg(msgText) table.insert(nrpMessages, { text = msgText, time = os.date('%H:%M:%S'), baseColor = 0xFF888888, isWelcome = true }) pushToOocEntries(0xFF888888, msgText) if not nrpEnabled then if sampAddChatMessage then sampAddChatMessage(msgText, -1) elseif pChat ~= nil and pChat ~= ffi.NULL then pushToIcBuffer(pChat.m_entry, msgText, 0xFF888888) if chatUpdate then chatUpdate() end end if nrpMode == "tab" and activeChatTab == "IC" and pChat ~= nil and pChat ~= ffi.NULL then ffi.copy(icBackupEntries, pChat.m_entry, 100 * ffi.sizeof("CChatEntry")) end end end local function extractHex(color) return string.format('%02x%02x%02x', bit.band(bit.rshift(color, 24), 0xFF), bit.band(bit.rshift(color, 16), 0xFF), bit.band(bit.rshift(color, 8), 0xFF)) end local function isColorMatch(color, hex) return extractHex(color) == hex end local function showActivated() local txt = '{FF0000}NRP Chat by mace in ChatLua activated.' if #nrpMessages > 0 and nrpMessages[#nrpMessages].isWelcome then return end table.insert(nrpMessages, { text = txt, time = os.date('%H:%M:%S'), baseColor = 0xFF888888, isWelcome = true }) pushToOocEntries(0xFF888888, txt) end local function showWatchingLine() local txt = '{888888}NRP Chat {2E8B57}watching...' if #nrpMessages > 0 and nrpMessages[#nrpMessages].text == txt then return end table.insert(nrpMessages, { text = txt, time = os.date('%H:%M:%S'), baseColor = 0xFF888888, isWelcome = true }) pushToOocEntries(0xFF888888, txt) end local function showWatching() nrpMessages = {} oocEntryMessageRefs = {} local txt = '{888888}NRP Chat {2E8B57}watching...' table.insert(nrpMessages, { text = txt, time = os.date('%H:%M:%S'), baseColor = 0xFF888888, isWelcome = true }) scrollOffset = 0 ffi.fill(oocBackupEntries, 100 * ffi.sizeof("CChatEntry")) pushToOocEntries(0xFF888888, txt) end local function showFollowList() local names = {} for name, _ in pairs(ssPlayers) do table.insert(names, '{808080}' .. name .. '(' .. (ssPlayersWithIds[name] or '?') .. '){2E8B57}') end local txt = '{888888}NRP Chat -> {2E8B57}following ' .. table.concat(names, '{888888}, ') table.insert(nrpMessages, { text = txt, time = os.date('%H:%M:%S'), baseColor = 0xFF888888, isWelcome = true }) scrollOffset = 0 pushToOocEntries(0xFF888888, txt) end local function showHelpMessages() local t = os.date('%H:%M:%S') local msgs = { '{FF0000}ALT+0 {888888}- deactivate/activate | {FF0000}ALT+1 {888888}- hide/show', '{FF0000}/ssadd ID {888888}- SS mode | {FF0000}/ssdel ID {888888}- remove | {FF0000}/ssoff {888888}- off', '{FF0000}/nrptimestamp {888888}- time | {FF0000}/nrpcc {888888}- clear | {FF0000}/nrpck {888888}- character kill as rp', '{FF0000}Home/End {888888}- scroll | {FF0000}/nrppagesize {888888}5-64 {808080}(current: ' .. chatRows .. ', default: 10){888888} - rows', '{FF0000}/nrpfontsize {888888}0.5-1.5 {808080}(current: ' .. string.format('%.1f', chatScale) .. ', default: 1.0){888888} - scale', '{FF0000}/nrplength {888888}40-150 {808080}(current: ' .. maxLineLength .. ', default: 80){888888} - line length', '{FF0000}/nrppos {888888}X Y {808080}(current: ' .. chatX .. ', ' .. chatY .. ', default: 930, 10){888888} - chat position', } for _, msg in ipairs(msgs) do table.insert(nrpMessages, { text = msg, time = t, baseColor = 0xFF888888, isWelcome = true }) pushToOocEntries(0xFF888888, msg) end scrollOffset = 0 end local function isPlayerInSSList(cleanText) if not next(ssPlayers) then return false end local t = cleanText:gsub('_', ' ') for name, _ in pairs(ssPlayers) do if t:find(name, 1, true) then return true end end return false end local function parseColorSegments(text) local segments, currentText, i = {}, "", 1 while i <= #text do local c = text:sub(i, i) if c == '{' then local eb = text:find('}', i) if eb then local cc = text:sub(i + 1, eb - 1) if #cc == 6 and tonumber(cc, 16) then if #currentText > 0 then table.insert(segments, { text = currentText }); currentText = "" end table.insert(segments, { color = cc }); i = eb + 1 else currentText = currentText .. text:sub(i, eb); i = eb + 1 end else currentText = currentText .. text:sub(i); break end else currentText = currentText .. c; i = i + 1 end end if #currentText > 0 then table.insert(segments, { text = currentText }) end return segments end local function getColoredSubtext(segments, startPos, length) local result = '' local currentPos = 0 local endPos = startPos + length - 1 for _, seg in ipairs(segments) do if seg.color then result = result .. '{' .. seg.color .. '}' elseif seg.text then local segLen = #seg.text local segStart = currentPos + 1 local segEnd = currentPos + segLen if segEnd >= startPos and segStart <= endPos then local from = math.max(1, startPos - currentPos) local to = math.min(segLen, endPos - currentPos) result = result .. seg.text:sub(from, to) end currentPos = currentPos + segLen end end return result end local function wrapText(text, maxLen) local cleanText = text:gsub('%b{}', '') if #cleanText <= maxLen then return { text } end local lines, pos = {}, 1 local segments = parseColorSegments(text) while pos <= #cleanText do local remaining = #cleanText - pos + 1 if remaining <= maxLen then table.insert(lines, getColoredSubtext(segments, pos, remaining)) break end table.insert(lines, getColoredSubtext(segments, pos, maxLen - 4) .. ' ...') pos = pos + maxLen - 4 end return lines end local function segColor(seg, def) if seg.color then local r = tonumber(seg.color:sub(1, 2), 16) local g = tonumber(seg.color:sub(3, 4), 16) local b = tonumber(seg.color:sub(5, 6), 16) return bit.bor(0xFF000000, bit.lshift(r, 16), bit.lshift(g, 8), b) end return def end local function applyAlpha(color, multiplier) return bit.bor(bit.lshift(math.floor(bit.band(bit.rshift(color, 24), 0xFF) * multiplier), 24), bit.band(color, 0x00FFFFFF)) end local function drawTextWithOutline(text, x, y, color) if visibilityMode == 2 or not fontText then return end local alpha = visibilityMode == 1 and 0.6 or 1.0 local dc, oc = applyAlpha(color, alpha), applyAlpha(0xFF000000, alpha) local o = math.max(1, math.floor(chatScale)) renderFontDrawText(fontText, text, x + o, y, oc, false) renderFontDrawText(fontText, text, x - o, y, oc, false) renderFontDrawText(fontText, text, x, y + o, oc, false) renderFontDrawText(fontText, text, x, y - o, oc, false) renderFontDrawText(fontText, text, x, y, dc, false) end local function drawNrpChat() if not nrpEnabled or nrpMode ~= "separate" then return end if chatHidden or visibilityMode == 2 or not fontText then return end local x, y = chatX, chatY local lh, sp = math.floor(18 * chatScale), math.floor(4 * chatScale) local total = #nrpMessages local maxScroll = math.max(0, total - chatRows) if scrollOffset > maxScroll then scrollOffset = maxScroll end local mc = math.min(chatRows, total) local si = total - mc + 1 - scrollOffset if si < 1 then si = 1 end oocEntryManager:clear() if maxScroll > 0 and scrollOffset > 0 then local ch = chatRows * lh local th = math.max(10, (chatRows / total) * ch) local ty = y + ((maxScroll - scrollOffset) / maxScroll) * (ch - th) renderDrawBox(x - 8, y, 2, ch, 0xFF000000) renderDrawBox(x - 9, ty, 4, th, 0xFFFF0000) end local curY = y for i = si, math.min(si + mc - 1, total) do local msg = nrpMessages[i] if msg then local segs = parseColorSegments(msg.text) local xp = x if showTime and msg.time ~= '' then local tt = '[' .. msg.time .. ']' drawTextWithOutline(tt, xp, curY, segColor(segs[1], msg.baseColor)) xp = xp + renderGetFontDrawTextLength(fontText, tt) + sp end local cc = msg.baseColor for _, seg in ipairs(segs) do cc = segColor(seg, cc) if seg.text then drawTextWithOutline(seg.text, xp, curY, cc) xp = xp + renderGetFontDrawTextLength(fontText, seg.text) end end local lineW = math.max(160, xp - x) oocEntryManager:observe(i, x - 4, curY - 2, x + lineW + 4, curY + lh - 2) curY = curY + lh end end end local function cp1251_lower(s) local bytes = {s:byte(1, #s)} for i = 1, #bytes do local b = bytes[i] if (b >= 65 and b <= 90) or (b >= 192 and b <= 223) then bytes[i] = b + 32 elseif b == 168 then bytes[i] = 184 end end return string.char(unpack(bytes)) end local function isRolePlayMessage(color, text) local cleanText = text:gsub('%b{}', '') local lowerText = cp1251_lower(cleanText) local endsWithOOC = cleanText:match('%)%)%s*$') if isColorMatch(color, 'c2a2da') then return true end if cleanText:match('^%s*%.%.%.') then if lastWasFromTarget then return true end if lastWasHidden then return false end return not (endsWithOOC and not isColorMatch(color, 'c2a2da')) end if endsWithOOC and isColorMatch(color, 'ffff00') then return true end if lowerText:find('unread') and lowerText:find('sms', 1, true) then return true end if lowerText:find('sms', 1, true) then return isColorMatch(color, 'ffff00') end if cleanText == ' ' then return true end if lowerText:find('в дверь]', 1, true) then return true end if lowerText:find('передал ваш репорт', 1, true) then return false end if afkAsRp and lowerText:find('установил ', 1, true) then return true end if not (cleanText:match('^%s*%(%(') or endsWithOOC) then for _, k in ipairs({ 'шепчет', 'говорит', 'кричит', 'сказал', 'обращается', '[телефон]', '[громкая связь]', '[в рацию', '[мегафон', '[в микрофон]' }) do if lowerText:find(k, 1, true) then return true end end if lowerText:find('передал', 1, true) and isColorMatch(color, 'ffffff') then return true end if lowerText:find('[ch', 1, true) and isColorMatch(color, 'ffffcc') then return true end end return false end local function pushToNrp(color, text) local time = os.date('%H:%M:%S') local cleanText = text:gsub('%b{}', '') local lowerText = cp1251_lower(cleanText) if chatHidden and (lowerText:find('ответ администратора') or cleanText:match('^%(%(')) then if addOneOffSound then pcall(addOneOffSound, 0, 0, 0, 1085) end end local argb = toArgb(color) local now = os.time() for i, line in ipairs(wrapText(text, maxLineLength)) do table.insert(nrpMessages, { text = line, time = (i == 1) and time or '', timestamp = now, baseColor = argb, isContinuation = (i > 1) }) if #nrpMessages > maxMessages then table.remove(nrpMessages, 1) end pushToOocEntries(argb, line) end end local function loadNrpConfig() local cfgPath = getIniPath() local f = io.open(cfgPath, "r") if not f then return end local inNrp = false local inSS = false for line in f:lines() do local sec = line:match("^%[([^%]]+)%]") if sec then inNrp = (sec == "NRPChat") inSS = (sec == "SSPlayers") elseif inNrp then local k, v = line:match("^([%w_]+)%s*=%s*(.+)") if k == "enabled" then nrpEnabled = (v == "1" or v == "true") elseif k == "mode" then nrpMode = (v == "tab" and "tab" or "separate") elseif k == "x" then chatX = tonumber(v) or 930 elseif k == "y" then chatY = tonumber(v) or 10 elseif k == "scale" then chatScale = tonumber(v) or 1.0 if fontText then renderReleaseFont(fontText) end fontText = renderCreateFont('Arial', math.max(8, math.floor(11 * chatScale)), 1) elseif k == "rows" then chatRows = tonumber(v) or 10 elseif k == "maxLineLength" then maxLineLength = tonumber(v) or 80 elseif k == "showTime" then showTime = (v == "1" or v == "true") elseif k == "afkAsRp" then afkAsRp = (v == "1" or v == "true") elseif k == "showHelp" then showHelp = (v == "1" or v == "true") elseif k == "hotkeyToggleMod" then local vk, name = parseKeyString(v) nrpHotkeys.toggleMod, nrpHotkeys.toggleModName = vk, name elseif k == "hotkeyToggleKey" then local vk, name = parseKeyString(v) nrpHotkeys.toggleKey, nrpHotkeys.toggleKeyName = vk, name elseif k == "hotkeyHideMod" then local vk, name = parseKeyString(v) nrpHotkeys.hideMod, nrpHotkeys.hideModName = vk, name elseif k == "hotkeyHideKey" then local vk, name = parseKeyString(v) nrpHotkeys.hideKey, nrpHotkeys.hideKeyName = vk, name elseif k == "hotkeyMode" then local vk, name = parseKeyString(v) nrpHotkeys.modeKey, nrpHotkeys.modeKeyName = vk, name end elseif inSS then local name = line:match("^([^=\r\n]+)") if name and #name > 0 then ssPlayers[name:gsub('_', ' ')] = true end end end f:close() end local function saveNrpConfig() setIniKeyValue("NRPChat", "enabled", nrpEnabled and "1" or "0") setIniKeyValue("NRPChat", "mode", nrpMode) setIniKeyValue("NRPChat", "x", tostring(math.floor(chatX))) setIniKeyValue("NRPChat", "y", tostring(math.floor(chatY))) setIniKeyValue("NRPChat", "scale", string.format("%.2f", chatScale)) setIniKeyValue("NRPChat", "rows", tostring(chatRows)) setIniKeyValue("NRPChat", "maxLineLength", tostring(maxLineLength)) setIniKeyValue("NRPChat", "showTime", showTime and "1" or "0") setIniKeyValue("NRPChat", "afkAsRp", afkAsRp and "1" or "0") setIniKeyValue("NRPChat", "showHelp", showHelp and "1" or "0") setIniKeyValue("NRPChat", "hotkeyToggleMod", nrpHotkeys.toggleModName or "") setIniKeyValue("NRPChat", "hotkeyToggleKey", nrpHotkeys.toggleKeyName or "") setIniKeyValue("NRPChat", "hotkeyHideMod", nrpHotkeys.hideModName or "") setIniKeyValue("NRPChat", "hotkeyHideKey", nrpHotkeys.hideKeyName or "") setIniKeyValue("NRPChat", "hotkeyMode", nrpHotkeys.modeKeyName or "") local cfgPath = getIniPath() local lines = {} local f = io.open(cfgPath, "r") local inSS = false if f then for line in f:lines() do local sec = line:match("^%[([^%]]+)%]") if sec then if inSS then for pName, _ in pairs(ssPlayers) do table.insert(lines, pName) end inSS = false end if sec == "SSPlayers" then inSS = true table.insert(lines, line) else table.insert(lines, line) end elseif not inSS then table.insert(lines, line) end end f:close() end if inSS then for pName, _ in pairs(ssPlayers) do table.insert(lines, pName) end elseif not next(lines) or not string.find(table.concat(lines, "\n"), "%[SSPlayers%]") then table.insert(lines, "[SSPlayers]") for pName, _ in pairs(ssPlayers) do table.insert(lines, pName) end end local out = io.open(cfgPath, "w") if out then for _, l in ipairs(lines) do out:write(l .. "\n") end out:close() end end function sampev.onServerMessage(color, text) if not nrpEnabled then return end local cleanText = text:gsub('%b{}', '') local fromTarget = isPlayerInSSList(cleanText) if next(ssPlayers) and cleanText:match('^%s*%.%.%.') and pendingDoStar then if fromTarget then pendingDoStar, pendingDoTimer = nil, 0 lastWasHidden, lastWasFromTarget = false, true if nrpMode == "tab" and activeChatTab == "OOC" then pushToIcBackup(0xFFC2A2DA, pendingDoStar.text) return false end sampAddChatMessage(pendingDoStar.text, 0xFFC2A2DA) return else pushToNrp(pendingDoStar.color, pendingDoStar.text) pendingDoStar, pendingDoTimer = nil, 0 pushToNrp(color, text) lastWasHidden, lastWasFromTarget = true, false return false end end if next(ssPlayers) and cleanText:match('^%s*%*') and not fromTarget then pendingDoStar, pendingDoTimer = { color = color, text = text }, 0 return false end if next(ssPlayers) and pendingDoStar then pushToNrp(pendingDoStar.color, pendingDoStar.text) pendingDoStar, pendingDoTimer = nil, 0 end if next(ssPlayers) and fromTarget then if isRolePlayMessage(color, text) then lastWasHidden, lastWasFromTarget = false, true if nrpMode == "tab" and activeChatTab == "OOC" then pushToIcBackup(color, text) return false end return else pushToNrp(color, text) lastWasHidden, lastWasFromTarget = true, false return false end end if next(ssPlayers) and cleanText:match('^%s*%.%.%.') and lastWasFromTarget then lastWasHidden, lastWasFromTarget = false, true if nrpMode == "tab" and activeChatTab == "OOC" then pushToIcBackup(color, text) return false end return end if next(ssPlayers) and isRolePlayMessage(color, text) then pushToNrp(color, text) lastWasHidden, lastWasFromTarget = true, false return false end if next(ssPlayers) then pushToNrp(color, text) lastWasHidden, lastWasFromTarget = true, false return false end if isRolePlayMessage(color, text) then lastWasHidden, lastWasFromTarget = false, false if nrpMode == "tab" and activeChatTab == "OOC" then pushToIcBackup(color, text) return false end return end pushToNrp(color, text) lastWasHidden, lastWasFromTarget = true, false return false end local function setupAllHooks() if sampAddresses.hooksInstalled then return true end if not sampAddresses.CHAT_RENDER or sampAddresses.CHAT_RENDER == 0 then return false end callbacksAnchor.onChatRender = ffi.cast("void (__stdcall *)(void*)", function(chat_ptr) if chat_ptr ~= nil and chat_ptr ~= ffi.NULL then local isFirst = (pChat == nil) pChat = ffi.cast("CChat*", chat_ptr) entryManager:clear() if isFirst and sampAddresses.CHAT_SET_PAGESIZE ~= 0 and currentPageSize[0] > 0 then thiscall1(sampAddresses.CHAT_SET_PAGESIZE, tonumber(ffi.cast("uintptr_t", pChat)), currentPageSize[0]) end end end) createHook(sampAddresses.CHAT_RENDER, 6, function(bridge, tramp) local cbAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onChatRender)) local code = { 0x60, -- 0: pushad 0xFF, 0x74, 0x24, 0x18, -- 1..4: push [esp+24] (saved ECX: chat) 0xB8, 0, 0, 0, 0, -- 5: mov eax, cbAddr 0xFF, 0xD0, -- 10..11: call eax (stdcall pops 4) 0x61, -- 12: popad 0xE9, 0, 0, 0, 0 -- 13: jmp tramp } for i = 1, #code do bridge[i - 1] = code[i] end ffi.cast("int32_t*", bridge + 6)[0] = cbAddr local relJmp = tramp - (tonumber(ffi.cast("uintptr_t", bridge)) + 18) ffi.cast("int32_t*", bridge + 14)[0] = relJmp end) callbacksAnchor.onPreDraw = ffi.cast("void (__stdcall *)(void*)", function(chat_ptr) if chat_ptr ~= nil and chat_ptr ~= ffi.NULL then pChat = ffi.cast("CChat*", chat_ptr) movePosition(0, 0) if offsetX ~= 0 or offsetY ~= 0 then pChat.m_bRenderToSurface = 0 else pChat.m_bRenderToSurface = 1 end end end) callbacksAnchor.onPostDraw = ffi.cast("void (__stdcall *)()", function() if pChat ~= nil and pChat ~= ffi.NULL then relocateDialog() end end) createHook(sampAddresses.CHAT_DRAW, 9, function(bridge, tramp) local preAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onPreDraw)) local postAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onPostDraw)) local code = { 0x60, -- 0: pushad 0xFF, 0x74, 0x24, 0x18, -- 1..4: push [esp+24] (saved ECX: chat) 0xB8, 0, 0, 0, 0, -- 5: mov eax, preAddr 0xFF, 0xD0, -- 10..11: call eax (stdcall pops 4) 0x61, -- 12: popad 0xE8, 0, 0, 0, 0, -- 13: call tramp 0x50, -- 18: push eax 0xBA, 0, 0, 0, 0, -- 19: mov edx, postAddr 0xFF, 0xD2, -- 24..25: call edx (stdcall pops 0) 0x58, -- 26: pop eax 0xC3 -- 27: ret } for i = 1, #code do bridge[i - 1] = code[i] end ffi.cast("int32_t*", bridge + 6)[0] = preAddr local relCall = tramp - (tonumber(ffi.cast("uintptr_t", bridge)) + 18) ffi.cast("int32_t*", bridge + 14)[0] = relCall ffi.cast("int32_t*", bridge + 20)[0] = postAddr end) callbacksAnchor.onInputOpen = ffi.cast("void (__stdcall *)(void*)", function(input_ptr) mInput = input_ptr end) callbacksAnchor.onRelocate = ffi.cast("void (__stdcall *)()", function() relocateDialog() end) createHook(sampAddresses.INPUT_OPEN, 6, function(bridge, tramp) local openAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onInputOpen)) local relocAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onRelocate)) local code = { 0x60, -- 0: pushad 0xFF, 0x74, 0x24, 0x18, -- 1..4: push [esp+24] (saved ECX: input) 0xB8, 0, 0, 0, 0, -- 5: mov eax, openAddr 0xFF, 0xD0, -- 10..11: call eax (stdcall pops 4) 0x61, -- 12: popad 0xE8, 0, 0, 0, 0, -- 13: call tramp 0x50, -- 18: push eax 0xBA, 0, 0, 0, 0, -- 19: mov edx, relocAddr 0xFF, 0xD2, -- 24..25: call edx (stdcall pops 0) 0x58, -- 26: pop eax 0xC3 -- 27: ret } for i = 1, #code do bridge[i - 1] = code[i] end ffi.cast("int32_t*", bridge + 6)[0] = openAddr local relCall = tramp - (tonumber(ffi.cast("uintptr_t", bridge)) + 18) ffi.cast("int32_t*", bridge + 14)[0] = relCall ffi.cast("int32_t*", bridge + 20)[0] = relocAddr end) callbacksAnchor.onRenderEntry = ffi.cast("void (__stdcall *)(void*, const char*, void*, uint32_t)", function(chat_ptr, src_ptr, rect_ptr, color) if chat_ptr == nil or chat_ptr == ffi.NULL or rect_ptr == nil or rect_ptr == ffi.NULL then return end local chat = ffi.cast("CChat*", chat_ptr) local chatAddr = tonumber(ffi.cast("uintptr_t", chat_ptr)) local src = tonumber(ffi.cast("uintptr_t", src_ptr)) local first = chatAddr + 0x132 local last = first + 100 * 252 if src >= first and src < last then local relative = src - first local field = relative % 252 if field >= 4 and field < 176 then local entryId = math.floor(relative / 252) local rect = ffi.cast("int32_t*", rect_ptr) local hitX1 = math.min(45, rect[0]) + offsetX local hitY1 = rect[1] + offsetY local hitX2 = rect[2] + offsetX local hitY2 = rect[1] + chat.m_nCharHeight + 1 + offsetY entryManager:observe(entryId, hitX1, hitY1, hitX2, hitY2) end end if chat.m_bRenderToSurface == 0 then local rect = ffi.cast("int32_t*", rect_ptr) rect[0] = rect[0] + offsetX rect[2] = rect[2] + offsetX rect[1] = rect[1] + offsetY rect[3] = rect[3] + offsetY end end ) createHook(sampAddresses.CHAT_RENDER_ENTRY, 6, function(bridge, tramp) local cbAddr = tonumber(ffi.cast("uintptr_t", callbacksAnchor.onRenderEntry)) local code = { 0x60, -- 0: pushad 0xFF, 0x74, 0x24, 0x38, -- 1..4: push [esp+56] (arg4: color) 0x8D, 0x44, 0x24, 0x2C, -- 5..8: lea eax, [esp+44] (arg3: rect_ptr) 0x50, -- 9: push eax 0xFF, 0x74, 0x24, 0x2C, -- 10..13: push [esp+44] (arg2: src) 0xFF, 0x74, 0x24, 0x24, -- 14..17: push [esp+36] (arg1: chat / saved ECX) 0xB8, 0, 0, 0, 0, -- 18: mov eax, cbAddr 0xFF, 0xD0, -- 23..24: call eax (stdcall pops 16) 0x61, -- 25: popad 0xE9, 0, 0, 0, 0 -- 26: jmp tramp } for i = 1, #code do bridge[i - 1] = code[i] end ffi.cast("int32_t*", bridge + 19)[0] = cbAddr local relJmp = tramp - (tonumber(ffi.cast("uintptr_t", bridge)) + 31) ffi.cast("int32_t*", bridge + 27)[0] = relJmp end) sampAddresses.hooksInstalled = true return true end local function updateMoveDrag() if not scriptInitialized or pChat == nil or pChat == ffi.NULL then return end local buttonDown = (ffi.C.GetAsyncKeyState(0x01) < 0) -- VK_LBUTTON local cursorMode = getSampCursorMode() local active = (gameHwnd ~= nil and ffi.C.GetForegroundWindow() == gameHwnd and pChat ~= nil and (isInputOpen() or (cursorMode >= 2 and cursorMode <= 3)) and not isGTAMenuActive()) local pt = ffi.new("POINT[1]") local hasPoint = active and (ffi.C.GetCursorPos(pt) ~= 0) and (ffi.C.ScreenToClient(gameHwnd, pt) ~= 0) if not buttonDown then if dragging then dragging = false if offsetX == 0 and offsetY == 0 and chatUpdate then chatUpdate() end end else if dragging and hasPoint then local dx = pt[0].x - lastMouse.x local dy = pt[0].y - lastMouse.y if dx ~= 0 or dy ~= 0 then movePosition(dx, dy) lastMouse.x = pt[0].x lastMouse.y = pt[0].y end elseif oocMoveDrag.active and hasPoint then chatX = pt[0].x - oocMoveDrag.offsetX chatY = pt[0].y - oocMoveDrag.offsetY elseif lineDragState.active and hasPoint then lineDragState.currentX = pt[0].x lineDragState.currentY = pt[0].y if lineDragState.sourceChat == "IC" then if nrpMode == "separate" and isPointInsideOocChat(pt[0].x, pt[0].y) then lineDragState.targetChat = "OOC" lineDragState.targetId = oocEntryManager:findClosestEntryByY(pt[0].y) elseif nrpMode == "tab" and isPointInsideTabOOC(pt[0].x, pt[0].y) then lineDragState.targetChat = "tab_OOC" lineDragState.targetId = -1 else lineDragState.targetChat = "IC" lineDragState.targetId = entryManager:findClosestEntryByY(pt[0].y) end elseif lineDragState.sourceChat == "OOC" then if nrpMode == "separate" and isPointInsideMainChat(pt[0].x, pt[0].y) then lineDragState.targetChat = "IC" lineDragState.targetId = entryManager:findClosestEntryByY(pt[0].y) elseif nrpMode == "tab" and isPointInsideTabIC(pt[0].x, pt[0].y) then lineDragState.targetChat = "tab_IC" lineDragState.targetId = -1 else lineDragState.targetChat = "OOC" lineDragState.targetId = (nrpMode == "separate") and oocEntryManager:findClosestEntryByY(pt[0].y) or entryManager:findClosestEntryByY(pt[0].y) end end elseif lineDragState.pending and hasPoint then lineDragState.currentX = pt[0].x lineDragState.currentY = pt[0].y local dy = math.abs(pt[0].y - lineDragState.startY) local dx = math.abs(pt[0].x - lineDragState.startX) if dy >= 5 or dx >= 8 then lineDragState.active = true lineDragState.pending = false if lineDragState.sourceChat == "IC" then lineDragState.targetId = entryManager:findClosestEntryByY(pt[0].y) else lineDragState.targetId = (nrpMode == "separate") and oocEntryManager:findClosestEntryByY(pt[0].y) or entryManager:findClosestEntryByY(pt[0].y) end end end end end function onWindowMessage(msg, wparam, lparam) if not scriptInitialized or pChat == nil or pChat == ffi.NULL then return end if settingsUI.waitingTarget then if msg == 0x0100 or msg == 0x0104 then if wparam == 0x1B then -- Escape cancels settingsUI.waitingTarget = nil elseif wparam == 0x08 or wparam == 0x2E then -- Backspace / Delete clears local t = settingsUI.waitingTarget if t == "screen_k1" then screenChatConfig.key1 = 0; screenChatConfig.key1Name = ""; saveScreenChatConfig() elseif t == "screen_k2" then screenChatConfig.key2 = 0; screenChatConfig.key2Name = ""; saveScreenChatConfig() elseif t == "nrp_toggleKey" then nrpHotkeys.toggleKey = 0; nrpHotkeys.toggleKeyName = ""; saveNrpConfig() elseif t == "nrp_toggleMod" then nrpHotkeys.toggleMod = 0; nrpHotkeys.toggleModName = ""; saveNrpConfig() elseif t == "nrp_hideKey" then nrpHotkeys.hideKey = 0; nrpHotkeys.hideKeyName = ""; saveNrpConfig() elseif t == "nrp_hideMod" then nrpHotkeys.hideMod = 0; nrpHotkeys.hideModName = ""; saveNrpConfig() elseif t == "nrp_modeKey" then nrpHotkeys.modeKey = 0; nrpHotkeys.modeKeyName = ""; saveNrpConfig() end settingsUI.waitingTarget = nil else local kname = getKeyName(wparam) local t = settingsUI.waitingTarget if t == "screen_k1" then screenChatConfig.key1 = wparam; screenChatConfig.key1Name = kname; saveScreenChatConfig() elseif t == "screen_k2" then screenChatConfig.key2 = wparam; screenChatConfig.key2Name = kname; saveScreenChatConfig() elseif t == "nrp_toggleKey" then nrpHotkeys.toggleKey = wparam; nrpHotkeys.toggleKeyName = kname; saveNrpConfig() elseif t == "nrp_toggleMod" then nrpHotkeys.toggleMod = wparam; nrpHotkeys.toggleModName = kname; saveNrpConfig() elseif t == "nrp_hideKey" then nrpHotkeys.hideKey = wparam; nrpHotkeys.hideKeyName = kname; saveNrpConfig() elseif t == "nrp_hideMod" then nrpHotkeys.hideMod = wparam; nrpHotkeys.hideModName = kname; saveNrpConfig() elseif t == "nrp_modeKey" then nrpHotkeys.modeKey = wparam; nrpHotkeys.modeKeyName = kname; saveNrpConfig() end settingsUI.waitingTarget = nil end consumeWindowMessage(true, false) return elseif msg == 0x0204 then -- WM_RBUTTONDOWN cancels settingsUI.waitingTarget = nil consumeWindowMessage(true, false) return end end -- Extract client coordinates for mouse messages local isMouseMsg = (msg >= 0x0200 and msg <= 0x020E) local xPos, yPos = 0, 0 if isMouseMsg then xPos = bit.band(lparam, 0xFFFF) if xPos >= 0x8000 then xPos = xPos - 0x10000 end yPos = bit.rshift(lparam, 16) if yPos >= 0x8000 then yPos = yPos - 0x10000 end end -- 1. WM_LBUTTONDOWN (0x0201) if msg == 0x0201 then local cursorMode = getSampCursorMode() local isEdit = popupActive or editLineActive if isInputOpen() or (cursorMode >= 2 and cursorMode <= 3) then if not isEdit then local hl, ht, hr, hb = getMoveHandleRect() if xPos >= hl and xPos <= hr and yPos >= ht and yPos <= hb then dragging = true lastMouse.x = xPos lastMouse.y = yPos selectedEntry = -1 consumeWindowMessage(true, false, true) return end if nrpEnabled and nrpMode == "separate" then local ohx, ohy, ohw, ohh = chatX, chatY - 24, 62, 20 if xPos >= ohx and xPos <= ohx + ohw and yPos >= ohy and yPos <= ohy + ohh then oocMoveDrag.active = true oocMoveDrag.offsetX = xPos - chatX oocMoveDrag.offsetY = yPos - chatY consumeWindowMessage(true, false, true) return end local oocId = oocEntryManager:getEntryIdByScreenCoords(xPos, yPos) if oocId > 0 and nrpMessages[oocId] then lineDragState.pending = true lineDragState.active = false lineDragState.sourceChat = "OOC" lineDragState.sourceId = oocId lineDragState.targetChat = "OOC" lineDragState.targetId = oocId lineDragState.startX = xPos lineDragState.startY = yPos lineDragState.currentX = xPos lineDragState.currentY = yPos consumeWindowMessage(true, false, true) return end end local entryId = entryManager:getEntryIdByScreenCoords(xPos, yPos) if entryId > -1 and pChat.m_entry[entryId].m_textColor ~= 0 then lineDragState.pending = true lineDragState.active = false lineDragState.sourceChat = (activeChatTab == "OOC" and "OOC" or "IC") lineDragState.sourceId = entryId lineDragState.targetChat = lineDragState.sourceChat lineDragState.targetId = entryId lineDragState.startX = xPos lineDragState.startY = yPos lineDragState.currentX = xPos lineDragState.currentY = yPos selectedEntry = entryId selectedEntrySource = (nrpMode == "tab" and activeChatTab == "OOC") and "TAB_OOC" or "MAIN" captureSelectedNrpMessage() consumeWindowMessage(true, false, true) return end end end end -- 2. WM_MOUSEMOVE (0x0200) if msg == 0x0200 then if dragging or lineDragState.pending or lineDragState.active then consumeWindowMessage(true, false, true) return end local cursorMode = getSampCursorMode() local isEdit = popupActive or editLineActive if isInputOpen() or (cursorMode >= 2 and cursorMode <= 3) then if not isEdit then local nrpId = (nrpEnabled and nrpMode == "separate" and not chatHidden and visibilityMode ~= 2) and oocEntryManager:getEntryIdByScreenCoords(xPos, yPos) or -1 if nrpId > 0 and nrpMessages[nrpId] then selectedEntry = nrpId selectedEntrySource = "NRP" captureSelectedNrpMessage() else local entryId = entryManager:getEntryIdByScreenCoords(xPos, yPos) if entryId > -1 and pChat ~= nil and pChat.m_entry[entryId].m_textColor ~= 0 then selectedEntry = entryId selectedEntrySource = (nrpMode == "tab" and activeChatTab == "OOC") and "TAB_OOC" or "MAIN" captureSelectedNrpMessage() else selectedEntry = -1 selectedNrpMessage = nil end end end elseif not isEdit then selectedEntry = -1 selectedNrpMessage = nil end end -- 3. WM_LBUTTONUP (0x0202) if msg == 0x0202 then tabDetachDrag.pending = false tabDetachDrag.active = false if dragging then dragging = false if offsetX == 0 and offsetY == 0 and chatUpdate then chatUpdate() end consumeWindowMessage(true, false, true) return end if oocMoveDrag.active then oocMoveDrag.active = false tabDetachDrag.pending = false tabDetachDrag.active = false if isPointInsideMainChat(xPos, yPos) then nrpMode = "tab" activeChatTab = "IC" rebuildOocBackupFromMessages() saveNrpConfig() if sampAddChatMessage then sampAddChatMessage("{44AAFF}[NRPChat]{FFFFFF} OOC-чат объединён с основным чатом (Вкладки IC / OOC)", -1) end else saveNrpConfig() end consumeWindowMessage(true, false, true) return end if lineDragState.active or lineDragState.pending then if lineDragState.active then if lineDragState.sourceChat == "IC" then if nrpMode == "separate" and isPointInsideOocChat(xPos, yPos) then transferLineIcToOoc(lineDragState.sourceId) elseif isPointInsideTabOOC(xPos, yPos) or lineDragState.targetChat == "tab_OOC" then transferLineIcToOoc(lineDragState.sourceId) elseif lineDragState.targetId > -1 and lineDragState.targetId ~= lineDragState.sourceId then moveChatEntry(lineDragState.sourceId, lineDragState.targetId) if nrpMode == "tab" then local entrySize = ffi.sizeof("CChatEntry") ffi.copy(icBackupEntries, pChat.m_entry, 100 * entrySize) end selectedEntry = lineDragState.targetId end elseif lineDragState.sourceChat == "OOC" then if nrpMode == "separate" and isPointInsideMainChat(xPos, yPos) then transferLineOocToIc(lineDragState.sourceId) elseif isPointInsideTabIC(xPos, yPos) or lineDragState.targetChat == "tab_IC" then if nrpMode == "tab" then transferOocTabToIc(lineDragState.sourceId) else transferLineOocToIc(lineDragState.sourceId) end elseif lineDragState.targetId > -1 and lineDragState.targetId ~= lineDragState.sourceId then if nrpMode == "separate" then if lineDragState.sourceId >= 1 and lineDragState.sourceId <= #nrpMessages and lineDragState.targetId >= 1 and lineDragState.targetId <= #nrpMessages then local item = table.remove(nrpMessages, lineDragState.sourceId) table.insert(nrpMessages, lineDragState.targetId, item) rebuildOocBackupFromMessages() end else moveChatEntry(lineDragState.sourceId, lineDragState.targetId) local entrySize = ffi.sizeof("CChatEntry") ffi.copy(oocBackupEntries, pChat.m_entry, 100 * entrySize) selectedEntry = lineDragState.targetId end end end end lineDragState.active = false lineDragState.pending = false lineDragState.sourceId = -1 lineDragState.targetId = -1 consumeWindowMessage(true, false, true) return end end -- 4. WM_RBUTTONDOWN (0x0204) if msg == 0x0204 then local cursorMode = getSampCursorMode() local isEdit = popupActive or editLineActive if isInputOpen() or (cursorMode >= 2 and cursorMode <= 3) then if not isEdit then if nrpEnabled and nrpMode == "separate" and not chatHidden and visibilityMode ~= 2 then local nrpEntryId = oocEntryManager:getEntryIdByScreenCoords(xPos, yPos) if nrpEntryId > 0 and nrpMessages[nrpEntryId] then selectedEntry = nrpEntryId selectedEntrySource = "NRP" captureSelectedNrpMessage() openContextMenu = true consumeWindowMessage(true, false, true) return end end local entryId = entryManager:getEntryIdByScreenCoords(xPos, yPos) if entryId > -1 and pChat ~= nil and pChat.m_entry[entryId].m_textColor ~= 0 then selectedEntry = entryId selectedEntrySource = (nrpMode == "tab" and activeChatTab == "OOC") and "TAB_OOC" or "MAIN" captureSelectedNrpMessage() openContextMenu = true consumeWindowMessage(true, false, true) return end end end end -- Block Enter/Escape when edit line window is open if editLineActive then if (msg == 0x0102 or msg == 0x0101 or msg == 0x0100) and (wparam == 0x0D or wparam == 0x1B) then if msg == 0x0101 then -- WM_KEYUP if not colorPopupActive then closeEditLine() end end consumeWindowMessage(true, false) end end end mimgui.OnInitialize(function() local io = mimgui.GetIO() io.ConfigFlags = bit.bor(io.ConfigFlags, mimgui.ConfigFlags.NoMouseCursorChange) local fontPath = nil if getFolderPath then local winFonts = getFolderPath(0x14) if winFonts and #winFonts > 0 then fontPath = winFonts .. "\\arial.ttf" end end if not fontPath or (doesFileExist and not doesFileExist(fontPath)) then fontPath = "C:\\Windows\\Fonts\\arial.ttf" end if doesFileExist and doesFileExist(fontPath) then local builder = io.Fonts:GetGlyphRangesCyrillic() io.Fonts:AddFontFromFileTTF(fontPath, 14.0, nil, builder) end local style = mimgui.GetStyle() style.ItemSpacing.y = 2 end) local function drawChatMoveHandle() if pChat ~= nil and isInputOpen() then local pt = ffi.new("POINT[1]") local hasMouse = (gameHwnd ~= nil and ffi.C.GetCursorPos(pt) ~= 0 and ffi.C.ScreenToClient(gameHwnd, pt) ~= 0) local mouseNear = hasMouse and isMouseNearChat(pt[0].x, pt[0].y) or dragging if mouseNear then local l, t, r, b = getMoveHandleRect() local hovered = isMoveHandleHovered() or dragging local dl = mimgui.GetForegroundDrawList() local cx = (l + r) * 0.5 local cy = (t + b) * 0.5 local p1 = mimgui.ImVec2(cx - 5.0, cy - 5.0) local p2 = mimgui.ImVec2(cx + 5.0, cy + 5.0) local fillCol = hovered and 0xAAEBEBEB or 0x44D2D2D2 local borderCol = hovered and 0xE6EBEBEB or 0x88D2D2D2 dl:AddRectFilled(p1, p2, fillCol) dl:AddRect(p1, p2, borderCol) end end end local function drawPagesizeSliderWindow() if not isInputOpen() then sliderIsDragging = false return end if pChat ~= nil then if not mimgui.IsMouseDown(0) then tabDetachDrag.pending = false tabDetachDrag.active = false sliderIsDragging = false end local sliderExtraW = (nrpEnabled and nrpMode == "tab") and 150.0 or 0.0 local sliderX = 42 + offsetX local input = getInput() local ebH = 40 if input ~= nil and input ~= ffi.NULL then local inputBytes = ffi.cast("uint8_t*", input) local editbox = ffi.cast("uint8_t**", inputBytes + 8)[0] if editbox ~= nil and editbox ~= ffi.NULL then local h = ffi.cast("int*", editbox + 20)[0] if h > 10 and h < 200 then ebH = h end end end local sliderY = pChat.m_nWindowBottom + offsetY + ebH + 4 if gameHwnd ~= nil then local clientRect = ffi.new("RECT[1]") if ffi.C.GetClientRect(gameHwnd, clientRect) ~= 0 then local maxW = clientRect[0].right local maxH = clientRect[0].bottom sliderX = math.max(0, math.min(maxW - 510 - sliderExtraW, sliderX)) sliderY = math.max(0, math.min(maxH - 45, sliderY)) end end mimgui.SetNextWindowPos(mimgui.ImVec2(sliderX, sliderY), mimgui.Cond.Always) local sliderWinFlags = bit.bor( mimgui.WindowFlags.NoTitleBar, mimgui.WindowFlags.NoResize, mimgui.WindowFlags.NoMove, mimgui.WindowFlags.NoCollapse, mimgui.WindowFlags.NoSavedSettings, mimgui.WindowFlags.AlwaysAutoResize ) mimgui.PushStyleColor(mimgui.Col.WindowBg, mimgui.ImVec4(0.12, 0.12, 0.12, 0.85)) mimgui.PushStyleColor(mimgui.Col.Border, mimgui.ImVec4(0.35, 0.35, 0.35, 0.70)) mimgui.PushStyleVarVec2(mimgui.StyleVar.WindowPadding, mimgui.ImVec2(8.0, 5.0)) mimgui.PushStyleVarFloat(mimgui.StyleVar.WindowRounding, 4.0) if mimgui.Begin("##PagesizeSliderWindow", nil, sliderWinFlags) then if nrpEnabled and nrpMode == "tab" then local tabW_IC = 64.0 local tabW_OOC = 70.0 local tabH = 22.0 local io = mimgui.GetIO() local mx = io.MousePos.x local my = io.MousePos.y local dl = mimgui.GetWindowDrawList() local icPos = mimgui.GetCursorScreenPos() tabRect_IC.x1 = icPos.x tabRect_IC.y1 = icPos.y tabRect_IC.x2 = icPos.x + tabW_IC tabRect_IC.y2 = icPos.y + tabH local icActive = (activeChatTab == "IC") local icDropHover = isPointInsideTabIC(mx, my) and lineDragState.active and lineDragState.sourceChat == "OOC" local icDropHighlight = icDropHover or (lineDragState.active and lineDragState.sourceChat == "OOC" and lineDragState.targetChat == "tab_IC") if mimgui.InvisibleButton("##TabICBtn", mimgui.ImVec2(tabW_IC, tabH)) then if not lineDragState.active then switchChatTab("IC") end end local icHovered = mimgui.IsItemHovered() or isPointInsideTabIC(mx, my) if icDropHighlight then lineDragState.targetChat = "tab_IC" lineDragState.targetId = -1 end local icBg = icDropHighlight and 0xFF2A8844 or (icActive and 0xFF2A5E95 or (icHovered and 0xEE2A2A2A or 0xEE1E1E1E)) local icBorder = (icDropHighlight or icActive or icHovered) and 0xFF4A90E2 or 0xAA454545 dl:AddRectFilled(icPos, mimgui.ImVec2(icPos.x + tabW_IC, icPos.y + tabH), icBg, 4.0) dl:AddRect(icPos, mimgui.ImVec2(icPos.x + tabW_IC, icPos.y + tabH), icBorder, 4.0) local icText = u8"IC-чат" local icTextSize = mimgui.CalcTextSize(icText) dl:AddText(mimgui.ImVec2(icPos.x + (tabW_IC - icTextSize.x) * 0.5, icPos.y + (tabH - icTextSize.y) * 0.5), 0xFFFFFFFF, icText) if icDropHighlight then mimgui.SetTooltip(u8"Отпустите для переноса строки в IC-чат") end mimgui.SameLine(0, 4) local oocPos = mimgui.GetCursorScreenPos() tabRect_OOC.x1 = oocPos.x tabRect_OOC.y1 = oocPos.y tabRect_OOC.x2 = oocPos.x + tabW_OOC tabRect_OOC.y2 = oocPos.y + tabH local oocActive = (activeChatTab == "OOC") local oocDropHover = isPointInsideTabOOC(mx, my) and lineDragState.active and lineDragState.sourceChat == "IC" local oocDropHighlight = oocDropHover or (lineDragState.active and lineDragState.sourceChat == "IC" and lineDragState.targetChat == "tab_OOC") if mimgui.InvisibleButton("##TabOOCBtn", mimgui.ImVec2(tabW_OOC, tabH)) then if not lineDragState.active then switchChatTab("OOC") end end local oocHovered = mimgui.IsItemHovered() or isPointInsideTabOOC(mx, my) if oocDropHighlight then lineDragState.targetChat = "tab_OOC" lineDragState.targetId = -1 end if mimgui.IsMouseClicked(0) and oocHovered and not lineDragState.active then tabDetachDrag.pending = true tabDetachDrag.startX = mx tabDetachDrag.startY = my end if tabDetachDrag.pending then if mimgui.IsMouseDown(0) then local distSq = (mx - tabDetachDrag.startX)^2 + (my - tabDetachDrag.startY)^2 if distSq > 400 then tabDetachDrag.pending = false tabDetachDrag.active = false nrpMode = "separate" if activeChatTab == "OOC" then switchChatTab("IC") end chatX = math.floor(mx - 40) chatY = math.floor(my - 10) oocMoveDrag.active = true oocMoveDrag.offsetX = 40 oocMoveDrag.offsetY = 10 saveNrpConfig() end else tabDetachDrag.pending = false tabDetachDrag.active = false end end local oocBg = oocDropHighlight and 0xFF2A8844 or (oocActive and 0xFF2A5E95 or (oocHovered and 0xEE2A2A2A or 0xEE1E1E1E)) local oocBorder = (oocDropHighlight or oocActive or oocHovered) and 0xFF4A90E2 or 0xAA454545 dl:AddRectFilled(oocPos, mimgui.ImVec2(oocPos.x + tabW_OOC, oocPos.y + tabH), oocBg, 4.0) dl:AddRect(oocPos, mimgui.ImVec2(oocPos.x + tabW_OOC, oocPos.y + tabH), oocBorder, 4.0) local oocText = u8"OOC-чат" local oocTextSize = mimgui.CalcTextSize(oocText) dl:AddText(mimgui.ImVec2(oocPos.x + (tabW_OOC - oocTextSize.x) * 0.5, oocPos.y + (tabH - oocTextSize.y) * 0.5), 0xFFFFFFFF, oocText) if oocDropHighlight then mimgui.SetTooltip(u8"Отпустите для переноса строки в OOC-чат") elseif oocHovered and not lineDragState.active then mimgui.SetTooltip(u8"Клик: переключить на OOC-чат\nЗажмите и потяните: отделить в отдельное окно") end mimgui.SameLine(0, 8) end if not sliderIsDragging then if pChat.m_nPageSize >= 1 and pChat.m_nPageSize <= 50 then currentPageSize[0] = pChat.m_nPageSize end end local curPos = mimgui.GetCursorScreenPos() local sliderW = 400.0 local sliderH = 22.0 local thumbW = 28.0 local travel = sliderW - thumbW local ratio = math.max(0.0, math.min(1.0, (currentPageSize[0] - 1) / 49.0)) local thumbX = curPos.x + ratio * travel local thumbY = curPos.y local io = mimgui.GetIO() local mx = io.MousePos.x local my = io.MousePos.y local isMouseDown = mimgui.IsMouseDown(0) local thumbHovered = (mx >= thumbX and mx <= thumbX + thumbW and my >= thumbY and my <= thumbY + sliderH) local trackHovered = (mx >= curPos.x and mx <= curPos.x + sliderW and my >= thumbY and my <= thumbY + sliderH) if mimgui.IsMouseClicked(0) then if thumbHovered then sliderIsDragging = true sliderDragOffset = mx - thumbX else sliderIsDragging = false end end if sliderIsDragging then if isMouseDown then local targetThumbX = mx - sliderDragOffset local newRatio = math.max(0.0, math.min(1.0, (targetThumbX - curPos.x) / travel)) local newVal = math.floor(1 + newRatio * 49 + 0.5) newVal = math.max(1, math.min(50, newVal)) if newVal ~= currentPageSize[0] then currentPageSize[0] = newVal applyPageSize(newVal) end thumbX = curPos.x + newRatio * travel else sliderIsDragging = false end end local dl = mimgui.GetWindowDrawList() dl:AddRectFilled(curPos, mimgui.ImVec2(curPos.x + sliderW, curPos.y + sliderH), 0xEE1E1E1E, 4.0) dl:AddRect(curPos, mimgui.ImVec2(curPos.x + sliderW, curPos.y + sliderH), 0xAA454545, 4.0) dl:AddRectFilled(curPos, mimgui.ImVec2(thumbX + thumbW * 0.5, curPos.y + sliderH), 0x664080C0, 4.0) local thumbCol = sliderIsDragging and 0xFF5FA8F5 or (thumbHovered and 0xFF4A90E2 or 0xFF357ABD) dl:AddRectFilled(mimgui.ImVec2(thumbX, thumbY), mimgui.ImVec2(thumbX + thumbW, thumbY + sliderH), thumbCol, 4.0) dl:AddRect(mimgui.ImVec2(thumbX, thumbY), mimgui.ImVec2(thumbX + thumbW, thumbY + sliderH), 0xFFFFFFFF, 4.0) dl:AddLine(mimgui.ImVec2(thumbX + 11.0, thumbY + 5.0), mimgui.ImVec2(thumbX + 11.0, thumbY + 17.0), 0xAAFFFFFF) dl:AddLine(mimgui.ImVec2(thumbX + 17.0, thumbY + 5.0), mimgui.ImVec2(thumbX + 17.0, thumbY + 17.0), 0xAAFFFFFF) local labelText = string.format("pagesize: %d", currentPageSize[0]) local textSize = mimgui.CalcTextSize(labelText) local textPos = mimgui.ImVec2(curPos.x + (sliderW - textSize.x) * 0.5, curPos.y + (sliderH - textSize.y) * 0.5) dl:AddText(textPos, 0xFFFFFFFF, labelText) mimgui.Dummy(mimgui.ImVec2(sliderW, sliderH)) mimgui.SameLine(0, 6) local btnPos = mimgui.GetCursorScreenPos() local btnW = 28.0 local btnH = sliderH if mimgui.InvisibleButton("##CameraBtn", mimgui.ImVec2(btnW, btnH)) then takeChatScreenshot() end local btnHovered = mimgui.IsItemHovered() local btnActive = mimgui.IsItemActive() local btnBgCol = btnActive and 0xFF2A5E95 or (btnHovered and 0xEE2A2A2A or 0xEE1E1E1E) local btnBorderCol = (btnHovered or btnActive) and 0xFF4A90E2 or 0xAA454545 local iconCol = (btnHovered or btnActive) and 0xFF5FA8F5 or 0xCCD2D2D2 dl:AddRectFilled(btnPos, mimgui.ImVec2(btnPos.x + btnW, btnPos.y + btnH), btnBgCol, 4.0) dl:AddRect(btnPos, mimgui.ImVec2(btnPos.x + btnW, btnPos.y + btnH), btnBorderCol, 4.0) local cx = btnPos.x + btnW * 0.5 local cy = btnPos.y + btnH * 0.5 dl:AddRect(mimgui.ImVec2(cx - 7.0, cy - 3.5), mimgui.ImVec2(cx + 7.0, cy + 6.0), iconCol, 2.0, 15, 1.4) dl:AddCircle(mimgui.ImVec2(cx, cy + 1.2), 2.8, iconCol, 12, 1.4) dl:AddRectFilled(mimgui.ImVec2(cx - 4.5, cy - 5.5), mimgui.ImVec2(cx - 1.5, cy - 3.0), iconCol, 1.0) dl:AddCircleFilled(mimgui.ImVec2(cx + 4.0, cy - 1.5), 1.0, iconCol) if btnHovered then mimgui.SetTooltip(u8(string.format("Сделать скриншот чата (%s)", getHotkeyString()))) end mimgui.SameLine(0, 6) local setBtnPos = mimgui.GetCursorScreenPos() local setBtnW = 28.0 local setBtnH = sliderH if mimgui.InvisibleButton("##SettingsBtn", mimgui.ImVec2(setBtnW, setBtnH)) then showSettingsWindow[0] = not showSettingsWindow[0] if showSettingsWindow[0] then if getSampFontSizeParam then settingsUI.cfgFontSize[0] = math.floor(getSampFontSizeParam() + 0.5) else settingsUI.cfgFontSize[0] = readSampCfg().fontsize end if pChat ~= nil and pChat ~= ffi.NULL then settingsUI.cfgPageSize[0] = pChat.m_nPageSize else settingsUI.cfgPageSize[0] = readSampCfg().pagesize end settingsUI.nrpScale[0] = chatScale settingsUI.nrpRows[0] = chatRows settingsUI.nrpLength[0] = maxLineLength settingsUI.nrpTime[0] = showTime settingsUI.nrpCk[0] = afkAsRp settingsUI.nrpHelp[0] = showHelp settingsUI.nrpEnabled[0] = nrpEnabled settingsUI.nrpPosX[0] = math.floor(chatX) settingsUI.nrpPosY[0] = math.floor(chatY) else settingsUI.waitingTarget = nil end end local setBtnHovered = mimgui.IsItemHovered() local setBtnActive = mimgui.IsItemActive() local setBtnBgCol = setBtnActive and 0xFF2A5E95 or (setBtnHovered and 0xEE2A2A2A or 0xEE1E1E1E) local setBtnBorderCol = (setBtnHovered or setBtnActive) and 0xFF4A90E2 or 0xAA454545 local setIconCol = (setBtnHovered or setBtnActive) and 0xFF5FA8F5 or 0xCCD2D2D2 dl:AddRectFilled(setBtnPos, mimgui.ImVec2(setBtnPos.x + setBtnW, setBtnPos.y + setBtnH), setBtnBgCol, 4.0) dl:AddRect(setBtnPos, mimgui.ImVec2(setBtnPos.x + setBtnW, setBtnPos.y + setBtnH), setBtnBorderCol, 4.0) local scx = setBtnPos.x + setBtnW * 0.5 local scy = setBtnPos.y + setBtnH * 0.5 dl:AddCircle(mimgui.ImVec2(scx, scy), 3.0, setIconCol, 12, 1.4) for a = 0, 7 do local angle = a * (math.pi / 4.0) local r1 = 3.5 local r2 = 6.0 dl:AddLine( mimgui.ImVec2(scx + math.cos(angle) * r1, scy + math.sin(angle) * r1), mimgui.ImVec2(scx + math.cos(angle) * r2, scy + math.sin(angle) * r2), setIconCol, 1.8 ) end if setBtnHovered then mimgui.SetTooltip(u8"Настройки ChatLua & NRPChat") end if trackHovered then mimgui.SetTooltip(u8"Зажмите ползунок и потяните для изменения pagesize (1 - 50)") end mimgui.End() end mimgui.PopStyleVar(2) mimgui.PopStyleColor(2) end end local function drawFloatingOocOverlay() if nrpEnabled and nrpMode == "separate" and isInputOpen() then local dl = mimgui.GetForegroundDrawList() local handleW = 62.0 local handleH = 20.0 local hx = chatX local hy = chatY - handleH - 4 local pt = ffi.new("POINT[1]") local hasMouse = (gameHwnd ~= nil and ffi.C.GetCursorPos(pt) ~= 0 and ffi.C.ScreenToClient(gameHwnd, pt) ~= 0) local mx = hasMouse and pt[0].x or 0 local my = hasMouse and pt[0].y or 0 local handleHovered = (mx >= hx and mx <= hx + handleW and my >= hy and my <= hy + handleH) local headerBg = (handleHovered or oocMoveDrag.active) and 0xEE2A5E95 or 0xCC1E1E1E local headerBorder = (handleHovered or oocMoveDrag.active) and 0xFF4A90E2 or 0xAA454545 dl:AddRectFilled(mimgui.ImVec2(hx, hy), mimgui.ImVec2(hx + handleW, hy + handleH), headerBg, 3.0) dl:AddRect(mimgui.ImVec2(hx, hy), mimgui.ImVec2(hx + handleW, hy + handleH), headerBorder, 3.0) dl:AddText(mimgui.ImVec2(hx + 6, hy + 3), 0xFFFFFFFF, u8"OOC-чат") if oocMoveDrag.active and isPointInsideMainChat(mx, my) then local x1, y1, x2, y2 = getMainChatRect() dl:AddRectFilled(mimgui.ImVec2(x1, y1), mimgui.ImVec2(x2, y2), 0x444A90E2, 6.0) dl:AddRect(mimgui.ImVec2(x1, y1), mimgui.ImVec2(x2, y2), 0xFF5FA8F5, 6.0, 15, 2.5) local snapText = u8"Отпустите для объединения в основной чат (Вкладки IC / OOC)" local stSize = mimgui.CalcTextSize(snapText) local stx = x1 + (x2 - x1 - stSize.x) * 0.5 local sty = y1 + (y2 - y1 - stSize.y) * 0.5 dl:AddRectFilled(mimgui.ImVec2(stx - 8, sty - 4), mimgui.ImVec2(stx + stSize.x + 8, sty + stSize.y + 4), 0xEE1E1E1E, 4.0) dl:AddText(mimgui.ImVec2(stx, sty), 0xFFFFFFFF, snapText) end end end local function drawLineDragIndicator() if lineDragState.active and lineDragState.sourceId > -1 then local dl = mimgui.GetForegroundDrawList() local badgeText = "" if lineDragState.sourceChat == "IC" or (lineDragState.sourceChat == "OOC" and nrpMode == "tab") then local srcPos = entryManager:getByEntryId(lineDragState.sourceId) if srcPos then dl:AddRectFilled(mimgui.ImVec2(srcPos.x1, srcPos.y1), mimgui.ImVec2(srcPos.x2, srcPos.y2), 0x33AAAAAA, 2.0) dl:AddRect(mimgui.ImVec2(srcPos.x1, srcPos.y1), mimgui.ImVec2(srcPos.x2, srcPos.y2), 0x88AAAAAA, 2.0) end if lineDragState.targetChat == "OOC" and nrpMode == "separate" then local lh = math.floor(18 * chatScale) local ow = math.floor(450 * chatScale) local oh = math.max(60, chatRows * lh) dl:AddRectFilled(mimgui.ImVec2(chatX - 8, chatY - 8), mimgui.ImVec2(chatX + ow + 8, chatY + oh + 8), 0x3344AA44, 4.0) dl:AddRect(mimgui.ImVec2(chatX - 8, chatY - 8), mimgui.ImVec2(chatX + ow + 8, chatY + oh + 8), 0xFF44AA44, 4.0, 15, 2.0) elseif lineDragState.targetChat == "tab_OOC" or lineDragState.targetChat == "tab_IC" then -- Do not draw reorder indicator over chat entries when dropping onto tabs! else local targetPos = entryManager:getByEntryId(lineDragState.targetId) if targetPos and lineDragState.targetId ~= lineDragState.sourceId then dl:AddRectFilled(mimgui.ImVec2(targetPos.x1, targetPos.y1), mimgui.ImVec2(targetPos.x2, targetPos.y2), 0x3344AAFF, 2.0) dl:AddRect(mimgui.ImVec2(targetPos.x1, targetPos.y1), mimgui.ImVec2(targetPos.x2, targetPos.y2), 0xAA44AAFF, 2.0) local lineY = (lineDragState.sourceId < lineDragState.targetId) and targetPos.y2 or targetPos.y1 dl:AddLine(mimgui.ImVec2(targetPos.x1 - 10, lineY), mimgui.ImVec2(targetPos.x2 + 10, lineY), 0xFF44AAFF, 2.5) dl:AddCircleFilled(mimgui.ImVec2(targetPos.x1 - 10, lineY), 3.5, 0xFF44AAFF) end end local entry = pChat.m_entry[lineDragState.sourceId] if entry.m_prefixColor ~= 0 and entry.m_szPrefix[0] ~= 0 then badgeText = badgeText .. ffi.string(entry.m_szPrefix) .. " " end badgeText = badgeText .. ffi.string(entry.m_szText) else local srcPos = oocEntryManager:getByEntryId(lineDragState.sourceId) if srcPos then dl:AddRectFilled(mimgui.ImVec2(srcPos.x1, srcPos.y1), mimgui.ImVec2(srcPos.x2, srcPos.y2), 0x33AAAAAA, 2.0) dl:AddRect(mimgui.ImVec2(srcPos.x1, srcPos.y1), mimgui.ImVec2(srcPos.x2, srcPos.y2), 0x88AAAAAA, 2.0) end if lineDragState.targetChat == "IC" then local x1, y1, x2, y2 = getMainChatRect() dl:AddRectFilled(mimgui.ImVec2(x1, y1), mimgui.ImVec2(x2, y2), 0x3344AA44, 4.0) dl:AddRect(mimgui.ImVec2(x1, y1), mimgui.ImVec2(x2, y2), 0xFF44AA44, 4.0, 15, 2.0) else local targetPos = oocEntryManager:getByEntryId(lineDragState.targetId) if targetPos and lineDragState.targetId ~= lineDragState.sourceId then dl:AddRectFilled(mimgui.ImVec2(targetPos.x1, targetPos.y1), mimgui.ImVec2(targetPos.x2, targetPos.y2), 0x3344AAFF, 2.0) dl:AddRect(mimgui.ImVec2(targetPos.x1, targetPos.y1), mimgui.ImVec2(targetPos.x2, targetPos.y2), 0xAA44AAFF, 2.0) local lineY = (lineDragState.sourceId < lineDragState.targetId) and targetPos.y2 or targetPos.y1 dl:AddLine(mimgui.ImVec2(targetPos.x1 - 10, lineY), mimgui.ImVec2(targetPos.x2 + 10, lineY), 0xFF44AAFF, 2.5) dl:AddCircleFilled(mimgui.ImVec2(targetPos.x1 - 10, lineY), 3.5, 0xFF44AAFF) end end local msg = nrpMessages[lineDragState.sourceId] if msg then badgeText = msg.text end end badgeText = badgeText:gsub("{%x%x%x%x%x%x}", "") if #badgeText > 40 then badgeText = badgeText:sub(1, 37) .. "..." end local prefixTag = (lineDragState.sourceChat == "IC" and "[IC] " or "[OOC] ") if lineDragState.targetChat ~= lineDragState.sourceChat then prefixTag = (lineDragState.sourceChat == "IC" and "[Move -> OOC] " or "[Move -> IC] ") end local displayStr = u8(prefixTag .. badgeText) local tSize = mimgui.CalcTextSize(displayStr) local bx = lineDragState.currentX + 16 local by = lineDragState.currentY + 16 local padX = 8 local padY = 5 dl:AddRectFilled(mimgui.ImVec2(bx, by), mimgui.ImVec2(bx + tSize.x + padX * 2, by + tSize.y + padY * 2), 0xF0181818, 4.0) dl:AddRect(mimgui.ImVec2(bx, by), mimgui.ImVec2(bx + tSize.x + padX * 2, by + tSize.y + padY * 2), 0xFF44AAFF, 4.0, 15, 1.5) dl:AddText(mimgui.ImVec2(bx + padX, by + padY), 0xFFFFFFFF, displayStr) end end local function drawSelectedLinePointer() if selectedEntry > -1 then local pointerEntryId = (selectedEntrySource == "NRP") and selectedEntry or getSelectedChatEntryIndex() local mgr = (selectedEntrySource == "NRP") and oocEntryManager or entryManager local entryPos = pointerEntryId and mgr:getByEntryId(pointerEntryId) or nil if entryPos ~= nil and pChat ~= nil then local winFlags = bit.bor( mimgui.WindowFlags.NoResize, mimgui.WindowFlags.NoCollapse, mimgui.WindowFlags.NoMove, mimgui.WindowFlags.NoScrollbar, mimgui.WindowFlags.NoSavedSettings, mimgui.WindowFlags.NoTitleBar, mimgui.WindowFlags.NoBackground ) local charHeight = tonumber(pChat.m_nCharHeight) local tagSize = mimgui.CalcTextSize("> ") local xPos = entryPos.x1 - tagSize.x local yPos = entryPos.y1 + math.ceil((entryPos.y2 - entryPos.y1) / 2) - getSampFontSize() + (getSampFontSizeParam() + charHeight / 6) mimgui.SetNextWindowPos(mimgui.ImVec2(xPos, yPos)) if mimgui.Begin("##SelectedLine", nil, winFlags) then mimgui.SetCursorPosX(0) mimgui.Text(">") mimgui.End() end end end end local function drawContextMenu() local wrapperFlags = bit.bor( mimgui.WindowFlags.NoResize, mimgui.WindowFlags.NoCollapse, mimgui.WindowFlags.NoScrollbar, mimgui.WindowFlags.NoSavedSettings, mimgui.WindowFlags.NoBackground, mimgui.WindowFlags.NoTitleBar ) if mimgui.Begin("##PopupWrapper", nil, wrapperFlags) then if openContextMenu and not mimgui.IsPopupOpen("TextContextMenu") then mimgui.OpenPopup("TextContextMenu") openContextMenu = false popupActive = true elseif popupActive and (not mimgui.IsPopupOpen("TextContextMenu") or selectedEntry < 0) then mimgui.CloseCurrentPopup() popupActive = false end if mimgui.BeginPopupContextItem("TextContextMenu") then local nrpMessageIndex = getSelectedNrpMessageIndex() local chatEntryIndex = getSelectedChatEntryIndex() if (nrpMessageIndex ~= nil) or (chatEntryIndex ~= nil) then mimgui.PushItemWidth(350.0) mimgui.PushStyleVarVec2(mimgui.StyleVar.ItemSpacing, mimgui.ImVec2(12.0, 12.0)) mimgui.PushStyleColor(mimgui.Col.PopupBg, mimgui.ImVec4(0.12, 0.12, 0.12, 0.9)) mimgui.PushStyleColor(mimgui.Col.HeaderHovered, mimgui.ImVec4(0.5, 0.5, 0.5, 1.0)) if mimgui.MenuItemBool(u8"Копировать (Copy)") then local with_colors = (ffi.C.GetKeyState(0x10) < 0) -- VK_SHIFT held local text if nrpMessageIndex then text = nrpMessages[nrpMessageIndex].text else local entry = pChat.m_entry[chatEntryIndex] text = "" if entry.m_prefixColor ~= 0 and entry.m_szPrefix[0] ~= 0 then text = text .. ffi.string(entry.m_szPrefix) .. " " end text = text .. ffi.string(entry.m_szText) end if not with_colors then text = text:gsub("{%x%x%x%x%x%x}", "") end setClipboardText(text) end if mimgui.MenuItemBool(u8"Удалить (Delete)") then if nrpMessageIndex then table.remove(nrpMessages, nrpMessageIndex) scrollOffset = math.min(scrollOffset, math.max(0, #nrpMessages - chatRows)) refreshOocMessageBuffer() else sampDeleteChatLine(chatEntryIndex) if nrpMode == "tab" then local entrySize = ffi.sizeof("CChatEntry") if activeChatTab == "OOC" then ffi.copy(oocBackupEntries, pChat.m_entry, 100 * entrySize) else ffi.copy(icBackupEntries, pChat.m_entry, 100 * entrySize) end end end selectedEntry = -1 selectedChatEntryIdentity = nil selectedNrpMessage = nil end if mimgui.MenuItemBool(u8"Редактировать (Edit)") then if nrpMessageIndex then local entry = nrpMessages[nrpMessageIndex] ffi.copy(editLineBuffer, u8(entry.text)) ARGBToFloat4(entry.baseColor or 0xFFFFFFFF, editLineColor) else local entry = pChat.m_entry[chatEntryIndex] local textUtf8 = u8(ffi.string(entry.m_szText)) ffi.copy(editLineBuffer, textUtf8) ARGBToFloat4(entry.m_textColor, editLineColor) local prefixUtf8 = u8(ffi.string(entry.m_szPrefix)) ffi.copy(editPrefixBuffer, prefixUtf8) ARGBToFloat4(entry.m_prefixColor, editPrefixColor) end editLineActive = true end if mimgui.MenuItemBool(u8"Очистить весь чат (Clear)") then if nrpMessageIndex then nrpMessages = {} oocEntryMessageRefs = {} scrollOffset = 0 refreshOocMessageBuffer() else sampDeleteChatLineAll() end selectedEntry = -1 selectedChatEntryIdentity = nil selectedNrpMessage = nil end mimgui.PopItemWidth() mimgui.PopStyleVar() mimgui.PopStyleColor(2) end mimgui.EndPopup() end mimgui.End() end end local function drawEditLineDialog() local nrpMessageIndex = getSelectedNrpMessageIndex() local chatEntryIndex = getSelectedChatEntryIndex() if editLineActive and nrpMessageIndex == nil and chatEntryIndex == nil then editLineActive = false selectedEntry = -1 selectedChatEntryIdentity = nil return end if editLineActive then local io = mimgui.GetIO() mimgui.PushStyleColor(mimgui.Col.FrameBg, mimgui.ImVec4(0.12, 0.12, 0.12, 0.9)) local editFlags = bit.bor( mimgui.WindowFlags.NoResize, mimgui.WindowFlags.NoCollapse, mimgui.WindowFlags.NoMove, mimgui.WindowFlags.NoScrollbar, mimgui.WindowFlags.NoSavedSettings, mimgui.WindowFlags.NoTitleBar, mimgui.WindowFlags.NoBackground ) local editOpen = ffi.new("bool[1]", editLineActive) mimgui.SetNextWindowPos(mimgui.ImVec2(io.DisplaySize.x * 0.5, io.DisplaySize.y * 0.5), mimgui.Cond.Appearing, mimgui.ImVec2(0.5, 0.5)) if mimgui.Begin("##EditLine", editOpen, editFlags) then mimgui.PushStyleVarVec2(mimgui.StyleVar.ItemSpacing, mimgui.ImVec2(8.0, 4.0)) mimgui.PushStyleVarVec2(mimgui.StyleVar.FramePadding, mimgui.ImVec2(4.0, 12.0)) mimgui.PushStyleColor(mimgui.Col.TextSelectedBg, mimgui.ImVec4(0.5, 0.5, 0.5, 1.0)) mimgui.PushItemWidth(io.DisplaySize.x * (750.0 / 1920.0)) local hasPrefix = not nrpMessageIndex and pChat.m_entry[chatEntryIndex].m_prefixColor ~= 0 and pChat.m_entry[chatEntryIndex].m_szPrefix[0] ~= 0 if hasPrefix then if mimgui.ColorButton("##PrefixColorBtn", mimgui.ImVec4(editPrefixColor[0], editPrefixColor[1], editPrefixColor[2], editPrefixColor[3]), mimgui.ColorEditFlags.NoTooltip) then mimgui.OpenPopup("##PrefixColorPopup") end if mimgui.BeginPopup("##PrefixColorPopup") then if mimgui.ColorPicker3("##PrefixPicker", editPrefixColor, bit.bor(mimgui.ColorEditFlags.NoSidePreview, mimgui.ColorEditFlags.NoSmallPreview)) then pChat.m_entry[chatEntryIndex].m_prefixColor = Float4ToARGB(editPrefixColor) captureSelectedNrpMessage() if nrpMode == "tab" then local backup = (activeChatTab == "OOC") and oocBackupEntries or icBackupEntries ffi.copy(backup + chatEntryIndex, pChat.m_entry + chatEntryIndex, ffi.sizeof("CChatEntry")) end if chatUpdate then chatUpdate() end end mimgui.EndPopup() end mimgui.SameLine(0, 5.0) if mimgui.InputText("##PrefixText", editPrefixBuffer, 64) then local prefixCp = u8:decode(ffi.string(editPrefixBuffer)) local copyLen = math.min(27, #prefixCp) ffi.copy(pChat.m_entry[chatEntryIndex].m_szPrefix, prefixCp, copyLen) pChat.m_entry[chatEntryIndex].m_szPrefix[copyLen] = 0 captureSelectedNrpMessage() if nrpMode == "tab" then local backup = (activeChatTab == "OOC") and oocBackupEntries or icBackupEntries ffi.copy(backup + chatEntryIndex, pChat.m_entry + chatEntryIndex, ffi.sizeof("CChatEntry")) end if chatUpdate then chatUpdate() end end local pPos = mimgui.GetItemRectMin() local pSize = mimgui.GetItemRectSize() mimgui.GetWindowDrawList():AddRect(pPos, mimgui.ImVec2(pPos.x + pSize.x, pPos.y + pSize.y), 0xFFFFFFFF) end if mimgui.ColorButton("ColorButton", mimgui.ImVec4(editLineColor[0], editLineColor[1], editLineColor[2], editLineColor[3]), mimgui.ColorEditFlags.NoTooltip) then mimgui.OpenPopup("ColorPickerPopup") end if mimgui.BeginPopup("ColorPickerPopup") then colorPopupActive = true if mimgui.ColorPicker3("##picker", editLineColor, bit.bor(mimgui.ColorEditFlags.NoSidePreview, mimgui.ColorEditFlags.NoSmallPreview)) then if nrpMessageIndex then nrpMessages[nrpMessageIndex].baseColor = Float4ToARGB(editLineColor) refreshOocMessageBuffer() else pChat.m_entry[chatEntryIndex].m_textColor = Float4ToARGB(editLineColor) captureSelectedNrpMessage() if nrpMode == "tab" then local backup = (activeChatTab == "OOC") and oocBackupEntries or icBackupEntries ffi.copy(backup + chatEntryIndex, pChat.m_entry + chatEntryIndex, ffi.sizeof("CChatEntry")) end end if chatUpdate then chatUpdate() end end mimgui.EndPopup() else colorPopupActive = false end mimgui.SameLine(0, 5.0) if mimgui.InputText("##ChatLine", editLineBuffer, 256) then local textCp = u8:decode(ffi.string(editLineBuffer)) if nrpMessageIndex then nrpMessages[nrpMessageIndex].text = textCp refreshOocMessageBuffer() else local copyLen = math.min(143, #textCp) ffi.copy(pChat.m_entry[chatEntryIndex].m_szText, textCp, copyLen) pChat.m_entry[chatEntryIndex].m_szText[copyLen] = 0 captureSelectedNrpMessage() if nrpMode == "tab" then local backup = (activeChatTab == "OOC") and oocBackupEntries or icBackupEntries ffi.copy(backup + chatEntryIndex, pChat.m_entry + chatEntryIndex, ffi.sizeof("CChatEntry")) end end if chatUpdate then chatUpdate() end end local inputPos = mimgui.GetItemRectMin() local inputSize = mimgui.GetItemRectSize() mimgui.GetWindowDrawList():AddRect(inputPos, mimgui.ImVec2(inputPos.x + inputSize.x, inputPos.y + inputSize.y), 0xFFFFFFFF) mimgui.PopItemWidth() mimgui.PopStyleVar(2) mimgui.PopStyleColor() mimgui.End() end mimgui.PopStyleColor() editLineActive = editOpen[0] end end local function drawSettingsWindow() if not showSettingsWindow[0] then return end local io = mimgui.GetIO() mimgui.SetNextWindowSize(mimgui.ImVec2(560, 530), mimgui.Cond.FirstUseEver) mimgui.SetNextWindowPos(mimgui.ImVec2((io.DisplaySize.x - 560) * 0.5, (io.DisplaySize.y - 530) * 0.5), mimgui.Cond.FirstUseEver) mimgui.PushStyleColor(mimgui.Col.WindowBg, mimgui.ImVec4(0.09, 0.10, 0.13, 0.97)) mimgui.PushStyleColor(mimgui.Col.ChildBg, mimgui.ImVec4(0.06, 0.07, 0.09, 0.85)) mimgui.PushStyleColor(mimgui.Col.PopupBg, mimgui.ImVec4(0.11, 0.12, 0.16, 0.98)) mimgui.PushStyleColor(mimgui.Col.Border, mimgui.ImVec4(0.24, 0.28, 0.38, 0.65)) mimgui.PushStyleColor(mimgui.Col.FrameBg, mimgui.ImVec4(0.14, 0.16, 0.22, 0.90)) mimgui.PushStyleColor(mimgui.Col.FrameBgHovered, mimgui.ImVec4(0.20, 0.25, 0.35, 1.00)) mimgui.PushStyleColor(mimgui.Col.FrameBgActive, mimgui.ImVec4(0.26, 0.32, 0.44, 1.00)) mimgui.PushStyleColor(mimgui.Col.TitleBg, mimgui.ImVec4(0.11, 0.13, 0.18, 1.00)) mimgui.PushStyleColor(mimgui.Col.TitleBgActive, mimgui.ImVec4(0.16, 0.22, 0.34, 1.00)) mimgui.PushStyleColor(mimgui.Col.ScrollbarBg, mimgui.ImVec4(0.08, 0.09, 0.12, 0.60)) mimgui.PushStyleColor(mimgui.Col.ScrollbarGrab, mimgui.ImVec4(0.25, 0.30, 0.42, 0.80)) mimgui.PushStyleColor(mimgui.Col.ScrollbarGrabHovered, mimgui.ImVec4(0.35, 0.42, 0.58, 1.00)) mimgui.PushStyleColor(mimgui.Col.ScrollbarGrabActive, mimgui.ImVec4(0.45, 0.54, 0.72, 1.00)) mimgui.PushStyleColor(mimgui.Col.CheckMark, mimgui.ImVec4(0.35, 0.78, 1.00, 1.00)) mimgui.PushStyleColor(mimgui.Col.SliderGrab, mimgui.ImVec4(0.28, 0.58, 0.92, 1.00)) mimgui.PushStyleColor(mimgui.Col.SliderGrabActive, mimgui.ImVec4(0.40, 0.72, 1.00, 1.00)) mimgui.PushStyleColor(mimgui.Col.Button, mimgui.ImVec4(0.17, 0.23, 0.34, 0.85)) mimgui.PushStyleColor(mimgui.Col.ButtonHovered, mimgui.ImVec4(0.24, 0.35, 0.52, 1.00)) mimgui.PushStyleColor(mimgui.Col.ButtonActive, mimgui.ImVec4(0.30, 0.45, 0.68, 1.00)) mimgui.PushStyleColor(mimgui.Col.Header, mimgui.ImVec4(0.18, 0.28, 0.44, 0.75)) mimgui.PushStyleColor(mimgui.Col.HeaderHovered, mimgui.ImVec4(0.24, 0.38, 0.58, 0.90)) mimgui.PushStyleColor(mimgui.Col.HeaderActive, mimgui.ImVec4(0.30, 0.48, 0.72, 1.00)) mimgui.PushStyleColor(mimgui.Col.Separator, mimgui.ImVec4(0.22, 0.26, 0.36, 0.70)) mimgui.PushStyleColor(mimgui.Col.Tab, mimgui.ImVec4(0.13, 0.16, 0.22, 0.90)) mimgui.PushStyleColor(mimgui.Col.TabHovered, mimgui.ImVec4(0.24, 0.34, 0.50, 1.00)) mimgui.PushStyleColor(mimgui.Col.TabActive, mimgui.ImVec4(0.20, 0.40, 0.68, 1.00)) mimgui.PushStyleColor(mimgui.Col.Text, mimgui.ImVec4(0.92, 0.94, 0.97, 1.00)) mimgui.PushStyleColor(mimgui.Col.TextDisabled, mimgui.ImVec4(0.50, 0.55, 0.65, 1.00)) mimgui.PushStyleVar(mimgui.StyleVar.WindowRounding, 9.0) mimgui.PushStyleVar(mimgui.StyleVar.FrameRounding, 5.0) mimgui.PushStyleVar(mimgui.StyleVar.GrabRounding, 5.0) mimgui.PushStyleVar(mimgui.StyleVar.PopupRounding, 6.0) mimgui.PushStyleVar(mimgui.StyleVar.TabRounding, 6.0) mimgui.PushStyleVar(mimgui.StyleVar.ScrollbarRounding, 6.0) mimgui.PushStyleVarVec2(mimgui.StyleVar.WindowPadding, mimgui.ImVec2(16.0, 14.0)) mimgui.PushStyleVarVec2(mimgui.StyleVar.FramePadding, mimgui.ImVec2(8.0, 5.0)) mimgui.PushStyleVarVec2(mimgui.StyleVar.ItemSpacing, mimgui.ImVec2(8.0, 7.0)) local function renderSectionHeader(title, subtitle) mimgui.Spacing() mimgui.TextColored(mimgui.ImVec4(0.38, 0.78, 1.00, 1.00), u8(title)) if subtitle and #subtitle > 0 then mimgui.SameLine() mimgui.TextDisabled(u8(subtitle)) end mimgui.Separator() mimgui.Spacing() end local function renderKeycapButton(targetId, currentName, defaultLabel, minWidth) local isWaiting = (settingsUI.waitingTarget == targetId) local width = minWidth or 105 local label = "" if isWaiting then label = u8"Нажмите клавишу...##" .. targetId mimgui.PushStyleColor(mimgui.Col.Button, mimgui.ImVec4(0.85, 0.45, 0.10, 0.95)) mimgui.PushStyleColor(mimgui.Col.ButtonHovered, mimgui.ImVec4(0.95, 0.55, 0.15, 1.00)) mimgui.PushStyleColor(mimgui.Col.ButtonActive, mimgui.ImVec4(0.75, 0.35, 0.05, 1.00)) mimgui.PushStyleColor(mimgui.Col.Text, mimgui.ImVec4(1.0, 1.0, 1.0, 1.0)) else local text = (currentName and #currentName > 0) and currentName or defaultLabel label = u8(string.format("[ %s ]##%s", text, targetId)) mimgui.PushStyleColor(mimgui.Col.Button, mimgui.ImVec4(0.15, 0.19, 0.28, 0.90)) mimgui.PushStyleColor(mimgui.Col.ButtonHovered, mimgui.ImVec4(0.22, 0.32, 0.48, 1.00)) mimgui.PushStyleColor(mimgui.Col.ButtonActive, mimgui.ImVec4(0.28, 0.42, 0.65, 1.00)) if currentName and #currentName > 0 then mimgui.PushStyleColor(mimgui.Col.Text, mimgui.ImVec4(0.40, 0.85, 1.0, 1.0)) else mimgui.PushStyleColor(mimgui.Col.Text, mimgui.ImVec4(0.55, 0.60, 0.68, 1.0)) end end local clicked = mimgui.Button(label, mimgui.ImVec2(width, 0)) mimgui.PopStyleColor(4) if clicked then if isWaiting then settingsUI.waitingTarget = nil else settingsUI.waitingTarget = targetId end end return clicked end local function renderClearKeyButton(targetId, onClear) mimgui.PushStyleColor(mimgui.Col.Button, mimgui.ImVec4(0.35, 0.15, 0.18, 0.75)) mimgui.PushStyleColor(mimgui.Col.ButtonHovered, mimgui.ImVec4(0.55, 0.20, 0.25, 0.95)) mimgui.PushStyleColor(mimgui.Col.ButtonActive, mimgui.ImVec4(0.70, 0.25, 0.30, 1.00)) mimgui.PushStyleColor(mimgui.Col.Text, mimgui.ImVec4(0.95, 0.75, 0.75, 1.0)) local clicked = mimgui.Button(u8"X##clr_" .. targetId, mimgui.ImVec2(24, 0)) mimgui.PopStyleColor(4) if clicked and onClear then onClear() end return clicked end local windowOpen = mimgui.Begin(u8"Настройки ChatLua & NRPChat", showSettingsWindow, mimgui.WindowFlags.NoCollapse) if windowOpen then if mimgui.BeginTabBar("##SettingsTabs") then if mimgui.BeginTabItem(u8"ChatLua (Основной чат)") then renderSectionHeader("• Скриншот чата (ScreenChat)", "(быстрый снимок чата в PNG)") mimgui.Text(u8"Клавиша снимка:") mimgui.SameLine(180) renderKeycapButton("screen_k1", screenChatConfig.key1Name, "Выбрать...", 110) mimgui.SameLine() mimgui.Text(u8"Модификатор:") mimgui.SameLine() renderKeycapButton("screen_k2", screenChatConfig.key2Name, "Нет", 80) if screenChatConfig.key2 and screenChatConfig.key2 > 0 then mimgui.SameLine() renderClearKeyButton("screen_k2", function() screenChatConfig.key2 = 0 screenChatConfig.key2Name = "" saveScreenChatConfig() end) end mimgui.TextDisabled(u8"(Нажмите на кнопку и затем нужную клавишу. ESC — отмена, Backspace/X — очистить)") renderSectionHeader("• Позиция основного чата", "(перемещение окна чата)") mimgui.Text(string.format(u8"Текущее смещение: X = %d, Y = %d", offsetX, offsetY)) mimgui.Text(string.format(u8"Сохранённые координаты по умолчанию: X = %d, Y = %d", defaultChatX, defaultChatY)) if mimgui.Button(u8"Использовать текущие координаты как дефолтные##save_def_pos") then saveDefaultPosition(offsetX, offsetY) settingsUI.cfgStatusMsg = u8"Текущие координаты сохранены как дефолтные!" settingsUI.cfgStatusTimer = os.clock() + 3.0 end mimgui.SameLine() if mimgui.Button(u8"Сбросить позицию в (0, 0)##reset_pos_zero") then movePosition(-offsetX, -offsetY) saveDefaultPosition(0, 0) settingsUI.cfgStatusMsg = u8"Позиция сброшена в (0, 0)!" settingsUI.cfgStatusTimer = os.clock() + 3.0 end renderSectionHeader("• Настройки sa-mp.cfg", "(размер шрифта и страниц)") mimgui.PushItemWidth(140) mimgui.DragInt(u8"Размер шрифта (fontsize)##cfg_fs", settingsUI.cfgFontSize, 1, -3, 5) mimgui.PopItemWidth() mimgui.SameLine() if mimgui.Button(u8"Взять из игры##fs_get") then if getSampFontSizeParam then settingsUI.cfgFontSize[0] = math.floor(getSampFontSizeParam() + 0.5) else settingsUI.cfgFontSize[0] = readSampCfg().fontsize end end mimgui.SameLine() if mimgui.Button(u8"Сохранить в sa-mp.cfg##fs_save") then if writeSampCfgValue("fontsize", settingsUI.cfgFontSize[0]) then settingsUI.cfgStatusMsg = u8"Параметр fontsize сохранён в sa-mp.cfg!" settingsUI.cfgStatusTimer = os.clock() + 3.0 end end mimgui.PushItemWidth(140) mimgui.DragInt(u8"Количество строк (pagesize)##cfg_ps", settingsUI.cfgPageSize, 1, 10, 64) mimgui.PopItemWidth() mimgui.SameLine() if mimgui.Button(u8"Взять из игры##ps_get") then if pChat ~= nil and pChat ~= ffi.NULL then settingsUI.cfgPageSize[0] = pChat.m_nPageSize else settingsUI.cfgPageSize[0] = readSampCfg().pagesize end end mimgui.SameLine() if mimgui.Button(u8"Сохранить в sa-mp.cfg##ps_save") then if writeSampCfgValue("pagesize", settingsUI.cfgPageSize[0]) then applyPageSize(settingsUI.cfgPageSize[0]) settingsUI.cfgStatusMsg = u8"Параметр pagesize сохранён в sa-mp.cfg!" settingsUI.cfgStatusTimer = os.clock() + 3.0 end end if settingsUI.cfgStatusMsg and os.clock() < settingsUI.cfgStatusTimer then mimgui.Spacing() mimgui.TextColored(mimgui.ImVec4(0.2, 0.9, 0.4, 1.0), settingsUI.cfgStatusMsg) end mimgui.EndTabItem() end if mimgui.BeginTabItem(u8"NRPChat (OOC-чат)") then renderSectionHeader("• Основные параметры", "") settingsUI.nrpEnabled[0] = nrpEnabled if mimgui.Checkbox(u8"Включить OOC-чат##nrp_en", settingsUI.nrpEnabled) then nrpEnabled = settingsUI.nrpEnabled[0] saveNrpConfig() if nrpEnabled then addSystemMsg('{888888}NRP Chat -> {2E8B57}включен') else if activeChatTab == "OOC" then switchChatTab("IC") end addSystemMsg('{888888}NRP Chat -> {FF0000}отключен') end end mimgui.Text(u8"Формат отображения:") mimgui.SameLine() if mimgui.RadioButtonBool(u8"Вкладки в основном чате (IC / OOC)##mode_tab", nrpMode == "tab") then if nrpMode ~= "tab" then nrpMode = "tab" saveNrpConfig() end end mimgui.SameLine() if mimgui.RadioButtonBool(u8"Отдельное плавающее окно##mode_sep", nrpMode == "separate") then if nrpMode ~= "separate" then if activeChatTab == "OOC" then switchChatTab("IC") end nrpMode = "separate" saveNrpConfig() end end if nrpMode == "separate" then renderSectionHeader("• Позиция плавающего окна", "(координаты на экране)") settingsUI.nrpPosX[0] = math.floor(chatX) settingsUI.nrpPosY[0] = math.floor(chatY) mimgui.PushItemWidth(120) if mimgui.DragInt(u8"Координата X##nrpX", settingsUI.nrpPosX, 1, 0, 3840) then chatX = settingsUI.nrpPosX[0] end if mimgui.IsItemDeactivatedAfterEdit() then saveNrpConfig() end mimgui.SameLine() if mimgui.DragInt(u8"Координата Y##nrpY", settingsUI.nrpPosY, 1, 0, 2160) then chatY = settingsUI.nrpPosY[0] end if mimgui.IsItemDeactivatedAfterEdit() then saveNrpConfig() end mimgui.PopItemWidth() mimgui.SameLine() if mimgui.Button(u8"Сбросить в (930, 10)##nrp_pos_def") then chatX, chatY = 930, 10 saveNrpConfig() end end renderSectionHeader("• Внешний вид и размер", "") settingsUI.nrpScale[0] = chatScale mimgui.PushItemWidth(180) if mimgui.SliderFloat(u8"Масштаб шрифта##nrpScale", settingsUI.nrpScale, 0.5, 1.5, "%.2f") then chatScale = settingsUI.nrpScale[0] if fontText then renderReleaseFont(fontText) end fontText = renderCreateFont('Arial', math.max(8, math.floor(11 * chatScale)), 1) end if mimgui.IsItemDeactivatedAfterEdit() then saveNrpConfig() end settingsUI.nrpRows[0] = chatRows if mimgui.SliderInt(u8"Количество строк (pagesize)##nrpRows", settingsUI.nrpRows, 5, 64) then chatRows = settingsUI.nrpRows[0] end if mimgui.IsItemDeactivatedAfterEdit() then saveNrpConfig() end settingsUI.nrpLength[0] = maxLineLength if mimgui.SliderInt(u8"Макс. длина строки##nrpLength", settingsUI.nrpLength, 40, 150) then maxLineLength = settingsUI.nrpLength[0] end if mimgui.IsItemDeactivatedAfterEdit() then saveNrpConfig() end mimgui.PopItemWidth() renderSectionHeader("• Опции фильтрации и чата", "") settingsUI.nrpTime[0] = showTime if mimgui.Checkbox(u8"Показывать время (timestamp)##nrpTime", settingsUI.nrpTime) then showTime = settingsUI.nrpTime[0] saveNrpConfig() end mimgui.SameLine() settingsUI.nrpCk[0] = afkAsRp if mimgui.Checkbox(u8"Засчитывать AFK как RP (Character Kill)##nrpCk", settingsUI.nrpCk) then afkAsRp = settingsUI.nrpCk[0] saveNrpConfig() end settingsUI.nrpHelp[0] = showHelp if mimgui.Checkbox(u8"Показывать подсказки при входе##nrpHelp", settingsUI.nrpHelp) then showHelp = settingsUI.nrpHelp[0] saveNrpConfig() end mimgui.SameLine() if mimgui.Button(u8"Очистить сообщения OOC##nrp_clear") then showWatching() addSystemMsg('{888888}NRP Chat -> {2E8B57}Чат очищен') end renderSectionHeader("• Горячие клавиши OOC-чата", "(нажмите на кнопку для изменения)") mimgui.Text(u8"Вкл / выкл OOC-чат:") mimgui.SameLine(220) renderKeycapButton("nrp_toggleMod", nrpHotkeys.toggleModName, "Нет", 75) if nrpHotkeys.toggleMod and nrpHotkeys.toggleMod > 0 then mimgui.SameLine() renderClearKeyButton("nrp_toggleMod", function() nrpHotkeys.toggleMod = 0; nrpHotkeys.toggleModName = ""; saveNrpConfig() end) end mimgui.SameLine() mimgui.Text("+") mimgui.SameLine() renderKeycapButton("nrp_toggleKey", nrpHotkeys.toggleKeyName, "Выбрать...", 95) if nrpHotkeys.toggleKey and nrpHotkeys.toggleKey > 0 then mimgui.SameLine() renderClearKeyButton("nrp_toggleKey", function() nrpHotkeys.toggleKey = 0; nrpHotkeys.toggleKeyName = ""; saveNrpConfig() end) end mimgui.Text(u8"Скрыть / показать окно:") mimgui.SameLine(220) renderKeycapButton("nrp_hideMod", nrpHotkeys.hideModName, "Нет", 75) if nrpHotkeys.hideMod and nrpHotkeys.hideMod > 0 then mimgui.SameLine() renderClearKeyButton("nrp_hideMod", function() nrpHotkeys.hideMod = 0; nrpHotkeys.hideModName = ""; saveNrpConfig() end) end mimgui.SameLine() mimgui.Text("+") mimgui.SameLine() renderKeycapButton("nrp_hideKey", nrpHotkeys.hideKeyName, "Выбрать...", 95) if nrpHotkeys.hideKey and nrpHotkeys.hideKey > 0 then mimgui.SameLine() renderClearKeyButton("nrp_hideKey", function() nrpHotkeys.hideKey = 0; nrpHotkeys.hideKeyName = ""; saveNrpConfig() end) end mimgui.Text(u8"Смена режима видимости:") mimgui.SameLine(220) renderKeycapButton("nrp_modeKey", nrpHotkeys.modeKeyName, "Выбрать...", 95) if nrpHotkeys.modeKey and nrpHotkeys.modeKey > 0 then mimgui.SameLine() renderClearKeyButton("nrp_modeKey", function() nrpHotkeys.modeKey = 0; nrpHotkeys.modeKeyName = ""; saveNrpConfig() end) end mimgui.TextDisabled(u8"Прокрутка истории: Home (вверх) / End (вниз)") renderSectionHeader("• Отслеживание RP-отыгровок (SS Follow Mode)", "") mimgui.PushItemWidth(160) mimgui.InputText(u8"##SSAddInput", settingsUI.ssPlayerBuffer, 64) mimgui.PopItemWidth() mimgui.SameLine() if mimgui.Button(u8"+ Добавить##ss_add") then local txt = ffi.string(settingsUI.ssPlayerBuffer) if #txt > 0 then local id = tonumber(txt) if id then local name = getPlayerNameById(id) if name then ssPlayers[name:gsub('_', ' ')] = true ssPlayersWithIds[name:gsub('_', ' ')] = id showFollowList() else addSystemMsg('{888888}NRP Chat -> {FF0000}Игрок с ID ' .. id .. ' не найден') end else ssPlayers[txt:gsub('_', ' ')] = true showFollowList() end ffi.fill(settingsUI.ssPlayerBuffer, 64) saveNrpConfig() end end mimgui.SameLine() if mimgui.Button(u8"+ Мой ник##ss_add_me") then local myName, myId = getLocalPlayerInfo() if myName and myId then ssPlayers[myName:gsub('_', ' ')] = true ssPlayersWithIds[myName:gsub('_', ' ')] = myId showFollowList() saveNrpConfig() end end if next(ssPlayers) then mimgui.Spacing() mimgui.BeginChild("##SSPlayersList", mimgui.ImVec2(0, 95), true) for name, _ in pairs(ssPlayers) do local idStr = ssPlayersWithIds[name] and tostring(ssPlayersWithIds[name]) or "?" mimgui.BulletText(u8(name .. " (" .. idStr .. ")")) mimgui.SameLine(mimgui.GetWindowWidth() - 36) renderClearKeyButton("del_ss_" .. name, function() ssPlayers[name] = nil ssPlayersWithIds[name] = nil saveNrpConfig() if not next(ssPlayers) then addSystemMsg('{888888}NRP Chat -> {FF0000}follow list cleared') end end) end mimgui.EndChild() if mimgui.Button(u8"Очистить весь список слежки (отключить режим)##ss_clear_all") then ssPlayers, ssPlayersWithIds, pendingDoStar = {}, {}, nil addSystemMsg('{888888}NRP Chat -> {FF0000}follow mode off') saveNrpConfig() end else mimgui.TextDisabled(u8"Список слежки пуст (показываются все RP-отыгровки рядом)") end mimgui.EndTabItem() end mimgui.EndTabBar() end end mimgui.End() mimgui.PopStyleVar(9) mimgui.PopStyleColor(28) end local renderUI = mimgui.OnFrame( function() if not scriptInitialized or isGTAMenuActive() then return false end return isInputOpen() or popupActive or editLineActive or dragging or lineDragState.active or lineDragState.pending or (getSampCursorMode() >= 2) or showSettingsWindow[0] or oocMoveDrag.active or tabDetachDrag.pending end, function() if not scriptInitialized or pChat == nil or pChat == ffi.NULL then return end if isGTAMenuActive() then return end if editLineActive or showSettingsWindow[0] then setSampCursorMode(2) end updateMoveDrag() drawChatMoveHandle() drawPagesizeSliderWindow() drawFloatingOocOverlay() drawLineDragIndicator() drawSelectedLinePointer() drawContextMenu() drawEditLineDialog() drawSettingsWindow() end ) renderUI.HideCursor = true function main() if isSampLoaded and not isSampLoaded() then return end while not isSampAvailable() do wait(100) end gameHwnd = getGameHWND() if not resolveSampSymbols() then print("[ChatLua] Error: Failed to resolve SA:MP signatures!") while true do wait(1000) end return end -- Patch /pagesize limit to 1..64: if sampAddresses.COMMAND_SET_PAGESIZE ~= 0 then setPatchBytes(sampAddresses.COMMAND_SET_PAGESIZE + 0x2A, { 0x83, 0xFE, 0x01 }) setPatchBytes(sampAddresses.COMMAND_SET_PAGESIZE + 0x2F, { 0x83, 0xFE, 0x40 }) end if sampAddresses.CHAT_SET_PAGESIZE ~= 0 then setPatchBytes(sampAddresses.CHAT_SET_PAGESIZE + 0x04, { 0x83, 0xF8, 0x01 }) end if sampAddresses.COMMAND_SET_PAGESIZE_HINT ~= 0 then setPatchBytes(sampAddresses.COMMAND_SET_PAGESIZE_HINT + 0xD, { string.byte('5'), string.byte('0') }) end fontText = renderCreateFont('Arial', math.max(8, math.floor(11 * chatScale)), 1) loadPosition() movePosition(0, 0) setupAllHooks() currentPageSize[0] = readDefaultPageSize() if pChat ~= nil and pChat ~= ffi.NULL then applyPageSize(currentPageSize[0]) end loadScreenChatConfig() loadNrpConfig() if nrpEnabled then showActivated() if showHelp then showHelpMessages() end showWatchingLine() end local function safeRegisterCommand(cmd, func) if sampUnregisterChatCommand then pcall(sampUnregisterChatCommand, cmd) end if sampRegisterChatCommand then sampRegisterChatCommand(cmd, func) end end safeRegisterCommand('ssadd', function(arg) local added = false local myName, myId = getLocalPlayerInfo() if myName and myId then ssPlayers[myName:gsub('_', ' ')], ssPlayersWithIds[myName:gsub('_', ' ')], added = true, myId, true end if arg and arg ~= '' then for idStr in arg:gmatch('%S+') do local id = tonumber(idStr) if id then local name = getPlayerNameById(id) if name then ssPlayers[name:gsub('_', ' ')], ssPlayersWithIds[name:gsub('_', ' ')], added = true, id, true else addSystemMsg('{888888}NRP Chat -> {FF0000}Invalid ID: ' .. id) end end end end if added and next(ssPlayers) then showFollowList(); saveNrpConfig() end end) safeRegisterCommand('ssdel', function(arg) if not arg or arg == '' then return end for idStr in arg:gmatch('%S+') do local id = tonumber(idStr) if id then local name = getPlayerNameById(id); if name then ssPlayers[name:gsub('_', ' ')], ssPlayersWithIds[name:gsub('_', ' ')] = nil, nil end end end if next(ssPlayers) then showFollowList() else addSystemMsg('{888888}NRP Chat -> {FF0000}follow list cleared') end saveNrpConfig() end) safeRegisterCommand('ssoff', function() ssPlayers, ssPlayersWithIds, pendingDoStar = {}, {}, nil addSystemMsg('{888888}NRP Chat -> {FF0000}follow mode off') saveNrpConfig() end) safeRegisterCommand('nrppos', function(arg) if not arg or arg == '' then addSystemMsg('{888888}/nrppos X Y {808080}(current: ' .. chatX .. ', ' .. chatY .. ', default: 930, 10)'); return end local x, y = arg:match('(%d+)%s+(%d+)') if x and y then chatX, chatY = tonumber(x), tonumber(y); saveNrpConfig(); addSystemMsg('{888888}NRP Chat -> position: ' .. chatX .. ', ' .. chatY) else addSystemMsg('{888888}/nrppos X Y {808080}(current: ' .. chatX .. ', ' .. chatY .. ', default: 930, 10)') end end) safeRegisterCommand('nrpfontsize', function(arg) if not arg or arg == '' then addSystemMsg('{888888}/nrpfontsize 0.5-1.5 {808080}(current: ' .. string.format('%.1f', chatScale) .. ', default: 1.0)'); return end local scale = tonumber(arg) if scale and scale >= 0.5 and scale <= 1.5 then chatScale = scale if fontText then renderReleaseFont(fontText) end fontText = renderCreateFont('Arial', math.max(8, math.floor(11 * scale)), 1) saveNrpConfig() addSystemMsg('{888888}NRP Chat -> font size: ' .. string.format('%.1f', scale)) else addSystemMsg('{888888}/nrpfontsize 0.5-1.5 {808080}(current: ' .. string.format('%.1f', chatScale) .. ', default: 1.0)') end end) safeRegisterCommand('nrppagesize', function(arg) if not arg or arg == '' then addSystemMsg('{888888}/nrppagesize 5-64 {808080}(current: ' .. chatRows .. ', default: 10)'); return end local rows = tonumber(arg) if rows and rows >= 5 and rows <= 64 then chatRows = rows; saveNrpConfig(); addSystemMsg('{888888}NRP Chat -> rows: ' .. rows) else addSystemMsg('{888888}/nrppagesize 5-64 {808080}(current: ' .. chatRows .. ', default: 10)') end end) safeRegisterCommand('nrptimestamp', function() showTime = not showTime; saveNrpConfig() end) safeRegisterCommand('nrpcc', function() if nrpEnabled then showWatching() end end) safeRegisterCommand('nrpck', function() afkAsRp = not afkAsRp saveNrpConfig() addSystemMsg('{888888}NRP Chat -> Character Kill as RP: ' .. (afkAsRp and '{2E8B57}ON' or '{FF0000}OFF')) end) local function toggleSettingsWindow() showSettingsWindow[0] = not showSettingsWindow[0] if showSettingsWindow[0] then if pChat ~= nil and pChat ~= ffi.NULL then settingsUI.cfgFontSize[0] = math.floor(getSampFontSizeParam() + 0.5) settingsUI.cfgPageSize[0] = pChat.m_nPageSize else local cfg = readSampCfg() settingsUI.cfgFontSize[0] = cfg.fontsize settingsUI.cfgPageSize[0] = cfg.pagesize end settingsUI.nrpScale[0] = chatScale settingsUI.nrpRows[0] = chatRows settingsUI.nrpLength[0] = maxLineLength settingsUI.nrpTime[0] = showTime settingsUI.nrpCk[0] = afkAsRp settingsUI.nrpHelp[0] = showHelp settingsUI.nrpEnabled[0] = nrpEnabled settingsUI.nrpPosX[0] = math.floor(chatX) settingsUI.nrpPosY[0] = math.floor(chatY) else settingsUI.waitingTarget = nil end end safeRegisterCommand('chatsettings', toggleSettingsWindow) safeRegisterCommand('chatcfg', toggleSettingsWindow) safeRegisterCommand('nrpsettings', toggleSettingsWindow) safeRegisterCommand('nrpcfg', toggleSettingsWindow) scriptInitialized = true local function isDown(vk) if not vk or vk <= 0 then return false end if vk == 0x12 then return isKeyDown(0x12) or isKeyDown(0xA4) or isKeyDown(0xA5) end if vk == 0x11 then return isKeyDown(0x11) or isKeyDown(0xA2) or isKeyDown(0xA3) end if vk == 0x10 then return isKeyDown(0x10) or isKeyDown(0xA0) or isKeyDown(0xA1) end return isKeyDown(vk) end local function isJustPressed(vk) if not vk or vk <= 0 then return false end if vk == 0x12 then return isKeyJustPressed(0x12) or isKeyJustPressed(0xA4) or isKeyJustPressed(0xA5) end if vk == 0x11 then return isKeyJustPressed(0x11) or isKeyJustPressed(0xA2) or isKeyJustPressed(0xA3) end if vk == 0x10 then return isKeyJustPressed(0x10) or isKeyJustPressed(0xA0) or isKeyJustPressed(0xA1) end return isKeyJustPressed(vk) end local function isHotkeyTriggered(mod, key) if not key or key <= 0 then return false end if mod and mod > 0 then if isJustPressed(key) and isDown(mod) then return true elseif isJustPressed(mod) and isDown(key) then return true end return false else return isJustPressed(key) end end while true do wait(0) local canTriggerHotkeys = not isGTAMenuActive() and not showSettingsWindow[0] and not settingsUI.waitingTarget and not isInputOpen() and not editLineActive if canTriggerHotkeys and isHotkeyTriggered(screenChatConfig.key2, screenChatConfig.key1) then takeChatScreenshot() end if pendingDoStar then pendingDoTimer = pendingDoTimer + 1 if pendingDoTimer >= 10 then pushToNrp(pendingDoStar.color, pendingDoStar.text) pendingDoStar, pendingDoTimer = nil, 0 end end if canTriggerHotkeys then if isHotkeyTriggered(nrpHotkeys.toggleMod, nrpHotkeys.toggleKey) then nrpEnabled = not nrpEnabled saveNrpConfig() if nrpEnabled then addSystemMsg('{888888}NRP Chat -> {2E8B57}включен') else if activeChatTab == "OOC" then switchChatTab("IC") end addSystemMsg('{888888}NRP Chat -> {FF0000}отключен') end end if isHotkeyTriggered(nrpHotkeys.hideMod, nrpHotkeys.hideKey) then if nrpMode == "separate" then chatHidden = not chatHidden local hName = (nrpHotkeys.hideModName ~= "" and (nrpHotkeys.hideModName .. "+") or "") .. (nrpHotkeys.hideKeyName ~= "" and nrpHotkeys.hideKeyName or "1") if chatHidden then addSystemMsg('{888888}NRP Chat -> {FF0000}окно скрыто {808080}(' .. hName .. ' для показа)') else addSystemMsg('{888888}NRP Chat -> {2E8B57}окно отображается') end else if activeChatTab == "OOC" then switchChatTab("IC") addSystemMsg('{888888}NRP Chat -> {2E8B57}переключено на IC вкладку') else switchChatTab("OOC") addSystemMsg('{888888}NRP Chat -> {2E8B57}переключено на OOC вкладку') end end end if isHotkeyTriggered(0, nrpHotkeys.modeKey) then visibilityMode = (visibilityMode + 1) % 3 local modeNames = { [0] = "полный", [1] = "компактный", [2] = "скрытый" } addSystemMsg('{888888}NRP Chat -> режим видимости: {2E8B57}' .. (modeNames[visibilityMode] or tostring(visibilityMode))) end if isJustPressed(36) then -- Home scrollOffset = math.min(scrollOffset + chatRows, math.max(0, #nrpMessages - chatRows)) end if isJustPressed(35) then -- End scrollOffset = math.max(scrollOffset - chatRows, 0) end end if nrpEnabled and nrpMode == "separate" and not chatHidden and visibilityMode ~= 2 and #nrpMessages > 0 then drawNrpChat() end end end function onScriptTerminate(scr, quitGame) if scr == script.this then scriptInitialized = false sampAddresses.hooksInstalled = false pcall(showCursor, false) local cmdsToUnregister = { 'ssadd', 'ssdel', 'ssoff', 'nrppos', 'nrpfontsize', 'nrppagesize', 'nrptimestamp', 'nrpcc', 'nrpck', 'chatsettings', 'chatcfg', 'nrpsettings', 'nrpcfg' } for _, c in ipairs(cmdsToUnregister) do if sampUnregisterChatCommand then pcall(sampUnregisterChatCommand, c) end end for _, patch in ipairs(activePatches) do local oldProt = ffi.new("DWORD[1]") local addrPtr = ffi.cast("void*", patch.address) ffi.C.VirtualProtect(addrPtr, #patch.orig, PAGE_EXECUTE_READWRITE, oldProt) local pBytes = ffi.cast("uint8_t*", patch.address) for i = 1, #patch.orig do pBytes[i - 1] = patch.orig[i] end ffi.C.VirtualProtect(addrPtr, #patch.orig, oldProt[0], nil) ffi.C.FlushInstructionCache(ffi.C.GetCurrentProcess(), addrPtr, #patch.orig) end for _, hook in ipairs(installedHooks) do local oldProt = ffi.new("DWORD[1]") local addrPtr = ffi.cast("void*", hook.address) ffi.C.VirtualProtect(addrPtr, #hook.orig, PAGE_EXECUTE_READWRITE, oldProt) local pBytes = ffi.cast("uint8_t*", hook.address) for i = 1, #hook.orig do pBytes[i - 1] = hook.orig[i] end ffi.C.VirtualProtect(addrPtr, #hook.orig, oldProt[0], nil) ffi.C.FlushInstructionCache(ffi.C.GetCurrentProcess(), addrPtr, #hook.orig) end if pChat ~= nil and pChat ~= ffi.NULL then pChat.m_bRenderToSurface = 1 if pChat.m_pGameUi ~= 0 then local dialog = ffi.cast("uint8_t*", pChat.m_pGameUi) ffi.cast("int*", dialog + 0x116)[0] = 0 ffi.cast("int*", dialog + 0x11A)[0] = 0 end end installedHooks = {} if hookMemory ~= nil and hookMemory ~= ffi.NULL then ffi.C.VirtualFree(hookMemory, 0, MEM_RELEASE) hookMemory = nil end if sampAddresses.thunkBuf ~= nil and sampAddresses.thunkBuf ~= ffi.NULL then ffi.C.VirtualFree(sampAddresses.thunkBuf, 0, MEM_RELEASE) sampAddresses.thunkBuf = nil end if fontText then pcall(renderReleaseFont, fontText) fontText = nil end end end