В 2004 году в RuneScape играли часами на 56k-модеме, который умирал в тот момент, как мама снимала трубку телефона. Трёхмерный мир, до пары тысяч игроков на сервере, десятки персонажей одновременно на экране — всё это работало в браузере, на скорости порядка 5 килобайт в секунду. Стоит проследить один-единственный игровой шаг, чтобы понять, как это было устроено.

В детстве было не до вопросов о том, как это работает — куда важнее казались сбор льна и убийство гоблинов. Ответ же — это последовательное, почти навязчивое стремление не тратить байты впустую. Итак: клик на клетке на один шаг севернее того места, где стоит персонаж, и путь каждого байта, пересекающего провод от этого клика — до сервера и до экрана другого игрока.

Центральный фонтан, площадь Варрок
Центральный фонтан, площадь Варрок

Методология

Детали в этом материале взяты из декомпилированного клиента RuneScape 2 2004 года. Приведённые фрагменты кода — вольный перевод из декомпиляции, местами упрощённый для читаемости, но с сохранением логики.

Ключевые принципы не идентичны во всех версиях игры, но большинство из них прослеживаются от RuneScape Classic (2001) до нынешней RuneScape 3 и, конечно, Old School RuneScape.

Ограничения

Разработчики Jagex работали в довольно жёстких рамках.

  • Пропускная способность. 56k-модем синхронизируется на скорости 56 килобит в секунду на приём, и меньше на отдачу, минус накладные расходы протокола и шумы линии. Условно 5 КБ/с на приём и заметно меньше на отдачу. Широкополосный интернет появился в британских домах уже к 2000 году, но большинство домохозяйств Великобритании перешло на него только к концу 2000-х — так что немало игроков сидело именно на dial-up.
  • Java-апплет, в браузере, в 2004 году. Java-апплеты работали в песочнице безопасности, что означало отсутствие сырых нативных сокетов и UDP. Каждый байт шёл через одно-единственное TCP-соединение — упорядоченно и с накладными расходами на каждый сегмент.
  • 600-миллисекундный серверный цикл. Игровой сервер RuneScape продвигается дискретными циклами (тиками) примерно по 600 миллисекунд. Каждый цикл для каждого игрока сервер должен вычислить всё, что этот игрок теперь видит, и отправить это до начала следующего цикла.

Слой шифрования, коротко

После завершения хендшейка логина, до отправки первых игровых пакетов, настраивается небольшой слой шифрования. Речь здесь не об экономии байтов — это единственное шифрование во всём стеке (не считая RSA-шифрования в хендшейке логина), и оно существует потому, что защищаемый им опкод — это ровно то, от чего зависит весь остальной протокол.

Каждый пакет начинается с байта «опкода» — небольшого числа, указывающего тип пакета. Именно этот опкод (и только он) шифруется потоковым шифром ISAAC. В работе задействованы два потока — один для трафика от клиента к серверу, другой для обратного направления. Обеим сторонам нужны оба потока: клиент шифрует то, что собирается отправить, и расшифровывает только что пришедшее, а сервер делает то же самое зеркально (для каждого подключённого игрока).

Оба потока получают начальное состояние из общего ключа из четырёх целых чисел. Два из них генерирует сам клиент; ещё два приходят от сервера в рамках хендшейка. Поток от сервера к клиенту использует тот же seed, но с добавлением 50 к каждому слову — этого достаточно, чтобы направления не делили один и тот же keystream:

this.outboundCipher = new ISAAC(seed);

for (int index = 0; index < 4; index++) {
    seed[index] += 50;
}

this.inboundCipher = new ISAAC(seed);

Шифрование на отправку — одна строка:

public void putOpcode(int opcode) {
    this.putByte(opcode + this.outboundCipher.value());
}

А на приём — зеркальная операция:

this.currentOpcode = (this.currentOpcode - this.inboundCipher.value()) & 0xFF;

