Загрузка данных


require('samp.synchronization')
local sampfuncs = require('sampfuncs')
local raknet = require('samp.raknet')
local vector = require('vector3d')
local memory = require('memory')
local ffi = require('ffi')
local inicfg = require('inicfg')
local imgui = require('mimgui')
local encoding = require('encoding')
encoding.default = 'CP1251'
local u8 = encoding.UTF8

ffi.cdef[[
    void keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo);
]]

function pressKeyDown(vk)
    ffi.C.keybd_event(vk, 0, 0, 0)
end

function releaseKey(vk)
    ffi.C.keybd_event(vk, 0, 2, 0)
end

local directIni = 'r-realtor by @purpurins.ini'
local ini = inicfg.load({
    main = {
        bar = true,
        barX = 50,
        barY = 500
    },
    catch = {
        safeX = 0.0,
        safeY = 0.0,
        safeZ = 0.0,
        curdStep = 0.7,
        rtpStep = 9,
        interiorX = -1297.3,
        interiorY = -416.1,
        interiorZ = 1001.0,
        outsideX = -425.8,
        outsideY = -1341.4,
        outsideZ = 40.5,
        checkRadius = 5.0,
        exitTimeout = 10,
        routeDelay = 50,
        priceLimit = 200000,
        autoPayday = false,
        manualElevator = false,
        apartmentIndex = 1
    }
}, directIni)
inicfg.save(ini, directIni)

function SaveCfg()
    inicfg.save(ini, directIni)
end

local teleport = {
    bar_move = false,
    boolean = false,
    persent = 0,
    packet = 0
}

local catch = {
    active = false,
    status = ''
}

local apartment = {
    hunting = false,
    items = {},
    target = nil,
    waitingElevator = false,
    waitingBuy = false
}

local ECONOM_PICKUPS = {
    {x = 2321.2, y = -2454.8, z = 1009.4},
    {x = 2320.9, y = -2459.2, z = 1009.4},
    {x = 2330.1, y = -2454.9, z = 1009.4},
    {x = 2330.0, y = -2458.9, z = 1009.4}
}

local KVARTIRA_PICKUPS = {
    {x = 3031.0, y = 1704.3, z = 997.5},
    {x = 3028.7, y = 1707.1, z = 997.5},
    {x = 3026.1, y = 1707.2, z = 997.5},
    {x = 3023.8, y = 1704.2, z = 997.5}
}

-- CP1251 encoded strings for packet matching
local CP1251 = {
    etazh = string.char(253, 242, 224, 230),
    radare = string.char(240, 224, 228, 224, 240, 229),
    svoboden = string.char(241, 226, 238, 225, 238, 228, 229, 237),
    podyezd = string.char(207, 238, 228, 252, 229, 231, 228),
    vzaimodeystvovat = string.char(194, 231, 224, 232, 236, 238, 228, 229, 233, 241, 242, 226, 238, 226, 224, 242, 252)
}

local showMenu = imgui.new.bool(false)
local showSettings = imgui.new.bool(false)

local buf = {
    curdStep = imgui.new.float(ini.catch.curdStep),
    rtpStep = imgui.new.int(ini.catch.rtpStep),
    checkRadius = imgui.new.float(ini.catch.checkRadius),
    exitTimeout = imgui.new.int(ini.catch.exitTimeout),
    routeDelay = imgui.new.int(ini.catch.routeDelay),
    priceLimit = imgui.new.int(ini.catch.priceLimit),
    autoPayday = imgui.new.bool(ini.catch.autoPayday),
    manualElevator = imgui.new.bool(ini.catch.manualElevator),
    apartmentIndex = imgui.new.int(ini.catch.apartmentIndex),
    interiorX = imgui.new.float(ini.catch.interiorX),
    interiorY = imgui.new.float(ini.catch.interiorY),
    interiorZ = imgui.new.float(ini.catch.interiorZ),
    outsideX = imgui.new.float(ini.catch.outsideX),
    outsideY = imgui.new.float(ini.catch.outsideY),
    outsideZ = imgui.new.float(ini.catch.outsideZ),
    safeX = imgui.new.float(ini.catch.safeX),
    safeY = imgui.new.float(ini.catch.safeY),
    safeZ = imgui.new.float(ini.catch.safeZ),
    barShow = imgui.new.bool(ini.main.bar)
}

local ROUTE_1 = {
    {x=-1295.7156,y=-416.8015,z=1001.0000},
    {x=-1295.7213,y=-417.3696,z=1001.0000},
    {x=-1295.7269,y=-417.9422,z=1001.0000},
    {x=-1295.7336,y=-418.6414,z=1001.0000},
    {x=-1295.7404,y=-419.3116,z=1001.0000},
    {x=-1295.7472,y=-419.9932,z=1001.0000},
    {x=-1295.7537,y=-420.6914,z=1001.0000},
    {x=-1295.7604,y=-421.2037,z=1001.0000},
    {x=-1296.0499,y=-421.8296,z=1001.0000},
    {x=-1296.4739,y=-422.2974,z=1001.0000},
    {x=-1297.0659,y=-422.6009,z=1001.0000},
    {x=-1297.5638,y=-422.6944,z=1001.0000},
    {x=-1298.2347,y=-422.3384,z=1001.0000},
    {x=-1298.7526,y=-421.9099,z=1001.0000},
    {x=-1299.2393,y=-421.4688,z=1001.0000},
    {x=-1299.8873,y=-421.4061,z=1001.0000},
    {x=-1300.5593,y=-421.4308,z=1001.0000},
    {x=-1301.2802,y=-421.4572,z=1001.0000},
    {x=-1301.9220,y=-421.4883,z=1001.0000},
    {x=-1302.5580,y=-421.7512,z=1001.0000},
    {x=-1302.7153,y=-422.3867,z=1001.0000},
    {x=-1302.6377,y=-423.0829,z=1001.0000},
    {x=-1302.8138,y=-423.7206,z=1001.0000},
    {x=-1303.1964,y=-424.2230,z=1001.0000},
    {x=-1303.5144,y=-424.6509,z=1001.0000},
    {x=-1303.8748,y=-425.1795,z=1001.0000},
    {x=-1304.1715,y=-425.5840,z=1001.0000},
    {x=-1304.5883,y=-426.1485,z=1001.0000},
    {x=-1304.9713,y=-426.6637,z=1001.0000},
    {x=-1305.3905,y=-427.2000,z=1001.0000},
    {x=-1305.8579,y=-427.7214,z=1001.0000},
    {x=-1306.3307,y=-428.2000,z=1001.0000},
    {x=-1306.7999,y=-428.6745,z=1001.0000},
    {x=-1307.2588,y=-429.1119,z=1001.0000},
    {x=-1307.7524,y=-429.5616,z=1001.0000},
    {x=-1308.2727,y=-430.0351,z=1001.0000},
    {x=-1308.8119,y=-430.5260,z=1001.0000},
    {x=-1309.3169,y=-430.9858,z=1001.0000},
    {x=-1309.8079,y=-431.4187,z=1001.0000},
    {x=-1310.3145,y=-431.7969,z=1001.0000}
}

