Как я вернул LAN-кооп в Neverwinter Nights 2 на Switch

У нас с супругой есть традиция: по вечерам погамать в коопчики, каждый со своей Switch. После Divinity захотелось чего-то в том же духе, и выбор пал на старую добрую Neverwinter Nights 2 (оригинал 2006 года). Запустили, нажали «Мультиплеер» и получили окно «Требуется аккаунт Nintendo» и подписку NSO.
У нас обе консоли прошиты (Atmosphere), так что онлайн Nintendo отпадает сразу. А самое интересное, что на ПК у NWN2 всегда была обычная игра по LAN и прямое подключение по IP. Хотелось проверить на лень товарищей разработчиков - остался ли этот код в Switch-версии или все-так его вырезали.
Делал я это в паре с Claude Code. Самую тяжёлую часть — дизассемблирование, поиск нужных мест в коде, первые версии патчей и работу с отладчиком — взял на себя он. Я по его находкам дописывал и доводил код, проверял всё на консолях, решал, куда копать дальше, и собирал итоговый релиз. Код в Switch-версии действительно остался, его просто спрятали. Дальше рассказываю, как мы его доставали: семь слоёв защиты от дурака, ловушки отладчика на живой консоли и в сумме семь правок в бинарнике. В конце отдельно про то, каково это — реверсить с ИИ.
Готовый патч лежит здесь: https://github.com/AnDyQnn/nwn2-switch-lan
Патч для консолей с Atmosphere. Онлайн Nintendo он не открывает, он включает локальную сеть, которая в игре уже есть.
Инструменты
Дамп игры через DBI: база + обновление v65536.
hactool / nstool + свои prod.keys, чтобы достать
exefs/main(NSO).Python + capstone для дизассемблирования. IDA не было, и, как оказалось, для такой задачи хватает пары скриптов на 50 строк. Их по ходу дела писал Claude.
Claude Code: ИИ-ассистент в терминале, который сам запускает команды, читает вывод и пишет скрипты.
Atmosphere:
exefs_patchesдля IPS-патчей кода, LayeredFS (atmosphere/contents/<TID>/romfs) для подмены файлов.Atmosphere standalone gdbstub + aarch64-none-elf-gdb (Arm GNU Toolchain). Это главный герой второй половины статьи.
Распаковка NSO
main в NSO сжат LZ4 посегментно. Самое простое — развернуть его в плоский образ, где смещения совпадают с адресами внутри модуля:
import struct, lz4.block as L
d = open('exefs/main', 'rb').read()
flags = struct.unpack_from('<I', d, 0xC)[0]
segs = []
for s in range(3): # .text, .rodata, .data
fo, mo, sz = struct.unpack_from('<III', d, 0x10 + s*0x10)
csz = struct.unpack_from('<I', d, 0x60 + s*4)[0]
raw = d[fo:fo+csz]
segs.append((mo, L.decompress(raw, uncompressed_size=sz) if flags & (1 << s) else raw))
img = bytearray(max(m + len(b) for m, b in segs))
for m, b in segs:
img[m:m+len(b)] = b
open('main.bin', 'wb').write(img)
Дальше всё работало с main.bin: импорты читаются из MOD0 → .dynamic → DT_SYMTAB/DT_JMPREL, вызовы SDK сопоставляются через PLT-заглушки (adrp x16 / ldr x17 / br x17), строки ищутся по паре adrp + add. Из этого Claude собрал мини-«IDA на коленке»: дизассемблер, который подписывает вызовы SDK и строки.
Слой 1. «Нужен аккаунт Nintendo»
Среди импортов нашлось ровно то, что ожидалось:
nn::nifm::IsNetworkAvailable()
nn::account::EnsureNetworkServiceAccountAvailable(UserHandle const&)
nn::account::EnsureNetworkServiceAccountIdTokenCacheAsync(...)
EnsureNetworkServiceAccountAvailable — это и есть системное окно «войдите в аккаунт Nintendo». Его вызывает функция-«поллер» с результатом 0 (готово), 1 (ошибка) или 2 (в процессе):
0x3ccaac bl nifm::IsNetworkAvailable
0x3ccab0 tbz w0, #0, 0x3ccae4
0x3ccab4 ldr x8, [x19, #0x150] ; <- патч
0x3ccab8 mov x0, sp
...
0x3ccacc bl account::EnsureNetworkServiceAccountAvailable
Правка: если интернет есть, сразу возвращаем «готово».
0x3ccab4 mov w0, wzr
0x3ccab8 b 0x3cca80 ; эпилог, return 0
Я проверил на консоли: окно исчезло, и сразу появилось новое: «К сожалению, онлайн-службы сейчас недоступны». Классическое подземелье: открыл дверь, а за ней ещё одна. И хорошо, если не мимик.
Слой 2. «Онлайн-шаги не завершены»
Текст ошибки нашёлся по номеру строки (strref 235397) в dialog.tlk, а номер — по паре mov w1, #0x9785 / movk w1, #3, lsl #16. Обработчик кнопки «Мультиплеер» проверяет, что «все онлайн-шаги платформы завершены»:
0x3c8e40 ldp x8, x9, [x0, #0x48] ; вектор шагов
... ; все ли done?
Эту функцию вызывают из 12 мест (мультиплеер, браузер, подключение…), поэтому проще пропатчить её саму:
0x3c8e40 mov w0, #1
0x3c8e44 ret
Слой 3. Photon и токен Nintendo
Дальше кнопка «Мультиплеер» лезет в Photon (облачный матчмейкинг Exit Games), а экран мультиплеера открывает только в колбэке успешного входа. Для входа игра отдаёт Photon token=<NSA id token>, то есть токен аккаунта Nintendo, и Photon сам проверяет его у Nintendo. Обойти это на клиенте нельзя в принципе: тут хоть двадцатку выкидывай, сервер Photon кубик не примет.
Но LAN-игре Photon и не нужен. Колбэк успеха делает одно: открывает экран по имени. Значит, можно открыть экран сразу, а заодно отправить системный запрос на сеть (nifm::SubmitNetworkRequest), без которого на Switch сокеты могут не заработать:
; StartMultiplayer, вместо connect(Photon)
0x622b44 ldr x8, [x22] ; синглтон платформы
0x622b48 ldr x0, [x8, #0x48]
0x622b4c bl 0x3cc9cc ; pctl::TryBeginFreeCommunication + nifm::SubmitNetworkRequest
0x622b64 adrp x8, ... ; GUI
0x622b6c ldr x0, [x8]
0x622b70 sub x1, x29, #0x10 ; имя экрана из аргумента скрипта
0x622b74 bl 0x678dc4 ; OpenScreen
0x622b78 b 0x622be4 ; уборка и выход
Меню мультиплеера открылось. Но кнопок LAN и «Прямое подключение» в нём нет.
Слой 4. Интерфейс: «только для ПК»
UI в NWN2 — это обычные XML-файлы в romfs. Там всё и нашлось:
<UIButton name="DirectConnect" strref="104" ... hideonplatforms="XBox,PS5,NX" ...
OnLeftClick=UIButton_Input_ScreenOpen("SCREEN_DIRECTCONNECT") />
<UIButton name='LAN_TAB' strref='5206' style='EE_EXPAND_TAB' ... showonplatforms='PC'
OnSelected='UIButton_Input_ShowLAN()'>
Через LayeredFS (atmosphere/contents/01008E601E03C000/romfs/ui/default/enhanced/) мы подложили два исправленных файла:
убрали
hideonplatformsу Direct Connect;открыли
LAN_TABи сделали её вкладкой по умолчанию;добавили в меню кнопку LAN.
Кнопки появились, сервер создаётся, но со второй консоли — «попытка найти сервер…» и тишина.
Слой 5. Клиент и сервер всё ещё на Photon
Проверить, слушает ли хост, можно прямо с ПК. NWN/NWN2 ищут LAN-игры пакетом BNES на UDP 5121:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.sendto(b'BNES\x00\x14\x00', ('192.168.1.15', 5121))
try:
print(s.recvfrom(4096))
except ConnectionResetError:
print('closed') # ICMP port unreachable — никто не слушает
Ответ: closed. Сервер, которого нет. Начали копать, где выбирается транспорт. Нашлась функция создания игры с тремя режимами:
; CreateGameAndConnect(..., mode)
cmp w24, #2 -> netlayer.SetPhoton()
cmp w24, #1 -> netlayer.SetUDP(port)
0 -> одиночная игра
Режим считался как 1 + ini["Server Options"]["GameSpy Enabled"]:
0x650470 cmp w0, #0
0x650474 mov w8, #1
0x650478 cinc w4, w8, ne ; -> mov w4, #1
Отдельно, в серверной части (servercore), сеть сервера выбиралась по флагу Photon ? Photon : UDP(Game Port):
0x8a4368 tbz w19, #0, 0x8a4374 ; -> b 0x8a4374 (всегда UDP)
0x8a436c bl SetPhoton
0x8a4374 mov w1, w21 ; Game Port, по умолчанию 5121
0x8a4378 bl SetUDP
Пропатчили оба места (плюс аналогичное при загрузке сетевого сохранения). Порт 5121 всё равно закрыт.
Слой 6. GDB на живой Switch
Дальше гадать по статике было бессмысленно. Пора кидать инициативу. Мы ещё пробовали запустить игру в эмуляторе Eden, но у него она шла с 60 FPS и чёрным экраном, так что этот путь отпал. В Atmosphere есть встроенный gdbstub, включается двумя строками в atmosphere/config/system_settings.ini:
[atmosphere]
enable_standalone_gdbstub=u8!0x1
enable_htc=u8!0x0
После перезагрузки консоль слушает TCP 22225, и к ней подключается обычный aarch64-none-elf-gdb:
(gdb) target extended-remote 192.168.1.15:22225
(gdb) info os processes
...
139 NWN2_EE
(gdb) attach 139
(gdb) monitor get info
Modules:
0x76ac005000 - 0x76acb26fff nwmain.nss
Нюанс: ASLR, поэтому база меняется при каждом запуске. Claude написал скрипт, который читает базу через monitor get info и генерирует для gdb файл с «тихими» брейкпоинтами: напечатать регистры, continue. Игра почти не тормозит, а лог показывает весь путь:
break *0x76ac44aa80
commands
silent
printf "HIT Netlayer.SetUDP port=%lx lr=%lx\n", $x1, $x30
continue
end
Первый же прогон всё объяснил:
HIT CreateGameAndConnect mode=0 <- одиночная?!
HIT ServerNet UDP path port=1401 <- 5121, патч работает
HIT Netlayer.SetUDP port=1401
HIT Netlayer.SetUDP exit <- вышла сразу, сокет не создан
Обе функции смотрят в одно поле глобального объекта приложения, app+0x2C («режим мультиплеера»). На Switch его выставляет путь через Photon, который мы выкинули. Флаг остаётся нулём, игра считает себя одиночной, а SetUDP молча выходит. Лечится одной записью в нашем обработчике кнопки:
0x622b50 adrp x8, 0x3402000
0x622b54 add x8, x8, #0xf0
0x622b58 ldr x8, [x8] ; app
0x622b5c mov w9, #1
0x622b60 str w9, [x8, #0x2c] ; multiplayer = 1
Следующий прогон:
HIT CreateGameAndConnect mode=1
HIT UdpInit port=1401
HIT UdpInit OK
И с ПК:
REPLY b'BNERU\x01\x14\x00\x07Pipiska'
REPLY b'BNXR...1100_west_harbor'
Сервер отвечает по LAN своим именем и модулем. Вот она, натуральная двадцатка.
Слой 7. «Ваше имя игрока уже используется»
Вторая консоль нашла сервер, но подключение оборвалось:
Сервер найден. Попытка подключения...
Ошибка соединения. Такое имя игрока уже используется.
Ники в меню у нас разные, так что включили отладчик на обеих консолях сразу (два gdb, две сессии) и прошли рукопожатие NWN-протокола целиком: BNCS → BNCR → BNVS → BNVR. Я в это время держал сервер на одной консоли и подключался с другой.
SRV HIT BNVS handler conn=1
SRV HIT Validate ret w0=6 ; код 6 = strref 10289, "имя уже используется"
В функции проверки нового игрока:
0x447900 bl 0x4472c0 ; есть ли игрок с таким же login-именем?
0x447904 cmp w0, #1
0x447908 b.ne 0x44793c ; -> b 0x44793c
0x44790c mov w21, #6
Login-имя на Switch берётся не из ника профиля, а из внутреннего буфера транспорта. Без аккаунта Nintendo он у всех консолей одинаковый. Убрали этот отказ, и оба подключения прошли проверку (Validate ret w0=0). Супруга заходит на сервер, и мы наконец стоим в одной локации.
Итоговый патч
Atmosphere ест IPS32. Смещения в нём — это адрес в NSO плюс 0x100 (заголовок):
def ips32(recs):
out = b'IPS32'
for off, data in sorted(recs.items()):
out += struct.pack('>I', off + 0x100) + struct.pack('>H', len(data)) + data
return out + b'EEOF'
Каждую правку скрипт сборки сначала проверяет: исходные байты на адресе должны совпасть с ожидаемой инструкцией, иначе он падает. Так другая сборка игры не получит мусор. Файл называется по build ID: atmosphere/exefs_patches/nwn2_lan/3554C29951DDA41FE0AF61D3888DC3BB019764CA.ips.
0x3ccab4— не вызывать «войти в аккаунт Nintendo»0x3c8e40— «онлайн-шаги готовы»0x622b44— «Мультиплеер» без Photon + nifm + флаг сетевого режима0x650478— новая сетевая игра → UDP0x5e8fc4— загрузка сетевого сохранения → UDP0x8a4368— сервер всегда на UDP 51210x447908— не отклонять одинаковые login-имена
Бонус: графика
По ходу дела нашлось, где Switch-версия хранит графику: nwn2.ini лежит прямо в сохранениях игры. Порт сильно недокручен:
NumberOfTextureMipLevelsToSkip=1 ; текстуры в половинном разрешении!
TextureMaxAnisoTropy=2
FarShadows=0
CharacterDropShadows=0
FarClipPlaneModifier=0.700000
WaterReflections=0
MultiSampleType=0
Выкрутили всё на максимум: полные текстуры, анизотропия 16, все тени с картами 2048, MSAA 4x, HDR, отражения воды, дальность 100%. Switch тянет это без проблем, игра всё-таки 2006 года, а картинка стала заметно свежее. Пресет лежит в репозитории в extras/.
Что осталось
Вкладка LAN пока пустая: широковещательный поиск на Switch не долетает. Прямое подключение по IP работает, нам его хватает.
После захода в «Мультиплеер» игра до перезапуска остаётся в сетевом режиме.
Интернет-игру (Photon) так включить нельзя, и это не было целью.
Каково это — реверсить с ИИ
Честно о том, как это выглядело со стороны человека, который до этого реверсом всерьёз не занимался.
Что получилось хорошо:
Claude сам писал и запускал скрипты: распаковка NSO, поиск строк и вызовов SDK, сборка IPS, генерация брейкпоинтов под ASLR. Я уже по готовым находкам дописывал код и решал, куда копать дальше.
Когда я принёс ссылку на форум, где описано, как работает LAN в NWN, он связал это с кодом: пакет
BNESна 5121, проверка порта с ПК.Отладчик через gdbstub, прокси и два gdb одновременно я бы сам не поднял без залипания в документацию.
Что было не так гладко:
Без живой игры ИИ долго гадал. Первые патчи «должны были» помочь, а на консоли не помогали, и каждый раз выяснялось, что есть ещё один слой. Перелом наступил, только когда мы включили отладчик и стали смотреть, что реально вызывается.
Были и косяки: например, при «уборке» удалился дамп базы игры, который ещё понадобился, и пришлось дампить 21 ГБ заново. Пару раз я тратил время на тесты, которые по-хорошему можно было не делать.
Механику игры ИИ не знает лучше игрока. Когда дверь в доме не открывалась, мы полчаса искали баг в патче, а нужно было просто поднять уровень. Мы буквально атаковали газебо: долго и серьёзно воевали с тем, что врагом не было.
Вывод: по статике находилось, где в игре нужный код, но каждый следующий слой всплывал только после запуска патча на консоли. Решающим в проекте стал отладчик на живой Switch: без него последние три слоя (транспорт, флаг сетевого режима, проверка имени) мы бы искали ещё долго.
Итого
Семь слоёв, семь правок в коде, два XML и один вечер с gdb на двух консолях. Если коротко о главном: статика хорошо показывает, что в игре есть, а отвечает на вопрос почему оно не работает только живой отладчик. gdbstub в Atmosphere для такого — подарок.
Патч, README и пресет графики: https://github.com/AnDyQnn/nwn2-switch-lan (MIT). Если у кого-то другая версия игры, напишите в Issues: перенести правки на новый build несложно, все адреса в списке выше.
В итоге NWN2 на прошитых Switch снова играется по локальной сети: хост создаёт сервер, второй игрок подключается по IP, аккаунт Nintendo и подписка не нужны. Мы так и проходим кампанию вдвоём.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.