Таким образом, тело пакета не зашифровано — только опкод. Как будет видно дальше, именно опкод сообщает, как читать остальную часть пакета и где заканчивается один пакет и начинается следующий. Без него тело — просто стена байтов, поэтому шифрование одного этого байта было самой дешёвой возможной защитой от сторонних парсеров пакетов.

Отправка запроса на перемещение

Разберём, что происходит при клике на клетку на один квадрат севернее и как это передаётся на сервер.

Ещё до какого-либо сетевого взаимодействия клиент запускает поиск в ширину по локальной карте коллизий, чтобы построить путь от текущей позиции до точки клика (в данном случае — тривиальный поиск), а затем записывает пакет для чтения сервером. Алгоритм поиска пути стандартный, поэтому подробно на нём останавливаться не будем.

Первая часть пакета — опкод, за которым следует один байт с длиной тела пакета. Как станет видно дальше, число байтов в пакете зависит от длины пути, поэтому байт «длины» позволяет серверу понять, сколько данных читать. Этот байт длины есть не у всех пакетов — только у тех, у которых тело переменного размера.

Начальная позиция занимает 4 байта (два short), каждая последующая точка пути — дельта в 2 байта, и ещё один финальный байт под флаг зажатой клавиши Ctrl. Таким образом, длина тела равна 4 + 2 * (pathLength - 1) + 1.

this.outboundStream.putOpcode(ClientToServerOpcodes.WALK_TILE);
this.outboundStream.putByte(4 + 2 * (pathLength - 1) + 1);

Пакет содержит абсолютную позицию первой точки пути (x и z, каждая как двухбайтовый short), а за ней — дельту каждой последующей точки пути относительно первой: по одному знаковому байту на ось, что комфортно укладывается в диапазон байта от -128 до 127, поскольку одиночный клик физически не может «улететь» дальше.

Решение передавать здесь только дельту, по 2 байта на шаг, а не абсолютные координаты по 4 байта на шаг — первый встреченный пример бережливости Jagex к сети. В абсолютных числах это экономит всего несколько байт для одного пакета перемещения, но каждая дополнительная точка пути стоит 2 байта вместо 4 — экономия 50% на каждую точку.

int firstX = pathX[0];
int firstZ = pathZ[0];

this.outboundStream.putShort(this.playerPositionX + firstX);
this.outboundStream.putShort(this.playerPositionZ + firstZ);

for (int i = 1; i < pathLength; i++) {
    this.outboundStream.putByte(this.pathX[i] - firstX);
    this.outboundStream.putByte(this.pathZ[i] - firstZ);
}

Ещё одно бережливое решение: pathX и pathZ содержат не каждую клетку пути, а только углы. Ходьба на десять клеток по прямой отправляет только одну точку пути — конечную. Сервер уже знает, откуда игрок начал, поэтому сам прокладывает линию и проверяет её по собственной карте коллизий.

Последняя часть пакета — один байт, указывающий, зажата ли клавиша Ctrl. В ранних версиях игры это использовалось для принудительного включения «режима бега», в более поздних версиях — инвертирует текущий режим перемещения (бежит к точке клика, если «бег» выключен, или идёт шагом, если включён):

this.outboundStream.putByte(this.keyStatus[Keys.CTRL] == 1 ? 1 : 0);

Итого один шаг на север занимает семь байт, включая опкод и маркер длины:

Поскольку путь состоял всего из одной клетки, цикл отправки «дельта»-точек не выполняется, так что можно сверить полезную нагрузку в 5 байт с маркером длины:

  • 4 + 2 * (pathLength - 1) + 1 = 4 + 2 * 0 + 1 = 5

После выполнения приведённых выше фрагментов кода пакет оказывается в исходящем потоке клиента. Этот поток сбрасывается в сеть примерно раз в 20 мс.

Сервер получает запрос

