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


require('samp.synchronization')
local sampfuncs = require('sampfuncs')
local raknet = require('samp.raknet')
local vector = require('vector3d')
local memory = require('memory')
local ffi = require('ffi')

-- stall diagnostics: which teleport call is followed by a game hang (frame gap > 0.7 s is printed with the last call)
local lastTpCall, lastTpCallAt = '', 0
do
    local origChar, origCar = setCharCoordinates, setCarCoordinates
    local function wrap(name, orig)
        return function(...)
            local line = debug.getinfo(2, 'l').currentline
            local t0 = os.clock()
            local r = orig(...)
            local dt = os.clock() - t0
            lastTpCall, lastTpCallAt = name .. ' line ' .. line, os.clock()
            if dt > 0.3 then
                sampAddChatMessage(string.format('{ff6969}[stall] %s line %d blocked %.1fs', name, line, dt), -1)
            end
            return r
        end
    end
    setCharCoordinates = wrap('setCharCoordinates', origChar)
    setCarCoordinates = wrap('setCarCoordinates', origCar)
end
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,
        carId = -1,
        carOffset = 12.0,
        curdPause = 1500
    }
}, 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)

local freezeDbg = false
local freezeSeen, freezeSeenAt = {}, 0
local tpSince = nil

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 not teleport.boolean then
            lua_thread.create(function() carTp(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('rcar', function()
        if carDriving() then
            local ok, id = sampGetVehicleIdByCarHandle(storeCarCharIsInNoSave(PLAYER_PED))
            if ok then
                ini.catch.carId = id
                SaveCfg()
                sampAddChatMessage('{69ff69}[r-realtor by @purpurins] Car saved: id ' .. id, -1)
                return
            end
        end
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] Sit in the car (driver) and use /rcar', -1)
    end)

    sampRegisterChatCommand('rfake', function()
        fakeTpEnabled = not fakeTpEnabled
        sampAddChatMessage('{ffff69}[r-realtor] fake tp (local ped stays, server moves): ' .. (fakeTpEnabled and 'on' or 'off'), -1)
    end)

    sampRegisterChatCommand('rfdbg', function()
        freezeDbg = not freezeDbg
        sampAddChatMessage('{ffff69}[freeze dbg] ' .. (freezeDbg and 'on' or 'off'), -1)
    end)

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

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

-- the server freezes the player for ~10 s after a teleport (TogglePlayerControllable, rpc 15): ignore it around our teleports
local freezeGuardUntil = 0
-- fake position mode: the server sees the player at fake.x/y/z (onfoot sync sent by us), the local ped does NOT move,
-- so the game never streams the far area (that is what hangs the client after a long teleport)
fake = {on = false, x = 0, y = 0, z = 0}
fakeTpEnabled = true

function sendVehicle(x, y, z)
    local p = createSync('vehicle')
    p.position = {x, y, z}
    p.moveSpeed = {0.0, 0.0, 0.0}
    p.send()
end

function fakeStart(x, y, z, car)
    fake.x, fake.y, fake.z = x, y, z
    fake.car = car and true or false
    if fake.on then return end
    fake.on = true
    lua_thread.create(function()
        while fake.on do
            if fake.car and isCharInAnyCar(PLAYER_PED) then
                sendVehicle(fake.x, fake.y, fake.z)
            else
                sendPlayer(fake.x, fake.y, fake.z, false)
            end
            wait(50)
        end
    end)
end

-- stop the fake mode; real = true: move the local ped to the fake point (one real teleport, as before)
function fakeStop(real)
    if not fake.on then return end
    fake.on = false
    if real then
        if fake.car and isCharInAnyCar(PLAYER_PED) then
            setCarCoordinates(storeCarCharIsInNoSave(PLAYER_PED), fake.x, fake.y, fake.z)
        else
            setCharCoordinates(PLAYER_PED, fake.x, fake.y, fake.z)
        end
    end
end

-- keys the script presses by keybd_event (Alt, arrows, Enter): make sure none is left held down
function releaseStuckKeys()
    for _, vk in ipairs({0xA4, 0xA5, 0x28, 0x0D}) do
        releaseKey(vk)
    end
end

local unfreezeRunning = false
function guardFreeze(sec)
    freezeGuardUntil = os.clock() + (sec or 20)
    if unfreezeRunning then return end
    unfreezeRunning = true
    -- keep the player free while the guard is active: control on, position not frozen (no scene loading here)
    lua_thread.create(function()
        releaseStuckKeys()
        while os.clock() < freezeGuardUntil do
            setPlayerControl(PLAYER_HANDLE, true)
            freezeCharPosition(PLAYER_PED, false)
            wait(100)
        end
        releaseStuckKeys()
        unfreezeRunning = false
    end)
end

function onReceiveRpc(id, bs)
    -- any server-side move (leaving the realtor office, interior doors): same guard window
    if id == 12 then
        fake.on = false
        guardFreeze(15)
    end
    if freezeDbg and os.clock() < freezeGuardUntil and id ~= 12 then
        -- every rpc id once per guard window
        if freezeSeenAt ~= freezeGuardUntil then
            freezeSeen = {}
            freezeSeenAt = freezeGuardUntil
        end
        if not freezeSeen[id] then
            freezeSeen[id] = true
            sampAddChatMessage('{ffff69}[freeze dbg] rpc ' .. id, -1)
        end
    end
    if id == 15 and (apartment.hunting or teleport.boolean or os.clock() < freezeGuardUntil) then
        return false
    end
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)
    if id == 41 then   -- connection accepted (reconnect): drop a teleport flag left from before
        fake.on = false
        teleport.boolean = false
        teleport.packet = 0
    end
    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)

    if freezeDbg and id == 215 and os.clock() < freezeGuardUntil then
        sampAddChatMessage('{ffff69}[freeze dbg] cef ' .. text:sub(1, 90), -1)
    end

    -- 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)

    -- the saved car (/rcar): get in, drive-tp near the entrance, get out, curd on foot to the entrance coords
    if not enterSavedCar() then apartment.hunting = false return end
    local carHandle = storeCarCharIsInNoSave(PLAYER_PED)

    -- the car stops carOffset metres short of the entrance (on the line from the start), the rest is on foot
    local sx, sy, sz = getCarCoordinates(carHandle)
    local dirx, diry = sx - target.posX, sy - target.posY
    local dl = math.sqrt(dirx * dirx + diry * diry)
    if dl < 1 then dl = 1 end
    local off = math.min(ini.catch.carOffset, dl)
    local carX = target.posX + dirx / dl * off
    local carY = target.posY + diry / dl * off
    local entranceZ = getGroundZFor3dCoord(target.posX + 2, target.posY + 2, 100.0)
    if entranceZ == 0 then entranceZ = 15.0 end
    entranceZ = entranceZ + 1.0

    if not carTp(carX, carY, entranceZ, false) then apartment.hunting = false return end
    if not apartment.hunting then return end

    -- get out of the car
    taskLeaveAnyCar(PLAYER_PED)
    local t0 = os.clock()
    while isCharInAnyCar(PLAYER_PED) and os.clock() - t0 < 3 do wait(50) end
    if isCharInAnyCar(PLAYER_PED) then
        local cx, cy, cz = getCarCoordinates(carHandle)
        warpCharFromCarToCoord(PLAYER_PED, cx + 2, cy, cz)
    end
    wait(100)

    -- the scene is loaded now: real ground at the entrance, then curd on foot to the exact coords
    local gz = getGroundZFor3dCoord(target.posX, target.posY, entranceZ + 5)
    if gz ~= 0 then entranceZ = gz + 1.0 end
    guardFreeze()
    -- always twice, just in case (no distance check)
    for _ = 1, 2 do
        if not apartment.hunting then return end
        curdToPoint(target.posX, target.posY, entranceZ)
        wait(ini.catch.curdPause or 1500)
    end
    sampAddChatMessage('{ffff69}[HUNT] At the entrance', -1)

    -- 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
        fakeStop(true)   -- walking yourself: the local ped has to be at the entrance
        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)
        guardFreeze()
        fakeStop(false)   -- from here the real position is sent again
        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)
        catch.status = 'Saved car...'
        if not enterSavedCar() then catch.active = false; catch.status = ''; return end
        catch.active = false
        catch.status = ''
        carTp(ini.catch.safeX, ini.catch.safeY, ini.catch.safeZ)
    end)