local ROUTE_2 = {
    {x=-1295.8469,y=-417.0778,z=1005.0547},
    {x=-1295.8411,y=-417.6600,z=1005.0547},
    {x=-1295.9012,y=-418.2766,z=1005.0547},
    {x=-1296.1851,y=-418.9017,z=1005.0547},
    {x=-1296.6958,y=-419.3144,z=1005.0547},
    {x=-1297.2224,y=-419.7321,z=1005.0547},
    {x=-1297.7899,y=-420.1824,z=1005.0547},
    {x=-1298.2262,y=-420.5285,z=1005.0547},
    {x=-1298.6305,y=-420.8826,z=1005.0547},
    {x=-1299.0696,y=-421.3048,z=1005.0547},
    {x=-1299.5313,y=-421.7599,z=1005.0547},
    {x=-1299.9503,y=-422.1730,z=1005.0547},
    {x=-1300.4106,y=-422.6270,z=1005.0547},
    {x=-1300.8479,y=-423.0581,z=1005.0547},
    {x=-1301.2782,y=-423.4823,z=1005.0547},
    {x=-1301.7518,y=-423.9495,z=1005.0547},
    {x=-1302.1996,y=-424.3909,z=1005.0547},
    {x=-1302.6555,y=-424.8404,z=1005.0547},
    {x=-1303.1189,y=-425.2972,z=1005.0547},
    {x=-1303.5433,y=-425.7156,z=1005.0547},
    {x=-1303.9847,y=-426.1509,z=1005.0547},
    {x=-1304.4431,y=-426.6029,z=1005.0547},
    {x=-1304.8875,y=-427.0410,z=1005.0547},
    {x=-1305.3370,y=-427.4844,z=1005.0547},
    {x=-1305.7819,y=-427.9187,z=1005.0547},
    {x=-1306.2023,y=-428.3069,z=1005.0547},
    {x=-1306.7052,y=-428.7716,z=1005.0547},
    {x=-1307.1713,y=-429.2023,z=1005.0547},
    {x=-1307.6539,y=-429.6483,z=1005.0547},
    {x=-1308.1357,y=-430.0823,z=1005.0547},
    {x=-1308.6005,y=-430.4549,z=1005.0547},
    {x=-1309.0978,y=-430.7673,z=1005.0547},
    {x=-1309.5988,y=-431.0603,z=1005.0547},
    {x=-1310.0538,y=-431.3443,z=1005.0547},
    {x=-1310.4633,y=-431.6360,z=1005.0547},
    {x=-1310.9218,y=-431.9683,z=1005.0547},
    {x=-1310.9340,y=-431.9771,z=1003.0547},
    {x=-1310.9340,y=-431.9771,z=1002.4662},
    {x=-1310.9340,y=-431.9771,z=1001.7351},
    {x=-1310.9395,y=-431.9811,z=1001.0000}
}

function selectRoute()
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    if pz > 1003.0 then
        return ROUTE_2, 2
    end
    return ROUTE_1, 1
end

function parseApartments(text)
    for gid, atype, entrance, aptNum, price, flag, px, py in
        text:gmatch('%[(%d+),(%d+),"(%d+)","[^#]+#%d+%. [^#]+#(%d+)",(%d+),(%d+),([%d%.%-]+),([%d%.%-]+)%]') do
        table.insert(apartment.items, {
            globalId = tonumber(gid),
            aptType = tonumber(atype),
            entrance = entrance,
            aptNumber = tonumber(aptNum),
            price = tonumber(price),
            free = tonumber(flag) == 0,
            posX = tonumber(px),
            posY = tonumber(py)
        })
    end
end

function countFreeApartments()
    local c = 0
    for _, apt in ipairs(apartment.items) do
        if apt.free and apt.price <= ini.catch.priceLimit then c = c + 1 end
    end
    return c
end

function findBestApartment()
    local count = 0
    for _, apt in ipairs(apartment.items) do
        if apt.free and apt.price <= ini.catch.priceLimit then
            count = count + 1
            if count == ini.catch.apartmentIndex then
                return apt
            end
        end
    end
    return nil
end

function isEconom(apt)
    return apt.price <= 500000
end

function getPickupCoords(apt)
    local idx = ((apt.aptNumber - 1) % 4) + 1
    if isEconom(apt) then
        return ECONOM_PICKUPS[idx]
    else
        return KVARTIRA_PICKUPS[idx]
    end
end

function getFloor(apt)
    return math.ceil(apt.aptNumber / 4)
end

local lastAltTime = 0

function sendAltKey()
    -- one Alt per 1.5s: the Interactions hook and the elevator phase must not double-press
    if os.clock() - lastAltTime < 1.5 then return end
    lastAltTime = os.clock()
    local bs = raknetNewBitStream()
    local bytes = {215, 2, 0, 0, 0, 0, 0, 21, 0, 0, 0, 79, 110, 80, 108, 97, 121, 101, 114, 67, 108, 105, 101, 110, 116, 83, 105, 100, 101, 75, 101, 121, 2, 0, 0, 0, 100, 18, 0, 0, 0}
    for _, b in ipairs(bytes) do raknetBitStreamWriteInt8(bs, b) end
    raknetSendBitStream(bs)
    raknetDeleteBitStream(bs)
end

function sendRealtorClose()
    local bs = raknetNewBitStream()
    local bytes = {215, 2, 0, 0, 0, 0, 0, 14, 0, 0, 0, 79, 110, 82, 101, 97, 108, 116, 111, 114, 67, 108, 111, 115, 101, 0, 0, 0, 0}
    for _, b in ipairs(bytes) do raknetBitStreamWriteInt8(bs, b) end
    raknetSendBitStream(bs)
    raknetDeleteBitStream(bs)
end

function sendRealtorSelectCategory(cat)
    local bs = raknetNewBitStream()
    local bytes = {215, 2, 0, 0, 0, 0, 0, 23, 0, 0, 0, 79, 110, 82, 101, 97, 108, 116, 111, 114, 83, 101, 108, 101, 99, 116, 67, 97, 116, 101, 103, 111, 114, 121, 2, 0, 0, 0, 100}
    for _, b in ipairs(bytes) do raknetBitStreamWriteInt8(bs, b) end
    raknetBitStreamWriteInt8(bs, bit.band(cat, 0xFF))
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetSendBitStream(bs)
    raknetDeleteBitStream(bs)
end

function sendRealtorLoadMore(page)
    local bs = raknetNewBitStream()
    local bytes = {215, 2, 0, 0, 0, 0, 0, 17, 0, 0, 0, 79, 110, 82, 101, 97, 108, 116, 111, 114, 76, 111, 97, 100, 77, 111, 114, 101, 2, 0, 0, 0, 100}
    for _, b in ipairs(bytes) do raknetBitStreamWriteInt8(bs, b) end
    raknetBitStreamWriteInt8(bs, bit.band(page, 0xFF))
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetSendBitStream(bs)
    raknetDeleteBitStream(bs)
end

function autoLoadApartments()
    lua_thread.create(function()
        apartment.items = {}
        sampAddChatMessage('{ffff69}[HUNT] Auto loading apartments...', -1)
        sendRealtorSelectCategory(1)
        wait(500)
        for page = 1, 10 do
            sendRealtorLoadMore(page)
            wait(300)
        end
        wait(500)
        local best = findBestApartment()
        if best and apartment.hunting and not apartment.target then
            apartment.target = best
            sampAddChatMessage('{69ff69}[HUNT] Apt #' .. best.aptNumber .. ' Ent #' .. best.entrance .. ' Price: ' .. best.price, -1)
            lua_thread.create(startApartmentCatch)
        elseif not best then
            sampAddChatMessage('{ff6969}[HUNT] No free apartments under ' .. ini.catch.priceLimit, -1)
        end
    end)
end