Главный цикл сервера просыпается примерно раз в 600 мс. При каждом пробуждении он вычитывает входящий буфер каждого игрока, запускает обработчики, требуемые пакетами, и формирует исходящие обновления игроков, о которых пойдёт речь дальше. Пакет, пришедший непосредственно перед обработкой цикла, обрабатывается почти мгновенно; пришедший сразу после — ждёт почти целые 600 мс.

Именно эти 600 мс цикла задают гранулярность задержки. 20-миллисекундный сброс на клиенте и прочие сетевые накладные расходы укладываются далеко внутрь этого интервала. Поэтому весь остальной материал — про байты, а не про время: экономить задержку тут попросту не на чем.

После того как сервер вычитал входящий буфер, чтение пакета происходит примерно так же, как описано выше, только в обратном порядке:

int opcode = player.inboundStream.takeOpcode();

if (opcode == ClientToServerOpcodes.WALK_TILE) {
    int length = player.inboundStream.takeByte();

    int deltaCount = (length - 4 - 1) / 2;

    int[] firstWaypoint = new int[2];
    firstWaypoint[0] = player.inboundStream.takeShort();
    firstWaypoint[1] = player.inboundStream.takeShort();

    int[][] waypointDeltas = new int[deltaCount][2];
    for (int i = 0; i < deltaCount; i++) {
        waypointDeltas[i][0] = player.inboundStream.takeByte();
        waypointDeltas[i][1] = player.inboundStream.takeByte();
    }

    boolean holdingCtrl = player.inboundStream.takeByte() == 1;

    player.processWalkTile(firstWaypoint, waypointDeltas, holdingCtrl);
}

Как видно, после определения опкода можно прочитать байт длины и, обратив логику записи, восстановить количество дельт.

Ранее упоминалось, что байт длины есть не у всех пакетов. На самом деле у большинства его нет — у большей части пакетов тело фиксированной длины. Читать их ещё проще. Взять хотя бы пакет «предмет на предмет» — отправляется, когда игрок «применяет» один предмет из инвентаря к другому:

if (opcode == ClientToServerOpcodes.USE_ITEM_ON_ITEM) {
    int sourceItemId = player.inboundStream.takeShort();
    int sourceInterfaceId = player.inboundStream.takeShort();
    int sourceInterfaceSlot = player.inboundStream.takeShort();

    int targetItemId = player.inboundStream.takeShort();
    int targetInterfaceId = player.inboundStream.takeShort();
    int targetInterfaceSlot = player.inboundStream.takeShort();

    player.processUseItemOnItem(/* ... */);
}

У этого пакета фиксированная длина 12 байт (6 short). Сервер заранее знает эту постоянную длину, так что передавать маркер длины для этого пакета не нужно.

Серверный цикл

Серверный цикл RuneScape состоит из нескольких шагов, и интересующие нас происходят в следующем порядке:

  • чтение входящих пакетов
  • обработка игроков (очередь действий, триггеры, перемещение и т. д.)
  • формирование обновлений игроков (подробнее в следующем разделе)
  • сброс исходящих пакетов

Общий принцип прост: прочитать, затем сделать, затем записать.

Обновления игроков

Прежде чем разбирать пакет, стоит явно проговорить основу протокола: клиент хранит собственное зеркало каждого игрока, которого видит. Отслеживаемый список ближних игроков, у каждого — последняя известная позиция, внешность, анимация и состояние чата, плюс собственное состояние локального игрока. Задача пакета обновления игроков — держать это зеркало синхронизированным с эталонной версией сервера, а значит, почти всегда обновление — это дельта относительно того, что клиент уже знает. «Ничего не изменилось» настолько дёшево именно потому, что данные уже есть у клиента; сервер лишь подтверждает, что они всё ещё актуальны.

Каждый цикл сервер отправляет игроку один составной «пакет обновления игроков». Этот единственный пакет описывает всё, что клиенту нужно знать про каждого видимого игрока — включая самого себя. Принимающий клиент разбирает эту информацию за четыре шага, и порядок таков:

private void readPlayerUpdates(Packet packet) {
    packet.accessMode(PacketAccess.BITS);

    this.readLocalPlayer(packet);

    // other players already tracked by the client
    this.readOtherPlayers(packet);

    // players newly in range, which the client should start tracking
    this.readNewPlayers(packet);

    packet.accessMode(PacketAccess.BYTES);

    // detailed changes about players
    this.readPlayerDetails(packet);
}

Первые три шага упакованы побитово — поток читается по несколько бит за раз, а не побайтово. Только четвёртый шаг в этой последовательности выровнен по байтам. Такое разделение неслучайно: перемещение и регистрация происходят часто и занимают мало места, поэтому им достаются биты; более редкие «богатые» обновления (игрок сменил экипировку, взмахнул мечом или что-то сказал) получают байты.

Шаг 1: локальный игрок

Логика чтения локального игрока проста, поэтому сначала код, а разбор — после:

private void readLocalPlayer(Packet packet) {
    int updated = packet.takeBits(1);

    // no local movement and no local detail changes
    if (updated == 0) {
        return;
    }
    
    int movementType = packet.takeBits(2);

    // type 1: a walk
    if (movementType == 1) {
        int direction = packet.takeBits(3);

        this.localPlayer.step(direction, false);

        int detailUpdated = packet.takeBits(1);
        if (detailUpdated == 1) {
            this.trackPlayerDetails(this.localPlayer.id);
        }
    }
    // type 0: no move, but a detail update follows
    // type 2: a run - two directions back-to-back
    // type 3: a teleport
}

Стоит перечитать первый if. Если локальный игрок не двигался и с ним ничего не изменилось за этот цикл, всё его присутствие в пакете обновления — это один бит. Не байт. Бит. Самое частое состояние любого игрока на любом цикле — «без изменений» — сделали самой дешёвой из возможных передач.

Если локальный игрок всё же переместился, это бит 1, два бита на тип, три бита на направление и один бит на флаг «будут ли ещё детали». Семь бит, меньше одного байта, чтобы сказать «я сделал шаг». Без учёта первого флага «требуется обновление» и типа перемещения это укладывается в четыре бита.

Остальные типы тоже дешёвы. Без учёта трёхбитных заголовков:

  • тип 0 (нет движения, но дальше идут детали): полезной нагрузки нет. Ноль бит.
  • тип 2 (бег): два трёхбитных направления подряд и бит флага «есть детали». Семь бит.
  • тип 3 (телепорт): плоскость высоты (2 бита), координаты x и z (по 7 бит), флаг «есть детали» и бит «прыжок» (сообщает клиенту, нужно ли анимировать это перемещение). Чуть дороже, но всё равно восемнадцать бит — немногим больше двух байт.

Шаг 2: отслеживаемые игроки

Та же идея, что и выше, применённая к толпе уже отслеживаемых игроков.

Важный момент: чтение отдельных бит здесь продолжается сразу же после раздела «локальный игрок» выше. То есть если раздел локального игрока занял всего 1 бит, следующий раздел начнёт читать со 2-го бита — пустого места для выравнивания байтов нет.

private void readOtherPlayers(Packet packet) {
    int count = packet.takeBits(8);

    for (int i = 0; i < count; i++) {
        int updated = packet.takeBits(1);

        if (updated == 0) {
            continue;
        }

        // read movementType etc as above
    }
}

8-битный счётчик, а затем по одному биту на каждого известного игрока — произошло ли с ним что-то. Стоя в толпе из сорока игроков, где никто не двигается, это сорок восемь бит (шесть байт), чтобы подтвердить, что вся сцена статична. Любой игрок, который действительно сделал шаг, стоит те же семь бит, что и локальный игрок в шаге 1.