end

-- slap the player under the map so the server sees it before the teleport (packets are let through while slapping)
function slapUnderMap()
    guardFreeze()
    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 teleport.boolean then return end
    lua_thread.create(function() carTp(mx, my, mz) end)
end

function startTpToCoords(tx, ty, tz)
    if teleport.boolean then return end
    lua_thread.create(function() carTp(tx, ty, tz) end)
end

-- car-only teleport: the player drives, vehicle sync in RTP_STEP-metre steps, one step per frame (no delays)
-- sit (driver) in the car saved with /rcar; call from a lua thread. returns true when sitting in it
function enterSavedCar()
    local savedId = tonumber(ini.catch.carId) or -1
    if savedId < 0 then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] No saved car (sit in it and use /rcar)', -1)
        return false
    end
    -- the interior / dimension changes with a delay after leaving: wait for the outside world, then for the car to stream in
    local t0 = os.clock()
    while getActiveInterior() ~= 0 and os.clock() - t0 < 10 do wait(100) end
    local okCar, carHandle = false, nil
    t0 = os.clock()
    while os.clock() - t0 < 10 do
        okCar, carHandle = sampGetCarHandleBySampVehicleId(savedId)
        if okCar and doesVehicleExist(carHandle) then break end
        okCar = false
        wait(200)
    end
    if not okCar then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] Saved car not found (save it with /rcar while sitting in it, stand near it)', -1)
        return false
    end
    if not (isCharInAnyCar(PLAYER_PED) and storeCarCharIsInNoSave(PLAYER_PED) == carHandle) then
        local drv = getDriverOfCar(carHandle)
        if drv ~= -1 and drv ~= PLAYER_PED then
            sampAddChatMessage('{ff6969}[r-realtor by @purpurins] Saved car is occupied', -1)
            return false
        end
        sampSendEnterVehicle(savedId, false)
        wait(150)
        warpCharIntoCar(PLAYER_PED, carHandle)
        wait(300)
    end
    if not carDriving() then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] Could not sit in the saved car', -1)
        return false
    end
    return true