function sendDialogResponseFull(dialogId, button, listIndex, inputText)
    local bs = raknetNewBitStream()
    -- Header
    local header = {215, 2, 0, 0, 0, 0, 0}
    for _, b in ipairs(header) do raknetBitStreamWriteInt8(bs, b) end
    -- "OnDialogResponse" string
    local name = "OnDialogResponse"
    raknetBitStreamWriteInt8(bs, #name)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    for i = 1, #name do raknetBitStreamWriteInt8(bs, string.byte(name, i)) end
    -- Params count
    raknetBitStreamWriteInt8(bs, 8)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    raknetBitStreamWriteInt8(bs, 0)
    -- dialogId (dword)
    raknetBitStreamWriteInt8(bs, 100)
    raknetBitStreamWriteInt8(bs, bit.band(dialogId, 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(dialogId, 8), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(dialogId, 16), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(dialogId, 24), 0xFF))
    -- button (dword)
    raknetBitStreamWriteInt8(bs, 100)
    raknetBitStreamWriteInt8(bs, bit.band(button, 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(button, 8), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(button, 16), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(button, 24), 0xFF))
    -- listIndex (dword)
    raknetBitStreamWriteInt8(bs, 100)
    local li = listIndex
    if li < 0 then li = 0xFFFFFFFF end
    raknetBitStreamWriteInt8(bs, bit.band(li, 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(li, 8), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(li, 16), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(li, 24), 0xFF))
    -- inputText (string with length prefix)
    raknetBitStreamWriteInt8(bs, 115)
    local str = inputText or ""
    raknetBitStreamWriteInt8(bs, bit.band(#str, 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(#str, 8), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(#str, 16), 0xFF))
    raknetBitStreamWriteInt8(bs, bit.band(bit.rshift(#str, 24), 0xFF))
    for i = 1, #str do raknetBitStreamWriteInt8(bs, string.byte(str, i)) end
    raknetSendBitStream(bs)
    raknetDeleteBitStream(bs)
end

imgui.OnInitialize(function()
    imgui.GetIO().IniFilename = nil
    local style = imgui.GetStyle()
    style.WindowRounding = 12
    style.FrameRounding = 8
    style.GrabRounding = 6
    style.ScrollbarRounding = 8
    style.ChildRounding = 8
    style.PopupRounding = 8
    style.ItemSpacing = imgui.ImVec2(10, 8)
    style.WindowPadding = imgui.ImVec2(14, 14)
    style.FramePadding = imgui.ImVec2(10, 6)
    style.WindowBorderSize = 1
    style.FrameBorderSize = 0
    style.ScrollbarSize = 10

    local colors = style.Colors
    -- Background
    colors[imgui.Col.WindowBg] = imgui.ImVec4(0.06, 0.06, 0.09, 0.96)
    colors[imgui.Col.ChildBg] = imgui.ImVec4(0.08, 0.08, 0.12, 0.60)
    colors[imgui.Col.PopupBg] = imgui.ImVec4(0.07, 0.07, 0.11, 0.96)
    -- Borders
    colors[imgui.Col.Border] = imgui.ImVec4(0.40, 0.20, 0.80, 0.30)
    colors[imgui.Col.BorderShadow] = imgui.ImVec4(0.00, 0.00, 0.00, 0.00)
    -- Text
    colors[imgui.Col.Text] = imgui.ImVec4(0.93, 0.93, 0.97, 1.00)
    colors[imgui.Col.TextDisabled] = imgui.ImVec4(0.45, 0.45, 0.55, 1.00)
    -- Buttons
    colors[imgui.Col.Button] = imgui.ImVec4(0.35, 0.15, 0.70, 0.65)
    colors[imgui.Col.ButtonHovered] = imgui.ImVec4(0.45, 0.25, 0.85, 0.80)
    colors[imgui.Col.ButtonActive] = imgui.ImVec4(0.55, 0.30, 0.95, 0.90)
    -- Frames
    colors[imgui.Col.FrameBg] = imgui.ImVec4(0.12, 0.10, 0.20, 0.80)
    colors[imgui.Col.FrameBgHovered] = imgui.ImVec4(0.20, 0.15, 0.35, 0.80)
    colors[imgui.Col.FrameBgActive] = imgui.ImVec4(0.25, 0.18, 0.45, 0.80)
    -- Headers
    colors[imgui.Col.Header] = imgui.ImVec4(0.30, 0.15, 0.60, 0.50)
    colors[imgui.Col.HeaderHovered] = imgui.ImVec4(0.40, 0.20, 0.75, 0.60)
    colors[imgui.Col.HeaderActive] = imgui.ImVec4(0.45, 0.25, 0.85, 0.70)
    -- Separator
    colors[imgui.Col.Separator] = imgui.ImVec4(0.40, 0.20, 0.80, 0.25)
    colors[imgui.Col.SeparatorHovered] = imgui.ImVec4(0.50, 0.30, 0.90, 0.50)
    -- Scrollbar
    colors[imgui.Col.ScrollbarBg] = imgui.ImVec4(0.05, 0.05, 0.08, 0.50)
    colors[imgui.Col.ScrollbarGrab] = imgui.ImVec4(0.35, 0.15, 0.70, 0.50)
    colors[imgui.Col.ScrollbarGrabHovered] = imgui.ImVec4(0.45, 0.25, 0.85, 0.70)
    colors[imgui.Col.ScrollbarGrabActive] = imgui.ImVec4(0.55, 0.30, 0.95, 0.90)
    -- Slider
    colors[imgui.Col.SliderGrab] = imgui.ImVec4(0.50, 0.25, 0.90, 0.80)
    colors[imgui.Col.SliderGrabActive] = imgui.ImVec4(0.60, 0.35, 1.00, 1.00)
    -- Checkmark
    colors[imgui.Col.CheckMark] = imgui.ImVec4(0.60, 0.35, 1.00, 1.00)
    -- Title
    colors[imgui.Col.TitleBg] = imgui.ImVec4(0.10, 0.05, 0.20, 0.95)
    colors[imgui.Col.TitleBgActive] = imgui.ImVec4(0.25, 0.12, 0.50, 0.95)
    colors[imgui.Col.TitleBgCollapsed] = imgui.ImVec4(0.08, 0.04, 0.15, 0.70)
end)

local mainFrame = imgui.OnFrame(function() return showMenu[0] and not isPauseMenuActive() and not sampIsScoreboardOpen() end,
function(player)
    player.HideCursor = imgui.IsMouseDown(1)
    local resX, resY = getScreenResolution()
    imgui.SetNextWindowPos(imgui.ImVec2(resX / 2, resY / 2), imgui.Cond.FirstUseEver, imgui.ImVec2(0.5, 0.5))
    imgui.SetNextWindowSize(imgui.ImVec2(380, 0), imgui.Cond.FirstUseEver)
    imgui.Begin(u8('r-realtor by @purpurins'), showMenu, imgui.WindowFlags.NoCollapse + imgui.WindowFlags.AlwaysAutoResize)

    -- Status block
    imgui.BeginChild(u8('##status'), imgui.ImVec2(-1, 45), true)
    if apartment.hunting then
        imgui.TextColored(imgui.ImVec4(0.40, 1.0, 0.40, 1.0), u8('  HUNTING'))
        if apartment.target then
            imgui.SameLine(150)
            imgui.TextColored(imgui.ImVec4(1.0, 0.85, 0.30, 1.0), u8('Apt #' .. apartment.target.aptNumber .. ' | Ent #' .. apartment.target.entrance .. ' | ' .. apartment.target.price))
        end
    elseif catch.active then
        imgui.TextColored(imgui.ImVec4(1.0, 1.0, 0.40, 1.0), u8('  ' .. catch.status))
    elseif teleport.boolean then
        imgui.TextColored(imgui.ImVec4(1.0, 1.0, 0.40, 1.0), u8('  Teleporting ' .. teleport.persent .. '%'))
    else
        local vehId = false
        if isCharOnFoot(PLAYER_PED) then vehId = getVehicle(200, true, false) end
        if vehId then
            imgui.TextColored(imgui.ImVec4(0.40, 1.0, 0.40, 1.0), u8('  Ready'))
            imgui.SameLine(150)
            imgui.TextColored(imgui.ImVec4(0.70, 0.70, 0.80, 1.0), u8('Vehicle: ' .. vehId))
        else
            imgui.TextColored(imgui.ImVec4(1.0, 0.40, 0.40, 1.0), u8('  No vehicle nearby'))
        end
    end
    imgui.EndChild()

    imgui.Spacing()

    -- Apartment Catcher
    imgui.TextColored(imgui.ImVec4(0.65, 0.45, 1.0, 1.0), u8('APARTMENT CATCHER'))
    imgui.Separator()
    imgui.Spacing()

    imgui.TextColored(imgui.ImVec4(0.70, 0.70, 0.80, 1.0), u8('Limit: ' .. ini.catch.priceLimit .. '  |  Free: ' .. countFreeApartments() .. '  |  Order: #' .. ini.catch.apartmentIndex))

    imgui.Spacing()

    if apartment.hunting then
        imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.70, 0.15, 0.15, 0.80))
        imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.85, 0.20, 0.20, 0.90))
        imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.95, 0.25, 0.25, 1.00))
        if imgui.Button(u8('  Stop Hunting'), imgui.ImVec2(-1, 35)) then
            apartment.hunting = false
            apartment.target = nil
            apartment.waitingElevator = false
            apartment.waitingBuy = false
            catch.active = false
            teleport.boolean = false
            catch.status = ''
        end
        imgui.PopStyleColor(3)
    else
        imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.15, 0.55, 0.15, 0.80))
        imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.20, 0.70, 0.20, 0.90))
        imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.25, 0.80, 0.25, 1.00))
        if imgui.Button(u8('  Start Hunting'), imgui.ImVec2(-1, 35)) then
            startHunting()
        end
        imgui.PopStyleColor(3)
        if #apartment.items > 0 and not catch.active and not teleport.boolean then
            if imgui.Button(u8('  Test (cached)'), imgui.ImVec2(-1, 28)) then
                startTestHunt()
            end
        end
    end

    imgui.Spacing()
    imgui.Spacing()

    -- Teleport
    imgui.TextColored(imgui.ImVec4(0.65, 0.45, 1.0, 1.0), u8('TELEPORT'))
    imgui.Separator()
    imgui.Spacing()

    if catch.active or teleport.boolean then
        imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.70, 0.15, 0.15, 0.80))
        imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.85, 0.20, 0.20, 0.90))
        imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.95, 0.25, 0.25, 1.00))
        if imgui.Button(u8('  Stop'), imgui.ImVec2(-1, 30)) then
            catch.active = false
            teleport.boolean = false
            catch.status = ''
        end
        imgui.PopStyleColor(3)
    else
        if imgui.Button(u8('  Catch (route)'), imgui.ImVec2(-1, 28)) then startCatch() end
        if imgui.Button(u8('  TP to mark'), imgui.ImVec2(-1, 28)) then startMarkTp() end
        imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.15, 0.40, 0.65, 0.80))
        imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.20, 0.50, 0.80, 0.90))
        imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.25, 0.55, 0.90, 1.00))
        if imgui.Button(u8(' Bus'), imgui.ImVec2(170, 28)) then
            startTpToCoords(-400.3, -1333.9, 41.1)
        end
        imgui.SameLine()
        if imgui.Button(u8(' Arz'), imgui.ImVec2(170, 28)) then
            startTpToCoords(648.566, 652.348, 12.0)
        end
        imgui.PopStyleColor(3)
    end

    imgui.Spacing()
    imgui.Spacing()

    -- Info
    imgui.TextColored(imgui.ImVec4(0.65, 0.45, 1.0, 1.0), u8('INFO'))
    imgui.Separator()
    imgui.Spacing()

    imgui.BeginChild(u8('##info'), imgui.ImVec2(-1, 75), true)
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    imgui.TextColored(imgui.ImVec4(0.60, 0.60, 0.70, 1.0), u8(string.format('Pos: %.1f, %.1f, %.1f', px, py, pz)))
    imgui.TextColored(imgui.ImVec4(0.60, 0.60, 0.70, 1.0), u8(string.format('Safe: %.1f, %.1f, %.1f', ini.catch.safeX, ini.catch.safeY, ini.catch.safeZ)))
    local _, rn = selectRoute()
    imgui.TextColored(imgui.ImVec4(0.60, 0.60, 0.70, 1.0), u8('Route: #' .. rn))
    imgui.EndChild()

    imgui.Spacing()

    if imgui.Button(u8('  Save safe pos'), imgui.ImVec2(-1, 25)) then
        local sx, sy, sz = getCharCoordinates(PLAYER_PED)
        ini.catch.safeX, ini.catch.safeY, ini.catch.safeZ = sx, sy, sz
        buf.safeX[0], buf.safeY[0], buf.safeZ[0] = sx, sy, sz
        SaveCfg()
        sampAddChatMessage(string.format('{69ff69}[r-realtor by @purpurins] Safe: %.1f, %.1f, %.1f', sx, sy, sz), -1)
    end

    imgui.Spacing()

    imgui.PushStyleColor(imgui.Col.Button, imgui.ImVec4(0.20, 0.15, 0.35, 0.80))
    imgui.PushStyleColor(imgui.Col.ButtonHovered, imgui.ImVec4(0.30, 0.20, 0.50, 0.90))
    imgui.PushStyleColor(imgui.Col.ButtonActive, imgui.ImVec4(0.35, 0.25, 0.60, 1.00))
    if imgui.Button(u8('  Settings'), imgui.ImVec2(-1, 25)) then showSettings[0] = not showSettings[0] end
    imgui.PopStyleColor(3)

    imgui.End()