Это ключевой приём. Состояние по умолчанию — «ничего не изменилось» — это один бит, самое дешёвое из возможных представлений. Реальные биты тратятся только на то, что действительно изменилось. Сервер и клиент разделяют, зашитое ещё на этапе компиляции, идентичное понимание протокола — включая то, что считается состоянием по умолчанию и что считается изменением. Ни одной из сторон никогда не нужно детально описывать «отсутствие изменений»; отсутствие детали, за нулевым битом, и есть сообщение.

Шаг 3: новые игроки в зоне видимости

Когда кто-то впервые заходит (или иначе появляется в поле зрения: входит в игру, телепортируется и т. д.), сервер должен представить его — кто это и где он находится относительно вас:

private void readNewPlayers(Packet packet) {
    // room for an 11-bit player id
    while (packet.bitsRemaining > 10) {
        int playerId = packet.takeBits(11);

        // sentinel: no more players
        if (playerId == 2047) {
            break;
        }

        Player otherPlayer;
        // ... allocate or look up the player ...

        int updated = packet.takeBits(1);
        if (updated == 1) {
            this.trackPlayerDetails(playerId);
        }

        int teleported = packet.takeBits(1);

        int deltaX = packet.takeBits(5);
        if (deltaX >= 16) { deltaX -= 32; } // signed 5-bit value: -16 to +15

        int deltaZ = packet.takeBits(5);
        if (deltaZ >= 16) { deltaZ -= 32; }

        otherPlayer.move(localPlayer.x + deltaX, localPlayer.z + deltaZ, teleported == 1);
    }
}

11-битный идентификатор игрока (значение 2047 зарезервировано как сигнальное «стоп», поэтому список не нуждается в заголовке длины), один бит на флаг «дальше будут детали», один бит — телепортировался ли игрок, и 10 бит на позицию. Позиция — одна из самых любимых деталей в этом разделе.

Относительные координаты

Абсолютные мировые координаты игрока — пара значений порядка тысяч: карта RuneScape очень велика (тысячи клеток по каждой оси). Два 16-битных числа, 32 бита в сумме, чтобы разместить кого-то в любой точке этой карты.

Но логике обновления игроков выше глобальная позиция не нужна. Достаточно знать, где игрок находится относительно локального игрока, потому что только это и можно увидеть. Другой игрок, находящийся в зоне отрисовки, удалён максимум примерно на пятнадцать клеток. Пятнадцать удобно укладывается в знаковое 5-битное число (от -16 до +15). Так что позиция вновь появившегося игрока стоит десять бит — по пять на ось — вместо тридцати двух. Пространство координат пересчитывается относительно локального игрока и обрезается до видимой области. Кодирование подогнано ровно под эту урезанную область, ни бита сверх нужного. Та же логика встречается в ветке телепорта из шага 1, где координаты выражены двумя 7-битными значениями (достаточно, чтобы адресовать загруженную область примерно в 104 клетки), а не полными мировыми координатами.

Это повторяющийся повсюду паттерн: определить наименьший набор значений, который вообще может понадобиться, и использовать ровно столько бит, сколько нужно для его представления, не больше.

Битовый курсор

В начале раздела упоминалось, что шаги 1–3 читают отдельные биты, а шаг 4 читает целые байты. Всё чтение «по несколько бит за раз» реализовано одним небольшим методом, который ведёт учёт позиции. Соглашение таково: биты заполняют каждый байт сверху вниз — первый бит стоит в позиции 7, последний — в позиции 0:

public int takeBits(int count) {
    int value = 0;
    for (int n = 0; n < count; n++) {
        int bytePos = this.bitPosition / 8;
        int bitInByte = 7 - (this.bitPosition % 8);

        int bitValue = (this.buffer[bytePos] >> bitInByte) & 1;
        value = (value << 1) | bitValue;

        this.bitPosition++;
    }
    return value;
}

