Задача
Играть вдвоём в Minecraft с модами сложнее, чем кажется. Нужно, чтобы у обоих совпали версия игры, загрузчик (Fabric, Forge, NeoForge, Quilt), набор модов и конфиги. После этого игроки должны ещё увидеть друг друга в сети, а это обычно Hamachi, проброс портов или арендованный сервер.
Я поставил себе такие требования:
-
Без белого IP и проброса портов.
-
Присоединение по одной ссылке.
-
Гость автоматически получает сборку хоста, включая локальные моды и конфиги.
-
Соединение шифруется.
-
Голосовой чат встроен в тот же канал.
Результат — лаунчер MineDed (GPL-3.0, Windows и Linux). Ниже — как он устроен и какие решения я принимал. Проект писался вместе с Claude Code, и про этот процесс тоже будет отдельный раздел.
Архитектура
Приложение на Tauri 2: ядро на Rust, интерфейс на React 19 и TypeScript. Установщик весит около 10 МБ, потому что нет собственного браузерного движка. Репозиторий — Cargo workspace, где каждый крейт отвечает за свою область:
|
Крейт |
Ответственность |
|---|---|
|
|
общие DTO, |
|
|
прогресс операций, пути, настройки, |
|
|
единый загрузчик файлов |
|
|
версии игры, Java, инстансы, запуск |
|
|
Fabric, Quilt, Forge, NeoForge |
|
|
каталог Modrinth, модпаки, пресеты оптимизации |
|
|
оффлайн-аккаунты, Yggdrasil, keyring, скины |
|
|
комнаты на iroh, туннель, протокол |
|
|
голосовой чат |
|
|
Tauri-оболочка ( |
Серверная часть живёт в Docker: hub на axum, сервер скинов Drasl, Caddy и собственный iroh-relay.
Контракты между модулями
В каждом крейте есть src/contract.rs с трейтами и src/stub.rs с заглушкой реализации. Потребитель работает против трейта, а не против чужого кода, поэтому крейты можно разрабатывать параллельно и не ломать друг друга.
Несколько правил, которые я закрепил и проверяю линтерами и ревью:
-
Сервисы не зависят от Tauri. Наружу они отдают события через трейты
*Observer, а оболочка подключает их к IPC. -
Долгие операции принимают
&dyn ProgressSinkи между шагами вызываютcheck_cancelled(), поэтому любую загрузку или установку можно прервать. -
Ошибки между крейтами и через IPC передаются только как
AppErrorсErrorCode, детали — через.detail(key, value). -
Сеть только через
lc_http::Downloader. Создавать свойreqwest::Clientв других крейтах нельзя. -
Пути только через
lc_runtime::Paths, секреты только в типеSecretи никогда в логах. -
unwrapиexpectзапрещены вне тестов,unsafeзапрещён workspace-линтом, clippy запускается с-D warnings.
Типы Rust → TypeScript генерируются через specta и tauri-specta (версия закреплена точным пином), результат лежит в bindings.ts и вручную не правится. Так контракт IPC не расходится между бэкендом и интерфейсом.
Ядро сетевого контракта (crates/lc-p2p/src/proto.rs, сокращено):
pub const ALPN: &[u8] = b"lc/1";
pub const PROTOCOL_VERSION: u32 = 1;
/// Above this many peers the host forwards voice instead of full mesh.
pub const MESH_LIMIT: usize = 6;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum ControlMsg {
Hello { protocol: u32, name: String, uuid: Option<String>,
peer_token: String, password_proof: Option<String> },
Welcome { peers: Vec<String>, peer_names: Vec<String> },
Ping { nonce: u64 },
Pong { nonce: u64 },
Chat { id: String, text: String, sent_at: String /* … */ },
VoiceState { speaking: bool, muted: bool, peer: Option<String> },
Kick { reason: String },
Bye,
}
Контракт подкреплён тестами. Например, ControlMsg::Ping обязан сериализоваться ровно в {"t":"ping","nonce":1}, и это проверяет отдельный тест.
Транспорт: iroh
Для P2P я выбрал iroh. Он построен на QUIC, адресует узлы по публичному ключу, сам пытается пробить NAT и при неудаче использует relay-сервер. Для моей задачи это означало, что не нужно писать свой hole punching и свою схему шифрования.
Как это выглядит для игрока:
-
Хост создаёт комнату, получает код и ссылку вида
https://mineded.dedseckms.ru/join/КОД. Комнату можно защитить паролем. -
Гость вводит код или открывает ссылку, лаунчер запускается и подключается.
-
Если прямое соединение невозможно (симметричный NAT, мобильный интернет), трафик идёт через relay. Он остаётся зашифрованным, и relay видит только зашифрованные пакеты.
В коде за это отвечает крейт lc-p2p, а формат сообщений вынесен в отдельный файл proto.rs, который, как и все контракты, меняется только осознанно.
Между двумя участниками открыто одно QUIC-соединение с ALPN lc/1, и в нём ходит три вида трафика.
|
Что |
Как передаётся |
|---|---|
|
Управление |
первый двунаправленный поток, JSON по одному сообщению на строку ( |
|
Игра |
каждое TCP-соединение Minecraft — отдельный двунаправленный поток: одна строка |
|
Голос |
QUIC-датаграммы: 8-байтовый заголовок и кадр Opus на 20 мс |
В управляющем потоке первым идёт Hello с двумя доказательствами. peer_token выдаёт hub-сервер (axum): он подтверждает, что участник состоит в комнате, и хост проверяет его своим hostToken. Для комнат с паролем добавляется password_proof — HMAC-SHA256, где ключ — пароль, сообщение — ключевой материал, экспортированный из TLS-сессии, а контекст — идентификатор комнаты. Сам пароль по сети не идёт, а доказательство привязано к конкретной TLS-сессии.
Код комнаты имеет вид ABCD-1234 (восемь символов, латиница и цифры). parse_code нормализует ввод: принимает любой регистр, пробелы, код без дефиса и ссылки с хвостом ?query или #fragment. Друзьям отправляется ссылка https://mineded.dedseckms.ru/join/КОД, потому что мессенджеры не делают кликабельными кастомные схемы. Страница сайта открывает mineded://join/КОД, а приложение зарегистрировано обработчиком этой схемы (плагин deep-link в Tauri). Старая схема mineup://, оставшаяся с прошлого названия проекта, по-прежнему принимается.
Туннель игрового трафика
Minecraft подключается к серверу по обычному TCP-адресу из списка серверов. Поэтому у гостя лаунчер поднимает локальный сокет на 127.0.0.1:порт и добавляет его в список серверов игры. Для Minecraft это выглядит как обычный сервер в локальной сети.
Дальше каждое входящее TCP-соединение проксируется в поток QUIC к хосту, а на стороне хоста поток соединяется с портом мира, открытого командой «Открыть для сети» (Esc → «Открыть для сети»). Игровой трафик идёт внутри того же зашифрованного соединения iroh.
Хосту нужно узнать, на каком порту Minecraft открыл мир для сети, а порт каждый раз новый. Для этого есть два независимых источника, и оба проверяются.
-
LAN-анонсы. Игра раз в ~1,5 с шлёт в группу
224.0.2.60:4445пакет вида[MOTD]…[/MOTD][AD]порт[/AD]. Лаунчер слушает эту группу (сокет сSO_REUSEADDR, потому что тот же порт занимает сканер самой игры), принимает пакеты только от адресов этой же машины и подтверждает порт попыткой TCP-соединения с127.0.0.1:портза 700 мс. Так анонс соседа по Wi-Fi не подменит туннель. -
Лог игры. Строки
Started serving on <порт>иLocal game hosted on port <порт>. Строка пишется потоком клиента (Render thread, до 1.13Client thread), а у Forge формат префикса отличается, поэтому парсер учитывает все варианты.
Лог — это текст, в который игрок может написать что угодно. Поэтому любая строка с [CHAT] отбрасывается, а разбор берёт только префиксы потоков игры. Иначе игрок мог бы набрать в чате «Started serving on 22» и перенаправить туннель на чужой порт. Для этого есть регрессионный тест chat_lines_are_ignored:
pub fn port_from_log_line(line: &str) -> Option<u16> {
const MARKERS: [&str; 2] = ["Started serving on ", "Local game hosted on port "];
if line.contains("[CHAT]") {
return None;
}
// Log4j prefix ends at the first "]: ": `[time] [Server thread/INFO]: msg`
let end = line.find("]: ")?;
let prefix = &line[..end + 1];
let msg = &line[end + 3..];
// … only [Server thread/INFO], [Render thread/INFO], [Client thread/INFO]
}
На стороне гостя лаунчер добавляет запись комнаты в servers.dat игры и рассылает те же LAN-анонсы с TTL 0, чтобы они не выходили за пределы машины. MOTD при этом очищается от скобок и обрезается до 64 символов. Записи комнат, оставшиеся от прошлого запуска, при старте удаляются фоновой задачей.
Передача сборки
За основу формата я взял .mrpack: это ZIP с манифестом modrinth.index.json и папкой overrides/. В манифесте перечислены файлы с хэшами и ссылками на скачивание, а в overrides лежит то, чего нет в каталоге Modrinth.
Схема передачи:
-
Моды, шейдеры и ресурспаки с Modrinth уходят ссылками. Гость качает их сам с CDN, это быстро и не нагружает канал хоста.
-
Локальные моды и конфиги передаются файлами по P2P-соединению.
-
Если подходящая сборка у гостя уже есть, она подхватывается без скачивания. Если нет, по кнопке «Скачать и играть» ставятся загрузчик, моды и конфиги.
Сборка передаётся отдельным потоком QUIC. Гость открывает поток и пишет StreamOpen::Pack { version }, хост отвечает одной строкой PackReply, после Ready идёт ровно size сырых байт, и поток закрывается:
pub enum StreamOpen {
Game,
Pack { version: u32 },
}
pub enum PackReply {
/// `sha256`: lowercase hex of the whole file.
Ready { size: u64, sha256: String },
/// `reason`: `unsupported` (pack version), `not_game_room`, `too_large`, `export_failed`.
Error { reason: String },
}
Хосты старой версии, которые не знают этого сообщения, сбрасывают поток без ответа. Это учтено в контракте как отдельный случай.
Чтобы понять, нужна ли гостю загрузка, комната хранит подпись сборки (InstanceSig): версия игры, загрузчик, его версия и mods_hash. Хэш считается по содержимому, а не по именам файлов: берётся SHA-256 каждого включённого mods/*.jar, список сортируется, и от него считается SHA-256 по строкам:
hashes.sort();
let mut h = Sha256::new();
for x in &hashes {
h.update(x.as_bytes());
h.update(b"n");
}
Ok(hex::encode(h.finalize()))
Поэтому переименованные или переставленные моды дают тот же хэш, а выключенные (.jar.disabled) и не-jar файлы в него не входят. Тест mods_hash_ignores_order_and_non_jars проверяет именно это.
Экспорт на стороне хоста. Хост собирает .mrpack из игровой папки (кроме logs, crash-reports, screenshots, saves и других служебных папок). Файл, который лаунчер ставил из Modrinth и для которого сохранены URL, sha1 и sha512, попадает в modrinth.index.json ссылкой. Всё остальное (локальные моды, конфиги) кладётся в overrides/. Результат кэшируется на комнату и удаляется, когда комната закрывается.
Импорт .mrpack валидирует манифест целиком до первой загрузки: игра должна быть minecraft, файлы с env.client = unsupported пропускаются, а для каждого файла нужны URL с разрешённого хоста, а также sha1 и sha512. Файлы скачиваются в staging-папку на том же томе, что и инстансы (чтобы переносить их без копирования), проверяются по sha1 при загрузке и по sha512 после, затем распаковываются overrides и client-overrides. Если что-то идёт не так, полусозданный инстанс удаляется.
Зависимости локальных модов
Свои моды с компьютера лаунчер разбирает по метаданным внутри .jar: для Fabric это fabric.mod.json, для Quilt quilt.mod.json, для Forge и NeoForge META-INF/mods.toml и META-INF/neoforge.mods.toml. Оттуда берутся идентификатор, версия и список зависимостей. Если чего-то не хватает, лаунчер предлагает доставить недостающее из Modrinth.
Каталог и зависимости строятся на Modrinth API v2 (ключ не нужен). У каждой версии проекта в ответе есть список зависимостей с типами required, optional, incompatible и embedded, и лаунчер переносит их в собственную модель. Установленные файлы записаны в индекс инстанса: для каждого хранятся вид контента, источник, project_id, version_id, sha1, sha512, размер и URL. Идентификаторы проекта и версии для сборок из .mrpack вынимаются из самого CDN-адреса вида cdn.modrinth.com/data/<проект>/versions/<версия>/<файл>.
Обновления проверяются пакетным запросом по хэшам, а не по именам файлов:
let body = serde_json::json!({
"hashes": hashes,
"algorithm": "sha1",
"loaders": loaders,
"game_versions": [target.mc],
});
Это один POST /version_files/update на каждый вид контента (моды, ресурспаки, шейдеры, датапаки). Для Quilt список загрузчиков расширяется до quilt и fabric, потому что большинство Fabric-модов на нём работает. Для шейдеров допустимы загрузчики iris, optifine, canvas и vanilla. Для модпаков берётся версия по приоритету: featured-релиз, затем любой релиз, затем самая свежая версия с .mrpack. Идентификаторы проекта и версии перед подстановкой в URL проверяются: допустимы только латиница, цифры, - и _.
Голос
Голосовой чат встроен в комнату. Конвейер на стороне отправителя:
-
кодек Opus, 36 кбит/с;
-
шумоподавление RNNoise;
-
фильтр для низкочастотного гула и вентилятора;
-
детектор активности речи с предзахватом и «хвостом», чтобы не глотались начало и конец фраз;
-
на приёмнике восстановление потерянных пакетов.
Есть push-to-talk с выбором клавиши, активация голосом, регулировка громкости каждого участника и компактное окно поверх игры. Оверлей работает в оконном и borderless-режимах, но не в эксклюзивном полноэкранном, а на Wayland может не держаться поверх остальных окон.
Параметры подбирались на слух: мы с другом заходили в реальную комнату и проверяли разборчивость речи, работу шумоподавления и задержку. В версии 1.2.1 это дало заметный результат: пропал «вертолёт» на фоне и перестали обрезаться слова.
Что внутри конвейера
Отправка (tx.rs). Захваченный звук проходит такие этапы:
-
Моно на частоте устройства → 48 кГц через ресемплер на оконном sinc-фильтре (
rubato, окно 128 точек, срез 0,92, чанки по 10 мс). При совпадении частот ресемплер пропускает звук как есть. -
Кадры по 20 мс (960 сэмплов), усиление входа от 0 до 2.
-
Фильтр высоких частот 90 Гц (биквад Баттерворта 2-го порядка): убирает гул вентилятора, рокот и постоянную составляющую до шумоподавления.
-
RNNoise (крейт
nnnoiseless) по двум половинкам кадра по 10 мс. Он же отдаёт вероятность речи, по которой работает детектор активности. После обработки подмешивается 6% исходного сигнала, чтобы замаскировать «музыкальный шум». -
Мягкий лимитер: линейно до порога 0,8, дальше плавное насыщение через
tanh, выше 1,0 не выходит. Коэффициент усиления не меняется, поэтому нет «накачки». -
Гейт (PTT или VAD) с предзахватом и «хвостом».
-
Opus и датаграмма с заголовком.
const HIGH_PASS_HZ: f64 = 90.0;
/// Dry signal mixed back after RNNoise: masks "musical noise" artefacts.
const DRY_MIX: f32 = 0.06;
// ...
*s = d / 32_767.0 * (1.0 - DRY_MIX) + *s * DRY_MIX;
Чтобы начало фразы не терялось, каждый кадр кодируется всегда, даже при закрытом гейте. Это нужно и для того, чтобы состояние энкодера Opus не прерывалось. Пакеты закрытого гейта складываются в кольцевой буфер предзахвата, а при открытии уходят вместе с текущим кадром с последовательными номерами. В тесте PTT это видно: при нажатии клавиши уходят три кадра предзахвата и текущий, номера 100…103. При закрытии гейта последний кадр затухает линейной рампой. Очередь на входе ограничена 200 мс, чтобы после зависания не копилась задержка.
Приём (mixer.rs). На каждого говорящего свой jitter-буфер и свой декодер. Ключ потока — пара «отправитель на транспорте + байт speaker из заголовка», потому что хост пересылает голос других гостей по своему соединению. Из буфера на каждый 20-миллисекундный тик приходит один из вариантов:
-
пакет — декодируется как есть;
-
потеря, но следующий пакет уже есть — кадр восстанавливается по FEC из следующего пакета;
-
потеря без FEC — маскировка потери (PLC Opus) с затуханием: усиление 0,8, затем 0,45, затем 0 на третьем подряд замаскированном кадре;
-
тишина — ничего не воспроизводится, усиление сбрасывается.
Лишняя задержка (например, после всплеска пакетов при предзахвате отправителя или из-за дрейфа часов) сбрасывается пропуском кадров: сначала почти тихих (тише −50 дБFS), а если избыток больше 12 кадров, то любых. Каждое изменение усиления идёт плавной рампой, чтобы не было щелчков на границах фраз. Заглушённых собеседников микшер вообще не декодирует, чтобы не тратить процессор. Поток без пакетов 10 секунд удаляется, а индикатор речи горит, пока последний реальный пакет проигран менее 200 мс назад. Выход микшера проходит тот же мягкий лимитер, громкость каждого собеседника и общая ограничены диапазоном от 0 до 2. Кривые и мусорные датаграммы (слишком короткие, неверная версия, пустой или слишком большой Opus-пакет) просто игнорируются.
/// Gain at the end of the 1st, 2nd, 3rd consecutive concealed frame.
const CONCEAL_GAINS: [f32; 3] = [0.8, 0.45, 0.0];
/// Decoded frames quieter than this may be skipped to drain excess latency.
const SKIP_QUIET_DB: f32 = -50.0;
/// Beyond this excess (frames) frames are skipped regardless of level.
const FORCE_SKIP_EXCESS: usize = 12;
Конвейер проверяется тестами на поведение, а не только на форму. Например, шум вентилятора (широкополосный шум плюс гул 50 Гц) за 250 кадров не должен открыть VAD больше двух раз и вывести в сеть больше 40 пакетов. Задержанная пачка пакетов должна приводить к маскировке, а не к цифровой тишине, и при этом поднимать целевую глубину jitter-буфера. А «пачка» тихих кадров предзахвата в начале потока должна дренироваться и не оставлять лишней задержки.
Аккаунты и скины
Для скинов и плащей развёрнут Drasl, сервер, совместимый с Yggdrasil и authlib-injector, поэтому игра принимает его без патчей. Можно играть и с оффлайн-аккаунтом (просто ник), тогда регистрация не нужна. Секреты хранятся в системном keyring.
Правило комнат: если хост вошёл через «Мой сервер», гости тоже должны быть с этого сервера. Хост с оффлайн-аккаунтом принимает всех.
Отдельная история была с загрузкой скинов по нику из открытых источников. Реализация упиралась в баги, в том числе во временный файл со слишком длинным именем, которое получалось из ника.
Обновления и безопасность
Автообновления подписаны, и лаунчер не установит подменённый пакет. Используется updater-плагин Tauri: CI собирает артефакты с подписью (createUpdaterArtifacts: true), публичный ключ minisign вшит в tauri.conf.json, а манифест latest.json лаунчер берёт из GitHub Releases. Если GitHub недоступен, есть резервный адрес на моём сервере:
"updater": {
"pubkey": "<публичный ключ minisign>",
"endpoints": [
"https://github.com/Dedseckms/MineDed/releases/latest/download/latest.json",
"https://relay.dedseckms.ru/updates/latest.json"
],
"windows": { "installMode": "passive" }
}
Модель доверия такая: принимая сборку хоста, гость запускает у себя чужие моды, то есть чужой код, и никакая проверка не докажет, что мод безвреден. Поэтому комнаты стоит принимать только от тех, кому доверяешь, а для закрытых игр есть пароль. Зато сам механизм передачи защищён от целого ряда атак:
-
Источники загрузки. Для файлов из
.mrpackразрешены толькоhttpsи четыре хоста из спецификации Modrinth:cdn.modrinth.com,github.com,raw.githubusercontent.com,gitlab.com. -
Целостность. sha1 проверяется при загрузке, sha512 после неё. Манифест без любого из этих хэшей отклоняется целиком, ещё до начала скачивания.
-
Пути. Все пути из архива проходят через
safe_join, на защиту от zip-slip есть отдельный тест. -
Лимиты распаковки. Не больше 20 000 записей и 4 ГиБ. Считаются реально записанные байты, потому что размеры, заявленные в архиве, можно подделать.
-
Чужие zip-сборки. JVM-аргументы фильтруются по белому списку: размеры
-Xmx,-Xms,-Xss,-Xmn, 73 разрешённых флага-XX(сборщик мусора, JIT, память) и безобидные-D-свойства. Всё, что читает файлы или запускает команды, и свойства с префиксамиjava.,log4j,forgeи другими отбрасывается. Путь к Java из чужого архива не берётся вообще. -
Туннель нельзя перенаправить сообщением в чате (см. выше), а пароль комнаты не передаётся по сети.
-
Веб-часть приложения работает с жёстким CSP:
default-src 'self', аframe-src,object-srcиform-actionзапрещены ('none').
Фрагмент теста на фильтр JVM-аргументов показывает, что именно отсекается:
"-XX:OnOutOfMemoryError=calc.exe",
"-Dlog4j.configurationFile=http://x/y.xml",
"-javaagent:evil.jar",
"-Djava.library.path=evil",
Как я работал с ИИ-агентом
Проект я делал вместе с Claude Code, но не по схеме «один агент пишет всё». Я описал правила работы в локальном файле инструкций (в публичный репозиторий он не попал) и дальше придерживался их.
Роли. Были lead, а также core, mods, accounts, net, voice, ui, server-ops, qa и release. У каждой роли свой список папок, куда она может писать: например, core работает только в crates/lc-core и своих командах Tauri, voice только в crates/lc-voice, qa только в tests/ и в продовый код не пишет вовсе.
Изоляция. Каждый субагент работал в отдельном git worktree на своей ветке вида agent/<роль>/<задача>, а коммиты назывались по шаблону <роль>(<область>): <что сделано>.
Контракты меняет только lead. Если агенту нужно изменить трейт или DTO, он не обходит контракт и не дублирует типы у себя, а останавливается и пишет в отчёте запрос на изменение контракта по заданному формату. Это не давало модулям разойтись по интерфейсам.
Definition of done. Задача считалась готовой, только если в её worktree проходили cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace и сборка фронтенда. Для интерфейса добавлялись проверка типов и vitest.
Мерж. В main вливал только lead и только после ревью роли qa.
Что делал я. После каждого результата агента я сам запускал приложение и записывал найденные проблемы. Мелкие правки делал руками, потому что это быстрее и экономит токены, а узкие задачи отдавал отдельной роли. Голос, например, нельзя проверить по описанию, поэтому мы тестировали его вдвоём в реальной комнате. Агент не любил нестандартные требования: упорно не получалось добиться того, чтобы скины можно было добавлять по нику, и по ходу вылезало много мелких багов.
Что не получилось и ограничения
-
Каталог модов только из Modrinth. Доступ к API CurseForge нужно получать по одобрению, это долго, а остальные источники либо меньше, либо ссылаются на Modrinth. Доступ к API CurseForge одобрили недавно. В коде CurseForge пока работает только на экспорт, а импорт отключён.
-
Оверлей голоса не работает в эксклюзивном полноэкранном режиме.
-
Без прямого соединения трафик идёт через relay, и задержка зависит от его расположения.
-
Установщик пока без платной подписи, поэтому Windows SmartScreen может показать предупреждение.
Как попробовать
Доступ на данный момент либо по инвайтам на сервер скинов drasl либо с помощью оффлайн аккаунтов. Для проверки нужно двое: один создаёт комнату, второй заходит по ссылке.
-
Репозиторий и релизы: https://github.com/Dedseckms/MineDed
-
Баги и идеи: https://github.com/Dedseckms/MineDed/issues
Проект неофициальный и не связан с Mojang и Microsoft.
Автор: Dedseckms