end)

local settingsFrame = imgui.OnFrame(function() return showSettings[0] and showMenu[0] and not isPauseMenuActive() end,
function(player)
    player.HideCursor = imgui.IsMouseDown(1)
    local resX, resY = getScreenResolution()
    imgui.SetNextWindowPos(imgui.ImVec2(resX / 2 + 190, resY / 2 - 100), imgui.Cond.FirstUseEver)
    imgui.SetNextWindowSize(imgui.ImVec2(320, 0), imgui.Cond.FirstUseEver)
    imgui.Begin(u8('Settings'), showSettings, imgui.WindowFlags.NoCollapse + imgui.WindowFlags.AlwaysAutoResize)

    if imgui.CollapsingHeader(u8('Apartment')) then
        if imgui.InputInt(u8('Price limit'), buf.priceLimit, 50000, 100000) then
            if buf.priceLimit[0] < 0 then buf.priceLimit[0] = 0 end
            ini.catch.priceLimit = buf.priceLimit[0]
            SaveCfg()
        end
        if imgui.Checkbox(u8('Auto payday tabs'), buf.autoPayday) then
            ini.catch.autoPayday = buf.autoPayday[0]
            SaveCfg()
        end
        if imgui.Checkbox(u8('Walk to elevator myself'), buf.manualElevator) then
            ini.catch.manualElevator = buf.manualElevator[0]
            SaveCfg()
        end
        if imgui.InputInt(u8('Apartment # (order)'), buf.apartmentIndex, 1, 1) then
            if buf.apartmentIndex[0] < 1 then buf.apartmentIndex[0] = 1 end
            ini.catch.apartmentIndex = buf.apartmentIndex[0]
            SaveCfg()
        end
    end

    if imgui.CollapsingHeader(u8('General')) then
        if imgui.Checkbox(u8('Show bar'), buf.barShow) then ini.main.bar = buf.barShow[0]; SaveCfg() end
        if imgui.SliderFloat(u8('Curd step'), buf.curdStep, 0.1, 2.0, '%.1f') then ini.catch.curdStep = buf.curdStep[0]; SaveCfg() end
        if imgui.SliderInt(u8('RTP step'), buf.rtpStep, 1, 20) then ini.catch.rtpStep = buf.rtpStep[0]; SaveCfg() end
        if imgui.SliderFloat(u8('Check radius'), buf.checkRadius, 1.0, 20.0, '%.1f') then ini.catch.checkRadius = buf.checkRadius[0]; SaveCfg() end
        if imgui.SliderInt(u8('Exit timeout'), buf.exitTimeout, 1, 30) then ini.catch.exitTimeout = buf.exitTimeout[0]; SaveCfg() end
        if imgui.SliderInt(u8('Route delay'), buf.routeDelay, 10, 200) then ini.catch.routeDelay = buf.routeDelay[0]; SaveCfg() end
    end

    if imgui.CollapsingHeader(u8('Coords')) then
        if imgui.InputFloat(u8('Int X'), buf.interiorX, 0.1, 1.0, '%.1f') then ini.catch.interiorX = buf.interiorX[0]; SaveCfg() end
        if imgui.InputFloat(u8('Int Y'), buf.interiorY, 0.1, 1.0, '%.1f') then ini.catch.interiorY = buf.interiorY[0]; SaveCfg() end
        if imgui.InputFloat(u8('Int Z'), buf.interiorZ, 0.1, 1.0, '%.1f') then ini.catch.interiorZ = buf.interiorZ[0]; SaveCfg() end
        if imgui.InputFloat(u8('Out X'), buf.outsideX, 0.1, 1.0, '%.1f') then ini.catch.outsideX = buf.outsideX[0]; SaveCfg() end
        if imgui.InputFloat(u8('Out Y'), buf.outsideY, 0.1, 1.0, '%.1f') then ini.catch.outsideY = buf.outsideY[0]; SaveCfg() end
        if imgui.InputFloat(u8('Out Z'), buf.outsideZ, 0.1, 1.0, '%.1f') then ini.catch.outsideZ = buf.outsideZ[0]; SaveCfg() end
        if imgui.InputFloat(u8('Safe X'), buf.safeX, 0.1, 1.0, '%.1f') then ini.catch.safeX = buf.safeX[0]; SaveCfg() end
        if imgui.InputFloat(u8('Safe Y'), buf.safeY, 0.1, 1.0, '%.1f') then ini.catch.safeY = buf.safeY[0]; SaveCfg() end
        if imgui.InputFloat(u8('Safe Z'), buf.safeZ, 0.1, 1.0, '%.1f') then ini.catch.safeZ = buf.safeZ[0]; SaveCfg() end
    end

    imgui.End()
end)