Как видно, метод выше проходит буфер по одному биту за раз. Без этого каждое «три бита на направление» и «один бит на бездействующего игрока» пришлось бы читать целым байтом, что убило бы большую часть бережливости протокола — восьми бездействующим игрокам понадобилось бы восемь байт вместо одного.

Когда побитовые шаги завершены, курсор округляется до начала следующего целого байта, и шаг 4 переходит к обычному побайтовому чтению.

Шаг 4: детальные изменения игрока

Четвёртый шаг отвечает за детальные обновления игрока, обычно связанные с его внешностью. Он затрагивает только тех игроков, для которых на одном из предыдущих шагов был выставлен флаг «дальше будут детали».

Полный список флагов обновления:

  • смотрит на существо
  • смотрит на клетку
  • принудительный публичный чат
  • анимация
  • изменение внешности: экипировка и т. д. (подробнее ниже)
  • получил удар
  • обычный публичный чат
  • графический эффект
  • принудительное перемещение по пути

Если посмотреть на раскладку, два нижних флага в списке (насколько можно судить) всегда представлены битами в старшем байте типа обновления. Это также, как правило, более редкие обновления, и, похоже, такое распределение — сознательный экономический выбор: только редкие события требуют передачи второго байта типа обновления.

В более поздних версиях добавляется обновление «получил второй удар за этот цикл» — оно тоже всегда представлено битом в старшем байте, что служит дополнительным подтверждением: второй байт нужен только для более редких событий.

Для каждого игрока в массиве обновлений с «дополнительными деталями» происходит перебор, и считывается флаг «тип обновления»:

private void readPlayerDetails(Packet packet) {
    for (int i = 0; i < moreDetailPlayerCount; i++) {
        int updateType = packet.takeByte();

        if ((updateType & 0b1000_0000) != 0) {
            updateType |= packet.takeByte() << 8;
        }
        
        // ...
    }
}

Здесь виден ещё один приём экономии байтов. Девять перечисленных флагов не помещаются в один байт, если каждый флаг — отдельный бит, поэтому для полного типа обновления в общем случае нужны два байта. Вместо чтения двух байт на игрока (через takeShort), семь флагов упаковываются в первый байт вместе с одним маркерным битом — самым старшим. Когда этот маркерный бит установлен, читается второй байт, сдвигается влево на один байт и объединяется с первым, давая 16-битное значение (из которых значимы 10 бит: 9 флагов плюс маркер).

После получения полного типа обновления он проверяется на наличие отдельных флагов для применения соответствующих деталей. Некоторые из них показаны ниже:

if ((updateType & 0b0000_0100) != 0) {
    // player is facing an entity (npc or another player)
    player.targetEntityId = packet.takeShort();
}

if ((updateType & 0b0010_0000) != 0) {
    // player is facing a tile
    player.targetTileX = packet.takeShort();
    player.targetTileZ = packet.takeShort();
}

if ((updateType & 0b0000_0010) != 0) {
    // player is performing an animation
    player.animationId = packet.takeShort();
    player.animationDelay = packet.takeByte();
}

// ... other flags ...

// check the least significant bit of the high byte
if ((updateType & (0b0000_0001 << 8)) != 0) {
    // a graphical effect is playing on the player
    player.graphicalEffectId = packet.takeShort();
    player.graphicalEffectHeight = packet.takeShort();
    player.graphicalEffectDelay = packet.takeShort();
}

В приведённых примерах видно несколько уже знакомых приёмов. В 8-битный или 16-битный тип обновления упаковано несколько значений флагов. Разные механизмы обновления имеют разный размер тела — это часть согласованного протокола между клиентом и сервером. Используется наименьший подходящий тип данных для представляемых значений. Все эти решения принимались с целью минимизировать объём передаваемых данных.

Обновление внешности

Подробно на разделе «внешность» в этом пакете останавливаться не будем, но это единственная действительно дорогая часть в списке. Она содержит:

  • имя
  • боевой уровень
  • информацию о частях тела, включая надетые предметы и трансформации NPC
  • цвет частей тела
  • анимации стойки и ходьбы
  • пол
  • иконки над головой (иконки молитв, череп PK)

