UDP в Lua: LuaSocket на практике

С LuaSocket и UDP начинались SL:MP и SNET. Разбираю базовый API на рабочем примере: сервер на LuaJIT и клиент в MoonLoader, которые обмениваются позициями игроков.

Автор: Опубликовано: Обновлено: 3 мин чтения

SL:MP начался в августе 2020 года с простого вопроса: получится ли синхронизировать игроков в GTA: San Andreas одним LuaSocket поверх SA:MP? Получилось. Дальше протокол прошёл путь от сжатого JSON через сериализованные Lua-таблицы к бинарному SL:NET поверх UDP. Сетевую часть я затем вынес в отдельную библиотеку SNET. Ниже – тот самый фундамент: голый LuaSocket и UDP, без надстроек.

Почему UDP

TCP гарантирует доставку и порядок. Для игры это скорее минус: если один сегмент потерялся, всё, что пришло за ним, ждёт повторной отправки. Позиция игрока, которая опоздала на полсекунды, уже никому не нужна, а из-за неё задерживаются и свежие данные.

UDP отправляет датаграммы как есть. Каждая приходит целиком или не приходит вовсе. Цена такая:

  • пакеты теряются, и никто об этом не сообщит;
  • приходят не в том порядке, в каком ушли;
  • иногда дублируются.

Для потока позиций это терпимо: следующий пакет придёт через 100 мс и перекроет потерянный. Для сообщения в чат или выбора пункта в диалоге – уже нет.

Сервер на LuaJIT

Основные функции LuaSocket для UDP: socket.udp() создаёт сокет, setsockname привязывает его к адресу и порту, receivefrom читает датаграмму вместе с адресом отправителя, sendto отправляет на конкретный адрес. Сервер ниже принимает пакеты от клиентов и рассылает каждый всем остальным:

local socket = require("socket")
local udp = assert(socket.udp())
assert(udp:setsockname("*", 7777))
udp:settimeout(0)
local clients = {}
while true do
  local data, ip, port = udp:receivefrom()
  if data then
    local key = ip .. ":" .. port
    clients[key] = { ip = ip, port = port, seen = socket.gettime() }
    for k, c in pairs(clients) do
      if k ~= key then
        udp:sendto(key .. " " .. data, c.ip, c.port)
      end
    end
  else
    -- очередь пуста: чистим молчащих клиентов и отдаём процессор
    local now = socket.gettime()
    for k, c in pairs(clients) do
      if now - c.seen > 10 then
        clients[k] = nil
      end
    end
    socket.sleep(0.01)
  end
end

"*" в setsockname означает все локальные интерфейсы. «Подключения» в UDP нет, поэтому клиента идентифицирует пара IP и порт, а отключение приходится определять по тишине – здесь это 10 секунд без пакетов.

С таймаутом 0 receivefrom при пустой очереди возвращает nil, "timeout". Пока пакеты есть, цикл вычитывает их без пауз, а socket.sleep срабатывает только когда читать нечего.

Клиент в MoonLoader

Клиенту нужен один сервер, поэтому удобнее setpeername: после него send и receive работают без адреса. В отличие от sendto, setpeername принимает и имя хоста.

local socket = require("socket")
local udp = assert(socket.udp())
udp:settimeout(0)
assert(udp:setpeername("127.0.0.1", 7777))
local function send_position()
  local x, y, z = getCharCoordinates(PLAYER_PED)
  udp:send(string.format("%.2f %.2f %.2f", x, y, z))
end
local function read_packets()
  while true do
    local data = udp:receive()
    if not data then break end
    local id, x, y, z = data:match("^(%S+) (%S+) (%S+) (%S+)$")
    if id then
      print(id, tonumber(x), tonumber(y), tonumber(z))
    end
  end
end
function main()
  local last_send = 0
  while true do
    wait(0)
    read_packets()
    local now = socket.gettime()
    if now - last_send >= 0.1 then
      send_position()
      last_send = now
    end
  end
end
function onScriptTerminate(script, quit_game)
  if script == thisScript() then
    udp:close()
  end
end

wait(0) отдаёт управление игре до следующего кадра, так что скрипт опрашивает сокет раз в кадр и не подвешивает GTA. Отправка ограничена десятью пакетами в секунду: слать позицию каждый кадр при 60 FPS – лишний трафик без заметной пользы. Вместо print в настоящем мультиплеере здесь двигался бы персонаж другого игрока.

Порядок и потери

Самая простая защита от перемешанных пакетов – порядковый номер. Отправитель увеличивает счётчик и кладёт его в пакет, получатель отбрасывает всё, что старее последнего принятого:

local last_seq = {}
local function accept(id, seq)
  if last_seq[id] and seq <= last_seq[id] then
    return false
  end
  last_seq[id] = seq
  return true
end

Для позиций этого достаточно: опоздавший пакет просто выбрасывается. С важными событиями сложнее. Их нужно подтверждать и переотправлять, пока подтверждение не придёт, и при этом не задерживать поток позиций. Плюс текстовый формат из примера неэкономен: числа в строке занимают в несколько раз больше байт, чем в бинарном виде. В SL:MP мы в итоге пришли к бинарному SL:NET с собственным битстримом.

Ещё одно ограничение касается размера. Типичный MTU в Ethernet – 1500 байт, и датаграмма крупнее этого дробится на IP-фрагменты. Если потеряется любой фрагмент, пропадёт вся датаграмма, так что большой пакет теряется заметно чаще маленького. Игровые пакеты лучше держать в пределах нескольких сотен байт, а большие данные (список игроков при входе, содержимое диалога) резать на части или отправлять через механизм с подтверждением.

Что добавляет SNET

SNET решает как раз эти задачи поверх того же LuaSocket и UDP. В нём есть подтверждение доставки для важных пакетов и приоритеты: что-то уходит «как получится», что-то обязано дойти. Ограничения на число клиентов в самой библиотеке нет. Работает SNET на LuaJIT под Windows и Linux и в MoonLoader, протокол одинаковый на обеих сторонах. Сообщество портировало его на Node.js (node-snet), был и экспериментальный порт на Go.

Если нужен собственный сетевой мод на Lua, начать с голого LuaSocket полезно хотя бы для того, чтобы понять, какие проблемы решает библиотека. Код в статье оформлен по правилам из моего гайда, а посмотреть, как всё это выглядит в готовом мультиплеере, можно, запустив сервер SL:MP.

Частые вопросы

Почему sendto выдаёт ошибку, если передать имя хоста?

В LuaSocket sendto принимает только IP-адрес: имена хостов там не разрешаются ради скорости. Либо заранее получите IP через socket.dns.toip(), либо используйте setpeername, который имена принимает.

Зачем settimeout(0), если можно читать сокет в отдельном потоке?

В Lua нет потоков в привычном смысле, а MoonLoader выполняет скрипты в корутинах внутри игрового цикла. Блокирующий receive остановил бы весь скрипт до прихода пакета. С таймаутом 0 вызов сразу возвращает nil и "timeout", если читать нечего.

Чем UDP-сокет с setpeername отличается от обычного?

После setpeername сокет становится «подключённым»: send и receive работают без адреса, а датаграммы от других адресов отбрасываются. Для клиента с одним сервером это удобно, для сервера с многими клиентами нужен неподключённый сокет и sendto/receivefrom.