function main()
    repeat wait(0) until isSampAvailable()
    lua_thread.create(infobar)

    sampRegisterChatCommand('rtp', function(arg)
        if arg == '' then blip, x, y, z = getMark()
        elseif arg == 'chp' then blip, x, y, z = getCheckPoint()
        elseif arg == 'bar' then ini.main.bar = not ini.main.bar; buf.barShow[0] = ini.main.bar; SaveCfg(); return
        elseif arg == 'bar move' then teleport.bar_move = not teleport.bar_move; return
        else return end
        if blip and isCharOnFoot(PLAYER_PED) and getVehicle(200, true, false) and not teleport.boolean then
            local vehicleId = getVehicle(200, true, false)
            local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(vehicleId))
            local positionX, positionY, positionZ = getCharCoordinates(PLAYER_PED)
            local carX, carY, carZ = getCarCoordinates(vehicleHandle)
            teleport.boolean = true
            lua_thread.create(function()
                slapUnderMap()
                if getDistanceBetweenCoords3d(positionX, positionY, positionZ, carX, carY, carZ) > 15 then
                    teleportToCar(positionX, positionY, positionZ, carX, carY, carZ)
                end
                wait(500)
                teleportToBlip(vehicleId, carX, carY, carZ, x, y, z)
            end)
        end
    end)

    sampRegisterChatCommand('rsave', function()
        local sx, sy, sz = getCharCoordinates(PLAYER_PED)
        ini.catch.safeX, ini.catch.safeY, ini.catch.safeZ = sx, sy, sz
        buf.safeX[0], buf.safeY[0], buf.safeZ[0] = sx, sy, sz
        SaveCfg()
        sampAddChatMessage(string.format('{69ff69}[r-realtor by @purpurins] Safe: %.1f, %.1f, %.1f', sx, sy, sz), -1)
    end)

    sampRegisterChatCommand('rr', function()
        showMenu[0] = not showMenu[0]
    end)

    sampAddChatMessage('{69ff69}[r-realtor by @purpurins] {FFFFFF}/rr - menu', -1)
    wait(-1)
end

function onServerMessage(color, text)
    if ini.catch.autoPayday and apartment.hunting and not apartment.target then
        local bankCheck = string.char(193, 192, 205, 202, 206, 194, 209, 202, 200, 201) -- БАНКОВСКИЙ in CP1251
        if text:find(bankCheck) then
            sampAddChatMessage('{ffff69}[HUNT] Payday detected! Auto loading...', -1)
            autoLoadApartments()
        end
    end
end

function onReceivePacket(id, bs)
    local len = raknetBitStreamGetNumberOfBytesUsed(bs)
    if len < 10 then return end

    raknetBitStreamResetReadPointer(bs)
    local text = ''
    for i = 1, len do
        local b = raknetBitStreamReadInt8(bs)
        if b < 0 then b = b + 256 end
        if b >= 32 then text = text .. string.char(b) end
    end
    raknetBitStreamResetReadPointer(bs)

    -- Parse apartment listings from CEF
    if text:find('setPropertyItems') then
        apartment.items = {}
        parseApartments(text)
    elseif text:find('addPropertyItems') then
        parseApartments(text)
    end

    -- Auto-select apartment when hunting
    if apartment.hunting and not apartment.target then
        if text:find('PropertyItems') then
            local best = findBestApartment()
            if best then
                apartment.target = best
                sampAddChatMessage('{69ff69}[HUNT] Apt #' .. best.aptNumber .. ' Ent #' .. best.entrance .. ' Price: ' .. best.price, -1)
                lua_thread.create(startApartmentCatch)
            end
        end
    end

    -- Auto elevator - simulate arrow keys
    if apartment.waitingElevator and text:find('addDialogInQueue') and (text:find(CP1251.etazh) or text:find('FFCD00')) then
        local target = apartment.target
        if target then
            local floor = getFloor(target)
            local floorIndex = floor - 1
            lua_thread.create(function()
                wait(100)
                -- Press Down arrow to select floor
                for i = 1, floorIndex do
                    pressKeyDown(0x28) -- VK_DOWN
                    wait(10)
                    releaseKey(0x28)
                    wait(15)
                end
                wait(30)
                -- Press Enter to confirm
                pressKeyDown(0x0D) -- VK_RETURN
                wait(10)
                releaseKey(0x0D)
                sampAddChatMessage('{69ff69}[HUNT] Floor ' .. floor, -1)
                apartment.waitingElevator = false
                wait(2000)
                if apartment.hunting and target then
                    local pickup = getPickupCoords(target)
                    if pickup then
                        setCharCoordinates(PLAYER_PED, pickup.x, pickup.y, pickup.z)
                        apartment.waitingBuy = true
                        sampAddChatMessage('{69ff69}[HUNT] At pickup, waiting...', -1)
                    end
                end
            end)
        end
    end

    -- Interactions [[7,"Взаимодействовать"]] -> answer with the Alt key packet
    if apartment.hunting and text:find('Interactions', 1, true) and text:find(CP1251.vzaimodeystvovat, 1, true) then
        lua_thread.create(function()
            wait(100)
            sendAltKey()
            sampAddChatMessage('{69ff69}[HUNT] Interaction -> Alt sent', -1)
        end)
    end

    -- Auto close "marked on radar" dialog
    if apartment.hunting and text:find('addDialogInQueue') and text:find(CP1251.radare) then
        lua_thread.create(function()
            wait(300)
            sendDialogResponseFull(0, 1, -1, "")
        end)
        return false
    end

    -- Auto Alt when at apartment pickup
    if apartment.waitingBuy and text:find('Appartam') then
        lua_thread.create(function()
            wait(100)
            pressKeyDown(0xA4) -- VK_LMENU (Left Alt)
            wait(30)
            releaseKey(0xA4)
            sampAddChatMessage('{69ff69}[HUNT] At apartment, pressing Alt...', -1)
        end)
    end

    -- Auto buy
    if apartment.waitingBuy and text:find('addDialogInQueue') and (text:find(CP1251.svoboden) or text:find('33CC00')) then
        lua_thread.create(function()
            wait(500)
            sendDialogResponseFull(0, 1, -1, "")
            sampAddChatMessage('{69ff69}[HUNT] BOUGHT!', -1)
            apartment.waitingBuy = false
            apartment.hunting = false
            apartment.target = nil
        end)
        return false
    end
end

function startHunting()
    if apartment.hunting then return end
    apartment.hunting = true
    apartment.target = nil
    apartment.items = {}
    apartment.waitingElevator = false
    apartment.waitingBuy = false
    sampAddChatMessage('{ffff69}[HUNT] Started! Open CEF > Квартиры > Свободное имущество', -1)
    sampAddChatMessage('{ffff69}[HUNT] Price limit: ' .. ini.catch.priceLimit, -1)
end

function startTestHunt()
    if #apartment.items == 0 then
        sampAddChatMessage('{ff6969}[TEST] No cached data! Open CEF first.', -1)
        return
    end
    local best = findBestApartment()
    if not best then
        sampAddChatMessage('{ff6969}[TEST] No free apartments in cache under ' .. ini.catch.priceLimit, -1)
        return
    end
    apartment.hunting = true
    apartment.target = best
    apartment.waitingElevator = false
    apartment.waitingBuy = false
    sampAddChatMessage('{ffff69}[TEST] Apt #' .. best.aptNumber .. ' Ent #' .. best.entrance .. ' Price: ' .. best.price, -1)
    lua_thread.create(startApartmentCatch)