В сумме раздел внешности обходится в 44–80 байт на игрока.

Почему без битовой упаковки?

Может показаться непоследовательным, что протокол отказывается от побитовой экономии именно там, где начинается самая объёмная часть пакета, но шаг 4 на самом деле следует тому же правилу, что и остальные — просто оказывается по другую его сторону. Битовая упаковка меняет процессорное время на байты: платится цена битового курсора ради возврата разницы между реальной шириной значения и байтом, в котором оно иначе бы уместилось. Такой обмен оправдан только там, где эта разница действительно есть и повторяется многократно.

В шагах 1, 2 и 3 она есть, причём многократно. Состояние по умолчанию — «без изменений» — это один бит, и он повторяется для каждого видимого игрока каждый цикл, поэтому экономия накапливается по десяткам сущностей. У шага 4 нет ни одной из этих двух составляющих. Здесь нет крохотного значения по умолчанию: у игрока либо вообще нет обновления (это уже отсечено одним битом выше по потоку), либо есть настоящее, и его наименьшее поле — «смотрит на существо» — уже занимает двухбайтовый short. У short нет разницы для возврата — он заполняет оба своих байта целиком, — так что битовая упаковка ничего бы не сэкономила, но по-прежнему требовала бы платы за битовый курсор. Множитель тоже исчезает: шаг 4 содержит только горстку игроков, изменившихся за этот цикл, а не всю толпу, так что даже если бы биты для экономии и нашлись, умножать их почти не на что. Единственное место, где приём всё ещё окупается — сам байт типа обновления, где маркерный бит покупает второй байт только при необходимости — упакован побитово внутри байта, ровно там, где разница всё ещё есть.

Вторая причина — то, как сервер формирует эту часть пакета, и это по сути тот же аргумент, только с точки зрения сервера. Многие поля шага 4 не пересчитываются каждый цикл — судя по всему, буфер внешности, например, строится один раз на игрока при каждом изменении и хранится как байтовый буфер, который сервер вклеивает в исходящие пакеты для любого наблюдателя, которому он нужен. Клиент точно кэширует его именно так, повторно используя, когда отслеживаемый игрок покидает видимую зону и возвращается в неё; было бы странно, если бы сервер не зеркалил такое поведение. Вклейка дёшева именно потому, что байтово выровненный блоб не зависит от позиции: где бы он ни оказался в пакете конкретного наблюдателя, это одна и та же последовательность байт, так что вставка — обычное копирование массива. Выровняй его по битам — и его смещение зависело бы от всего, что записано перед ним, а это отличается для каждого наблюдателя и каждого цикла, так что один и тот же кэшированный блоб потребовал бы нового сдвига и маскирования для каждого наблюдателя, каждый цикл, и кэш перестал бы быть выгодным.

Так что две половины пакета настроены под два разных дефицитных ресурса. Побитово упакованная передняя часть дёшева в вычислении, невозможна для кэширования и существует, чтобы поберечь нисходящий канал клиента на dial-up. Ничего в ней нельзя разделить между наблюдателями: каждый видит свою толпу, позиционированную относительно себя. Байтово выровненная задняя часть дорога в вычислении, но редко меняется, поэтому строится один раз и вклеивается там, где нужна — и здесь ограничивающий фактор вовсе не провод, а бюджет времени сервера на сборку до двух тысяч таких пакетов до следующего цикла. Протокол переключает представление ровно в той точке, где меняется этот ограничивающий фактор.

Байты на проводе

Подсчитаем для реального сценария: сделан один шаг на север, и посмотрим, что получит клиент соседнего игрока в пакете обновления игроков за этот цикл. Допустим, в его поле зрения ещё двадцать игроков, и в этом цикле переместился только один.