end

function carDriving()
    return isCharInAnyCar(PLAYER_PED) and getDriverOfCar(storeCarCharIsInNoSave(PLAYER_PED)) == PLAYER_PED
end

function carTp(tx, ty, tz, fakeEnd)
    if teleport.boolean then return false end
    if not carDriving() then
        sampAddChatMessage('{ff6969}[r-realtor by @purpurins] Teleport works only while driving a car!', -1)
        return false
    end
    local car = storeCarCharIsInNoSave(PLAYER_PED)
    local mx, my, mz = getCarCoordinates(car)
    local step = ini.catch.rtpStep
    local startDist = getDistanceBetweenCoords3d(mx, my, mz, tx, ty, tz)
    local time = os.time()
    teleport.boolean = true
    teleport.persent = 0
    guardFreeze()
    local streamed = false
    local sent, BURST = 0, 8
    while getDistanceBetweenCoords3d(mx, my, mz, tx, ty, tz) >= step and teleport.boolean do
        -- stream the destination only when little is left
        if not streamed and not fakeEnd and getDistanceBetweenCoords3d(mx, my, mz, tx, ty, tz) < step * 4 then
            streamed = true
            requestCollision(tx, ty)
            loadScene(tx, ty, tz)
        end
        teleport.persent = math.floor(100 - (getDistanceBetweenCoords3d(mx, my, mz, tx, ty, tz) / startDist) * 100)
        local vec = vector(tx - mx, ty - my, tz - mz)
        vec:normalize()
        mx = mx + vec.x * step; my = my + vec.y * step; mz = mz + vec.z * step
        sendVehicle(mx, my, mz)
        -- the sync is unreliable: a burst of hundreds of packets in one frame gets partly dropped and the server rolls back to the last
        -- position it got ("a bit, again a bit"). A short yield every few packets keeps them arriving in order.
        sent = sent + 1
        if sent % BURST == 0 then wait(0) end
    end
    if not teleport.boolean then return false end   -- stopped by hand
    for _ = 1, 3 do sendVehicle(tx, ty, tz) end
    sampAddChatMessage('TP: ' .. os.time() - time .. 's', -1)
    if fakeEnd then
        fakeStart(tx, ty, tz, true)
    else
        requestCollision(tx, ty)
        loadScene(tx, ty, tz)
        -- ground height: probe from above (the old probe from tz+5 returned the floor below when tz was under the ground)
        local gz = 0
        local t0 = os.clock()
        while gz == 0 and os.clock() - t0 < 1.5 do
            gz = getGroundZFor3dCoord(tx, ty, tz + 60)
            if gz == 0 then gz = getGroundZFor3dCoord(tx, ty, 800.0) end
            if gz == 0 then requestCollision(tx, ty); wait(50) end
        end
        local landZ = (gz ~= 0) and (gz + 0.8) or (tz + 3.0)
        setCarCoordinates(car, tx, ty, landZ)
        wait(100)
        -- still under the map: lift it
        local _, _, cz0 = getCarCoordinates(car)
        if cz0 < landZ - 2 then setCarCoordinates(car, tx, ty, landZ + 2.0) end
        -- landed in water: look around for dry ground (rings of 8 points), up to 20 tries
        if isCarInWater(car) then
            local tries = 0
            for r = 6, 60, 6 do
                for a = 0, 7 do
                    local ang = math.rad(a * 45)
                    local nx, ny = tx + math.cos(ang) * r, ty + math.sin(ang) * r
                    requestCollision(nx, ny)
                    local ngz = getGroundZFor3dCoord(nx, ny, tz + 30)
                    if ngz ~= 0 then
                        setCarCoordinates(car, nx, ny, ngz + 0.5)
                        wait(80)
                        tries = tries + 1
                        if not isCarInWater(car) then break end
                    end
                end
                if not isCarInWater(car) or tries >= 20 then break end
            end
        end
        -- the char jumped out of the car in the water: back to the driver seat
        if not isCharInAnyCar(PLAYER_PED) then
            warpCharIntoCar(PLAYER_PED, car)
            wait(100)
        end
    end
    teleport.boolean = false
    teleport.packet = 0
    if not fakeEnd then
        -- push the real position to the server right away, a few times: its streamer (cars, pickups, players) works from the last known position
        for _ = 1, 5 do
            if carDriving() then sampForceVehicleSync() else sampForceOnfootSync() end
            wait(60)
        end
    end
    return true