end

function startApartmentCatch()
    local target = apartment.target
    if not target then return end

    -- apartment found: close the realtor tablet right away
    sendRealtorClose()

    -- Phase 1: Route out of realtor
    local routePoints, routeNum = selectRoute()
    sampAddChatMessage('{ffff69}[HUNT] Using route ' .. routeNum, -1)
    if #routePoints > 0 then
        catch.active = true
        catch.status = 'Route out...'
        for i, point in ipairs(routePoints) do
            if not apartment.hunting then break end
            catch.status = 'Route ' .. i .. '/' .. #routePoints
            setCharCoordinates(PLAYER_PED, point.x, point.y, point.z)
            wait(ini.catch.routeDelay)
        end
        catch.active = false
        if not apartment.hunting then return end

        catch.status = 'Waiting exit...'
        local timeout = os.clock() + ini.catch.exitTimeout
        while os.clock() < timeout and apartment.hunting do
            wait(100)
            local px, py, pz = getCharCoordinates(PLAYER_PED)
            if getDistanceBetweenCoords3d(px, py, pz, ini.catch.outsideX, ini.catch.outsideY, ini.catch.outsideZ) <= ini.catch.checkRadius then break end
        end
        local px, py, pz = getCharCoordinates(PLAYER_PED)
        if getDistanceBetweenCoords3d(px, py, pz, ini.catch.outsideX, ini.catch.outsideY, ini.catch.outsideZ) > ini.catch.checkRadius then
            sampAddChatMessage('{ff6969}[HUNT] Exit failed!', -1)
            apartment.hunting = false
            return
        end
    end

    if not apartment.hunting then return end

    -- Phase 2: RTP to entrance
    sampAddChatMessage('{ffff69}[HUNT] RTP to entrance...', -1)
    wait(300)

    local vehicleId = nil
    for attempt = 1, 10 do
        vehicleId = getVehicle(200, true, false)
        if not vehicleId then vehicleId = getVehicle(200, false, false) end
        if vehicleId then break end
        wait(500)
    end
    if not vehicleId then
        sampAddChatMessage('{ff6969}[HUNT] No vehicle after 10 tries!', -1)
        apartment.hunting = false
        return
    end
    local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(vehicleId))
    local vx, vy, vz = getCarCoordinates(vehicleHandle)
    local px, py, pz = getCharCoordinates(PLAYER_PED)

    local entranceZ = getGroundZFor3dCoord(target.posX + 2, target.posY + 2, 100.0)
    if entranceZ == 0 then entranceZ = 15.0 end
    entranceZ = entranceZ + 1.0

    -- slap the player under the map before the teleport
    setCharCoordinates(PLAYER_PED, px, py, pz - 3.0)
    wait(300)

    teleport.boolean = true
    if getDistanceBetweenCoords3d(px, py, pz, vx, vy, vz) > 15 then
        teleportToCar(px, py, pz, vx, vy, vz)
    end
    wait(500)
    teleportToBlip(vehicleId, vx, vy, vz, target.posX, target.posY, entranceZ)

    if not apartment.hunting then return end

    -- plain teleport onto the entrance coords (RTP sometimes lands on the canopy)
    setCharCoordinates(PLAYER_PED, target.posX, target.posY, entranceZ)

    -- Phase 3: Wait 4s after arriving at the house, then teleport to elevator
    sampAddChatMessage('{ffff69}[HUNT] Arrived, elevator in 4s...', -1)
    wait(4000)
    if not apartment.hunting then return end

    -- Elevator: either walk there yourself (manualElevator) or auto-move
    local elevCoords
    if isEconom(target) then
        elevCoords = {x = 2325.9, y = -2454.3, z = 997.6}
    else
        elevCoords = {x = 2769.0, y = 1700.9, z = 997.5}
    end
    local elevTimeout = 15
    if ini.catch.manualElevator then
        sampAddChatMessage('{ffff69}[HUNT] Walk to the elevator and press Alt, floor is picked automatically', -1)
        apartment.waitingElevator = true
        elevTimeout = 60
    else
        sampAddChatMessage('{ffff69}[HUNT] Moving to elevator...', -1)
        safeSetPos(elevCoords.x, elevCoords.y, elevCoords.z)
        wait(200)
        setCharCoordinates(PLAYER_PED, elevCoords.x, elevCoords.y, elevCoords.z)
        wait(300)

        -- The Interactions hook normally sends Alt itself; send ours only if it did not (two Alts close the dialog)
        local tpAt = os.clock()
        apartment.waitingElevator = true
        while os.clock() - tpAt < 1.5 and lastAltTime < tpAt and apartment.hunting do wait(50) end
        if lastAltTime < tpAt then sendAltKey() end
    end

    -- Wait for elevator dialog
    sampAddChatMessage('{ffff69}[HUNT] Waiting elevator...', -1)

    local timeout = os.clock() + elevTimeout
    while os.clock() < timeout and apartment.waitingElevator and apartment.hunting do
        wait(200)
    end

    if apartment.waitingElevator then
        sampAddChatMessage('{ff6969}[HUNT] Elevator timeout!', -1)
        apartment.hunting = false
        apartment.waitingElevator = false
    end
end

function startCatch()
    if catch.active or teleport.boolean then return end
    if ini.catch.safeX == 0.0 and ini.catch.safeY == 0.0 and ini.catch.safeZ == 0.0 then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] No safe pos!', -1)
        return
    end
    local routePoints, routeNum = selectRoute()
    if #routePoints == 0 then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] No route file!', -1)
        return
    end
    catch.active = true
    lua_thread.create(function()
        catch.status = 'Following route...'
        for i, point in ipairs(routePoints) do
            if not catch.active then break end
            catch.status = 'Route ' .. i .. '/' .. #routePoints
            setCharCoordinates(PLAYER_PED, point.x, point.y, point.z)
            wait(ini.catch.routeDelay)
        end
        if not catch.active then return end
        catch.status = 'Waiting exit...'
        local timeout = os.clock() + ini.catch.exitTimeout
        while os.clock() < timeout and catch.active do
            wait(100)
            local px, py, pz = getCharCoordinates(PLAYER_PED)
            if getDistanceBetweenCoords3d(px, py, pz, ini.catch.outsideX, ini.catch.outsideY, ini.catch.outsideZ) <= ini.catch.checkRadius then break end
        end
        local px, py, pz = getCharCoordinates(PLAYER_PED)
        if getDistanceBetweenCoords3d(px, py, pz, ini.catch.outsideX, ini.catch.outsideY, ini.catch.outsideZ) > ini.catch.checkRadius then
            catch.status = 'Exit failed!'
            catch.active = false
            return
        end
        catch.status = 'RTP to safe...'
        wait(300)
        local vehicleId = getVehicle(200, true, false)
        if not vehicleId then catch.active = false; return end
        local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(vehicleId))
        local vx, vy, vz = getCarCoordinates(vehicleHandle)
        px, py, pz = getCharCoordinates(PLAYER_PED)
        slapUnderMap()
        teleport.boolean = true
        catch.active = false
        catch.status = ''
        if getDistanceBetweenCoords3d(px, py, pz, vx, vy, vz) > 15 then teleportToCar(px, py, pz, vx, vy, vz) end
        wait(500)
        teleportToBlip(vehicleId, vx, vy, vz, ini.catch.safeX, ini.catch.safeY, ini.catch.safeZ)
        wait(500)
        local fpx, fpy, fpz = getCharCoordinates(PLAYER_PED)
        local e2, vh2 = sampGetCarHandleBySampVehicleId(vehicleId)
        if e2 then setCarCoordinates(vh2, fpx + 3, fpy, fpz) end
    end)
end