Добавив байт опкода и маркер длины (два байта, а не один, как в пакете перемещения — длина блока обновления игроков может превышать 255), получаем примерно девять байт для полного ответа на вопрос «что только что сделали все вокруг меня» — за цикл, в котором один человек сделал один шаг в толпе из двадцати одного. Исходящий пакет перемещения весил семь байт; отражённое обратно обновление — около девяти. Шестнадцать байт туда-обратно за один шаг — и сервер отправляет тот же девятибайтовый ответ каждому другому игроку, который видит игрока, сделавшего шаг. При 5 КБ/с есть запас на сотни таких пакетов в секунду — и именно в этом суть: бой, толпы и чат должны укладываться в один и тот же канал.

Общий урок

Клиент RuneScape и сервер, с которым он взаимодействует — это не две системы, обменивающиеся сообщениями. Это одна система, которая просто оказалась разделена TCP-соединением. Каждая экономия в этом протоколе опирается на знание, общее для обеих сторон и никогда не передаваемое по сети:

  • Обе стороны запускают один и тот же алгоритм поиска пути по одной и той же карте коллизий, поэтому клиент может отправлять только углы пути, а сервер — просто проверять его.
  • Обе стороны договариваются, ещё на этапе компиляции, что состояние игрока по умолчанию — «не изменился», поэтому «не изменился» может стоить всего один бит.
  • Обе стороны договариваются, что «видимый» означает «в пределах примерно 15 клеток», поэтому позиция может занимать пять бит на ось вместо шестнадцати.
  • Обе стороны согласуют фиксированную таблицу того, что вообще может измениться, поэтому битовая маска может заменить схему.

Ничего из этого общего понимания не передаётся по проводу. Оно заложено в проектирование. Протокол компактен потому, что обе программы писались вместе людьми, относившимися к сети как к деталям реализации единого приложения, а не как к границе, разделяющей два разных.

Возникает соблазн прочитать это как реликт — так приходилось строить системы до того, как пропускная способность подешевела. Но граница никогда не проходила между «старым» и «новым»; она проходит по тому, для чего предназначена система, и по тому, какое ограничение реально сдерживает. Современный веб-сервис намеренно строится по противоположному принципу: слабо связанный, самоописывающийся, версионированный, многословный — то же самое обновление сцены в виде JSON поверх HTTP разрослось бы до сотен байт, и одни только заголовки затмили бы девять байт оригинала. Эта тяжеловесность — не расточительность; это плата за возможность менять одну сторону без передеплоя другой, обслуживать множество разных клиентов и отлаживаться, читая сырой трафик. Это правильные настройки по умолчанию, когда давление оказывают команды и скорость изменений, а не байты.

Легко упустить, как много сегодняшнего софта всё ещё живёт на стороне RuneScape этой границы. Соревновательный шутер, файтинг с rollback-неткодом, лента рыночных данных — везде, где обе стороны выкатываются вместе и каждый байт на счету, применяется тот же тесно совместно спроектированный, побитово упакованный, зашитый в схему подход. Развязанный стиль — не признак современного проектирования, а ответ на требование независимой выкатки. К нему двигаются или от него отдаляются в зависимости от того, какое ограничение реально сдерживает систему.

Если же надавить в другую сторону — сделать так, чтобы буквально каждый байт имел значение — получится именно такое: модель данных и формат передачи, спроектированные настолько плотно совместно, что существуют как единый артефакт. Там, где вся изощрённость заключается в том, что удалось не отправить. Изучение этого протокола — это изучение того, как выглядит инженерия под жёстким, абсолютным ограничением.

Благодарности

Спасибо Jagex за создание системы, которая не просто выдержала испытание временем, но и оказалась достаточно хороша, чтобы её стоило разбирать и изучать спустя двадцать лет.

Спасибо множеству участников сообществ сохранения и реверс-инжиниринга, с которыми довелось работать последние пятнадцать лет, формируя нынешнее понимание этой темы.