end

function teleportToBlip(vehicleId, mx, my, mz, cx, cy, cz, fakeEnd)
    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)
    if fakeEnd then
        -- the local ped stays where it is: the server gets onfoot sync at the destination
        fakeStart(cx, cy, cz)
        teleport.boolean = false
        teleport.packet = 0
        sampAddChatMessage('TP (fake): ' .. os.time() - time .. 's', -1)
        return
    end
    sampAddChatMessage('TP: ' .. os.time() - time .. 's', -1)
    requestCollision(cx, cy)
    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)
    local lastFrame = os.clock()
    while true do wait(0)
        local nowF = os.clock()
        setCharCanBeKnockedOffBike(PLAYER_PED, false)   -- the char cannot fall off a bike
        if nowF - lastFrame > 0.7 then
            sampAddChatMessage(string.format('{ff6969}[stall] game froze %.1fs, last call: %s (%.1fs before)', nowF - lastFrame, lastTpCall, (lastFrame - lastTpCallAt)), -1)
        end
        lastFrame = nowF
        -- hunt stopped / ended while the fake position is on: bring the local ped to it, real sync again
        if fake.on and not apartment.hunting and not teleport.boolean then
            fakeStop(true)
        end
        -- watchdog: the teleport flag cannot stay on for more than 2 minutes (it blocks the sync)
        if teleport.boolean then
            tpSince = tpSince or os.clock()
            if os.clock() - tpSince > 120 then
                teleport.boolean = false
                tpSince = nil
                sampAddChatMessage('{ff6969}[r-realtor] TP flag stuck, reset', -1)
            end
        else
            tpSince = nil
        end
        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

-- stepwise (curd) teleport from the current position to a point, then a real set at the end
function curdToPoint(x, y, z)
    local px, py, pz = getCharCoordinates(PLAYER_PED)
    teleport.boolean = true
    guardFreeze()
    teleportToCar(px, py, pz, x, y, z)
    teleport.boolean = false
    setCharCoordinates(PLAYER_PED, x, y, z)
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

-- only the sync packets are held back while teleporting (not everything: a stuck flag used to freeze the player for good)
local SYNC_PACKETS = {[200] = true, [203] = true, [204] = true, [206] = true, [207] = true, [209] = true, [210] = true, [211] = true, [212] = true}

function onSendPacket(id, bs, priority, reliability, orderingChannel)
    if (teleport.boolean or fake.on) and SYNC_PACKETS[id] 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},
        vehicle = {'VehicleSyncData', raknet.PACKET.VEHICLE_SYNC, sampStorePlayerIncarData},
        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 = 0x445FD0  -- RVA of the mark slot (was 0x43C350), 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
    if isCharInAnyCar(PLAYER_PED) then
        warpCharFromCarToCoord(PLAYER_PED, x, y, z)
    else
        setCharCoordinates(PLAYER_PED, x, y, z)
    end
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