-- slap the player under the map so the server sees it before the teleport (packets are let through while slapping)
function slapUnderMap()
    local was = teleport.boolean
    teleport.boolean = false
    local x, y, z = getCharCoordinates(PLAYER_PED)
    setCharCoordinates(PLAYER_PED, x, y, z - 3.0)
    wait(300)
    teleport.boolean = was
end

function startMarkTp()
    local blip, mx, my, mz = getMark()
    if not blip then sampAddChatMessage('{ff6969}[r-realtor by @purpurins] No mark!', -1); return end
    if not isCharOnFoot(PLAYER_PED) then return end
    local vehicleId = getVehicle(200, true, false)
    if not vehicleId or teleport.boolean then return end
    local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(vehicleId))
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    local cx, cy, cz = getCarCoordinates(vehicleHandle)
    teleport.boolean = true
    lua_thread.create(function()
        slapUnderMap()
        if getDistanceBetweenCoords3d(px, py, pz, cx, cy, cz) > 15 then teleportToCar(px, py, pz, cx, cy, cz) end
        wait(500)
        teleportToBlip(vehicleId, cx, cy, cz, mx, my, mz)
    end)
end

function startTpToCoords(tx, ty, tz)
    if not isCharOnFoot(PLAYER_PED) then return end
    local vehicleId = getVehicle(200, true, false)
    if not vehicleId or teleport.boolean then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] No vehicle or already running!', -1)
        return
    end
    local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(vehicleId))
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    local cx, cy, cz = getCarCoordinates(vehicleHandle)
    teleport.boolean = true
    lua_thread.create(function()
        slapUnderMap()
        if getDistanceBetweenCoords3d(px, py, pz, cx, cy, cz) > 15 then teleportToCar(px, py, pz, cx, cy, cz) end
        wait(500)
        teleportToBlip(vehicleId, cx, cy, cz, tx, ty, tz)
    end)
end

function teleportToBlip(vehicleId, mx, my, mz, cx, cy, cz)
    local step = ini.catch.rtpStep
    local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(tonumber(vehicleId)))
    local start_distance = getDistanceBetweenCoords3d(mx, my, mz, cx, cy, cz)
    local time = os.time()
    sampSendEnterVehicle(vehicleId, true)
    while getDistanceBetweenCoords3d(mx, my, mz, cx, cy, cz) >= step and teleport.boolean do
        teleport.persent = math.floor(100 - (getDistanceBetweenCoords3d(mx, my, mz, cx, cy, cz) / start_distance) * 100)
        local vec = vector(cx - mx, cy - my, cz - mz)
        vec:normalize()
        mx = mx + vec.x * step
        my = my + vec.y * step
        mz = mz + vec.z * step
        sendPassenger(mx, my, mz, vehicleId)
        wait(0)
    end
    sendPassenger(cx, cy, cz, vehicleId)
    sampAddChatMessage('TP: ' .. os.time() - time .. 's', -1)
    requestCollision(cx, cy)
    loadScene(cx, cy, cz)
    wait(200)
    local groundZ = getGroundZFor3dCoord(cx, cy, cz + 5)
    if groundZ == 0 then
        wait(500)
        groundZ = getGroundZFor3dCoord(cx, cy, cz + 5)
    end
    if groundZ == 0 then groundZ = cz end
    setCharCoordinates(PLAYER_PED, cx, cy, groundZ + 1.0)
    setCarCoordinates(vehicleHandle, cx + 3, cy, groundZ + 0.5)
    teleport.boolean = false
    teleport.packet = 0
end

function infobar()
    local font = renderCreateFont('Comic Sans MS', 10, 4)
    local realtorFont = renderCreateFont('Arial', 9, 5)
    while true do wait(0)
        local statusText = ''
        if apartment.hunting then
            statusText = apartment.target and ('{69ff69}HUNT: #' .. apartment.target.aptNumber) or '{ffff69}HUNTING...'
        elseif catch.active then
            statusText = '{ffff69}' .. catch.status
        elseif teleport.boolean then
            statusText = '{ffff69}TP ' .. teleport.persent .. '%'
        else
            local s = '{ff6969}N/A'
            if isCharOnFoot(PLAYER_PED) then
                local v = getVehicle(200, true, false)
                if v then s = '{69ff69}OK (id:' .. v .. ')' end
            end
            statusText = '{FFFFFF}' .. s
        end
        if teleport.bar_move then
            if sampGetCursorMode() ~= 2 then sampSetCursorMode(2) end
            local px, py = getCursorPos()
            if isKeyJustPressed(0x01) then
                sampSetCursorMode(0); ini.main.barX, ini.main.barY = px, py; teleport.bar_move = false; SaveCfg()
            elseif isKeyJustPressed(0x02) then
                sampSetCursorMode(0); teleport.bar_move = false
            end
            renderFontDrawText(font, rainbowText('r-realtor by @purpurins', 1) .. '\n' .. statusText, px, py, -1)
        elseif ini.main.bar then
            renderFontDrawText(font, rainbowText('r-realtor by @purpurins', 1) .. '\n' .. statusText, ini.main.barX, ini.main.barY, -1)
        end

        -- r-realtor by @purpurins HUD
        if ini.main.bar then
            local huntColor = apartment.hunting and '{69FF69}' or '{FF6969}'
            local huntText = apartment.hunting and 'ON' or 'OFF'
            local paydayColor = ini.catch.autoPayday and '{69FF69}' or '{FF6969}'
            local paydayText = ini.catch.autoPayday and 'ON' or 'OFF'
            local aptText = 'NONE'
            local aptColor = '{FF6969}'
            if apartment.target then
                aptText = '#' .. apartment.target.aptNumber .. ' Ent#' .. apartment.target.entrance
                aptColor = '{69FF69}'
            end
            local stText = 'IDLE'
            local stColor = '{FFFFFF}'
            if apartment.waitingBuy then
                stText = 'BUYING'
                stColor = '{69FF69}'
            elseif apartment.waitingElevator then
                stText = 'ELEVATOR'
                stColor = '{FFFF69}'
            elseif apartment.hunting and apartment.target then
                stText = 'CATCHING'
                stColor = '{FFFF69}'
            elseif apartment.hunting then
                stText = 'SEARCHING'
                stColor = '{FFFF69}'
            end

            local realtorHud = '{FFFFFF}r-realtor by @purpurins\n'
                .. '{FFFFFF}HUNTING: ' .. huntColor .. '[' .. huntText .. ']\n'
                .. '{FFFFFF}PAYDAY: ' .. paydayColor .. '[' .. paydayText .. ']\n'
                .. '{FFFFFF}APT: ' .. aptColor .. '[' .. aptText .. ']\n'
                .. '{FFFFFF}STATUS: ' .. stColor .. '[' .. stText .. ']\n'
                .. '{FFFFFF}LIMIT: {FFFF69}[' .. ini.catch.priceLimit .. ']\n'
                .. '{FFFFFF}FREE: {FFFF69}[' .. countFreeApartments() .. ']\n'
                .. '{FFFFFF}ORDER: {FFFF69}[#' .. ini.catch.apartmentIndex .. ']'

            renderFontDrawText(realtorFont, realtorHud, ini.main.barX, ini.main.barY + 40, -1)
        end
    end
end

function teleportToCar(mx, my, mz, cx, cy, cz)
    local h = cz > mz
    local step = ini.catch.curdStep
    while getDistanceBetweenCoords3d(mx, my, mz, cx, cy, cz) >= step and teleport.boolean do
        local vec = vector(cx - mx, cy - my, cz - mz)
        vec:normalize()
        mx = mx + vec.x * step; my = my + vec.y * step; mz = mz + vec.z * step
        sendPlayer(mx, my, mz, h)
        wait(0)
    end
    sendPlayer(cx, cy, cz, h)
end

function sendPassenger(x, y, z, vehicleId)
    local vehicleHandle = select(2, sampGetCarHandleBySampVehicleId(tonumber(vehicleId)))
    local rx, ry, rz, dx, dy, dz = getCarRealMatrix(vehicleHandle)
    local u = createSync('unoccupied')
    u.vehicleId = tonumber(vehicleId); u.seatId = 0
    u.roll = {rx, ry, rz}; u.direction = {dx, dy, dz}
    u.vehicleHealth = getCarHealth(vehicleHandle); u.position = {x, y, z}; u.send()
    teleport.packet = teleport.packet + 1
    if teleport.packet == 10 then
        local p = createSync('passenger')
        p.vehicleId = tonumber(vehicleId); p.seatId = 1
        p.health = getCharHealth(PLAYER_PED); p.armor = getCharArmour(PLAYER_PED)
        p.position = {x, y, z}; p.send()
        teleport.packet = 0
    end
    sendPlayer(x, y, z, true, vehicleId)
end

function sendPlayer(x, y, z, h, surfingVehicleId)
    local p = createSync('player')
    if tonumber(surfingVehicleId) then
        p.surfingOffsets = {1, 1, 1}; p.surfingVehicleId = tonumber(surfingVehicleId)
    end
    p.moveSpeed = {0.1, 0.1, h and 0.1 or -0.1}; p.position = {x, y, z}; p.send()
end

function onSendPacket(id, bs, priority, reliability, orderingChannel)
    if teleport.boolean then return false end
end

function createSync(sync_type, copy_from_player)
    copy_from_player = (copy_from_player == nil) and true or copy_from_player
    local sync_traits = {
        player = {'PlayerSyncData', raknet.PACKET.PLAYER_SYNC, sampStorePlayerOnfootData},
        passenger = {'PassengerSyncData', raknet.PACKET.PASSENGER_SYNC, sampStorePlayerPassengerData},
        unoccupied = {'UnoccupiedSyncData', raknet.PACKET.UNOCCUPIED_SYNC, nil}
    }
    local si = sync_traits[sync_type]
    local dt = 'struct ' .. si[1]
    local data = ffi.new(dt, {})
    local ptr = tonumber(ffi.cast('uintptr_t', ffi.new(dt .. '*', data)))
    if copy_from_player then
        local cf = si[3]
        if cf then
            local pid = (copy_from_player == true) and select(2, sampGetPlayerIdByCharHandle(PLAYER_PED)) or tonumber(copy_from_player)
            cf(pid, ptr)
        end
    end
    local function func_send()
        local bs = raknetNewBitStream()
        raknetBitStreamWriteInt8(bs, si[2])
        raknetBitStreamWriteBuffer(bs, ptr, ffi.sizeof(data))
        raknetSendBitStreamEx(bs, sampfuncs.HIGH_PRIORITY, sampfuncs.UNRELIABLE_SEQUENCED, 1)
        raknetDeleteBitStream(bs)
    end
    return setmetatable({send = func_send}, {
        __index = function(t, k) return data[k] end,
        __newindex = function(t, k, v) data[k] = v end
    })
end

-- mark pointer: resolved by signature in clientside.dll, static offset is the fallback
local MARK_SIG = '8B 0D ? ? ? ? 8D 45 F0 50 E8 11'  -- mov ecx,[mark_slot] in clientside.dll (from mark_dump)
local MARK_SIG_DISP = 2      -- byte index of the 4-byte absolute address inside the pattern
local MARK_OFFSET_FALLBACK = 0x43C350  -- RVA found by mark_dump (was 0x43F220), used only if the signature is not found
local markPtrAddr = nil

local function findSig(mh, sig)
    local bytes, mask = {}, {}
    for tok in sig:gmatch('%S+') do
        if tok == '?' or tok == '??' then bytes[#bytes + 1] = 0; mask[#mask + 1] = false
        else bytes[#bytes + 1] = tonumber(tok, 16); mask[#mask + 1] = true end
    end
    local n = #bytes
    if n == 0 then return nil end
    local nt = ffi.cast('uint32_t*', mh + 0x3C)[0]
    local codeSize = ffi.cast('uint32_t*', mh + nt + 0x1C)[0]   -- SizeOfCode
    local codeBase = ffi.cast('uint32_t*', mh + nt + 0x2C)[0]   -- BaseOfCode
    local base = ffi.cast('uint8_t*', mh + codeBase)
    for i = 0, codeSize - n do
        local ok = true
        for j = 1, n do
            if mask[j] and base[i + j - 1] ~= bytes[j] then ok = false; break end
        end
        if ok then return mh + codeBase + i end
    end
    return nil
end

local function resolveMarkPtrAddr(mh)
    if markPtrAddr then return markPtrAddr end
    local hit = findSig(mh, MARK_SIG)
    if hit then
        markPtrAddr = ffi.cast('uint32_t*', hit + MARK_SIG_DISP)[0]
        return markPtrAddr
    end
    if MARK_SIG ~= '' then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] mark signature not found, using old offset', -1)
    end
    markPtrAddr = mh + MARK_OFFSET_FALLBACK
    return markPtrAddr
end

function getMark()
    local mh = getModuleHandle("clientside.dll")
    if mh == 0 then return false end
    local ptr = memory.getint32(resolveMarkPtrAddr(mh), true)
    if ptr == 0 then return false end
    local tx = memory.getfloat(ptr + 0x10, true)
    local ty = memory.getfloat(ptr + 0x14, true)
    if tx == 0.0 and ty == 0.0 then return false end
    local tz = getGroundZFor3dCoord(tx, ty, 1000.0)
    return true, tx, ty, (tz == 0 and 15.0 or tz)
end

-- set position only after collision/scene at the target is loaded (interiors: no floor yet = fall into the void)
function safeSetPos(x, y, z, timeoutSec)
    requestCollision(x, y)
    loadScene(x, y, z)
    local deadline = os.clock() + (timeoutSec or 3.0)
    while os.clock() < deadline do
        local gz = getGroundZFor3dCoord(x, y, z + 1.0)
        if gz ~= 0 then break end
        requestCollision(x, y)
        wait(50)
    end
    setCharCoordinates(PLAYER_PED, x, y, z)
end

function getCheckPoint()
    local rx, ry, rz = 0.0, 0.0, 0.0
    local found = false
    for id = 0, 31 do
        local ms = 0xC7F168 + id * 56
        local mx = representIntAsFloat(readMemory(ms + 0, 4, false))
        local my = representIntAsFloat(readMemory(ms + 4, 4, false))
        local mz = representIntAsFloat(readMemory(ms + 8, 4, false))
        if mx ~= 0.0 or my ~= 0.0 or mz ~= 0.0 then rx, ry, rz = mx, my, mz; found = true end
    end
    return found, rx, ry, rz
end

function getCarRealMatrix(handle)
    local entity = getCarPointer(handle)
    if entity ~= 0 then
        local cm = memory.getuint32(entity + 20, true)
        if cm ~= 0 then
            return memory.getfloat(cm+0,true), memory.getfloat(cm+4,true), memory.getfloat(cm+8,true),
                   memory.getfloat(cm+16,true), memory.getfloat(cm+20,true), memory.getfloat(cm+24,true)
        end
    end
end

function getVehicle(checkDist, passenger, driver)
    local dist, id = 9999, -1
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    for _, veh in pairs(getAllVehicles()) do
        local exist, vid = sampGetVehicleIdByCarHandle(veh)
        if exist then
            local dc = (getDriverOfCar(veh) ~= -1)
            if (not driver and not dc) or (driver and dc) then
                local cx, cy, cz = getCarCoordinates(veh)
                local nd = getDistanceBetweenCoords3d(cx, cy, cz, px, py, pz)
                if (passenger and getMaximumNumberOfPassengers(veh) > 0) or not passenger then
                    if nd < checkDist and dist > nd then id = vid; dist = nd end
                end
            end
        end
    end
    return id ~= -1 and id or false
end

function rainbowText(text, speed)
    local r = {}
    local len = #text
    for i = 1, len do
        local c = text:sub(i, i)
        local rv = math.floor(math.sin((os.clock() + (1.00/len)*i) * speed + 0) * 127 + 128)
        local gv = math.floor(math.sin((os.clock() + (1.00/len)*i) * speed + 2) * 127 + 128)
        local bv = math.floor(math.sin((os.clock() + (1.00/len)*i) * speed + 4) * 127 + 128)
        r[#r+1] = string.format('{%02X%02X%02X}%s', rv, gv, bv, c)
    end
    return table.concat(r)
end