Удалённое управление эмулятором Fceux с помощью Python

в 13:17, , рубрики: emulation, jupyter, Nes, python, romhacking

В статье я опишу, как сделать эмулятор NES управляемым удалённо, и сервер для удалённой отправки команд на него.

Удалённое управление эмулятором Fceux с помощью Python - 1

Зачем это нужно?

Некоторые эмуляторы различных игровых консолей, в том числе и Fceux, позволяют писать и запускать пользовательские скрипты на Lua. Но Lua – плохой язык для написания серьёзных программ. Это скорее язык для вызова функций, написанных на Си. Авторы эмуляторов используют его только из-за легковесности и простоты встраивания. Точная эмуляция требует много ресурсов процессора, и ранее скорость эмуляции была одной из главных целей авторов, а о возможности скриптования действий если и вспоминали, то далеко не в первую очередь.

Сейчас мощности среднего процессора с головой хватает для эмуляции NES, почему бы тогда не использовать в эмуляторах мощные скриптовые языки вроде Python или JavaScript?

К сожалению, ни в одном из популярных эмуляторов NES нет возможности использовать эти или другие языки. Я обнаружил только малоизвестный проект Nintaco, который также основан на ядре Fceux, зачем-то переписанном на Java. Тогда я решил добавить возможность написания скриптов на Python для управления эмулятором сам.

Мой результат – это Proof-of-Concept возможности управления эмулятором, он не претендует на скорость или надёжность, но он работает. Я делал его для себя, но так как вопрос о том, как управлять эмулятором с помощью скриптов, встречается достаточно часто, то я выложил исходники на гитхаб.

Как это устроено

На стороне эмулятора

Эмулятор Fceux уже включает в себя несколько Lua-библиотек, включённых в него в виде скомпилированного кода. Одна из них – LuaSocket. Она плохо документирована, однако мне удалось найти пример работающего кода среди коллекции скриптов Xkeeper0. Он использовал сокеты для управления эмулятором через Mirc. Собственно, код, который открывает tcp-сокет:

function connect(address, port, laddress, lport)
    local sock, err = socket.tcp()
    if not sock then return nil, err end
    if laddress then
        local res, err = sock:bind(laddress, lport, -1)
        if not res then return nil, err end
    end
    local res, err = sock:connect(address, port)
    if not res then return nil, err end
    return sock
end

sock2, err2 = connect("127.0.0.1", 81)
sock2:settimeout(0) --it's our socket object
print("Connected", sock2, err2)

Это низкоуровневый сокет, который получает и отправляет данные по 1 байту.

В эмуляторе Fceux основной цикл Lua-скрипта выглядит так:

function main()
    while true do                 --вечный цикл
        passiveUpdate()        --проверка, не пришли ли новые команды через сокет
        emu.frameadvance() --передача управления эмулятору для отрисовки следующего кадра
    end
end

А проверка данных из сокета:

function passiveUpdate()
    local message, err, part = sock2:receive("*all")
    if not message then
        message = part
    end
    if message and string.len(message)>0 then
        --print(message)
        local recCommand = json.decode(message)
        table.insert(commandsQueue, recCommand)
        coroutine.resume(parseCommandCoroutine)
    end
end

Код достаточно прост – делается чтение данных из сокета, и если была обнаружена следующая команда, то осуществляется её парсинг и исполнение. Парсинг и выполнение организованы с помощью корутин (сопрограмм) – это мощная концепция языка Lua для приостановки и продолжения выполнения кода.

И ещё важная одна вещь про Lua-скриптинг в Fceux – эмуляция может быть временно остановлена из скрипта. Каким образом организовать продолжения выполнения Lua-кода и вновь запустить её командой, полученной из сокета? Это было бы невозможно, но существует плохо документированная возможность вызвать Lua-код даже при остановленной эмуляции (спасибо feos за то, что навёл на неё):

gui.register(passiveUpdate) --undocumented. this function will call even if emulator paused

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

На стороне сервера команд

Я использую очень простой текстовый RPC-протокол, основанный на JSON. Сервер сериализует название функции и аргументы в JSON-строку и отправляет её через сокет. Дальше выполнение кода останавливается, пока эмулятор не ответит строкой завершения выполнения команды. Ответ будет содержать поля "FUNCTIONNAME_finished" и результат выполнения функции.

Идея реализована в классе syncCall:

class syncCall:
    @classmethod
    def waitUntil(cls, messageName):
        """cycle for reading data from socket until needed message was read from it. All other messages will added in message queue"""
        while True:
            cmd = messages.parseMessages(asyncCall.waitAnswer(), [messageName])
            #print(cmd)
            if cmd != None:
                if len(cmd)>1:
                    return cmd[1]
                return
               
    @classmethod
    def call(cls, *params):
        """wrapper for sending [functionName, [param1, param2, ...]] to socket and wait until client return [functionName_finished, [result1,...]] answer"""
        sender.send(*params)
        funcName = params[0]
        return syncCall.waitUntil(funcName + "_finished")

С помощью этого класса Lua-методы эмулятора Fceux могут быть обёрнуты в Python-классы:

class emu:
    @classmethod
    def poweron(cls):
        return syncCall.call("emu.poweron")
        
    @classmethod
    def pause(cls):
        return syncCall.call("emu.pause")
        
    @classmethod
    def unpause(cls):
        return syncCall.call("emu.unpause")
        
    @classmethod
    def message(cls, str):
        return syncCall.call("emu.message", str)
        
    @classmethod
    def softreset(cls):
        return syncCall.call("emu.softreset")
        
    @classmethod
    def speedmode(cls, str):
        return syncCall.call("emu.speedmode", str)

И затем вызваны дословно так же, как и из Lua:

#Перезапуск игры:
emu.poweron()

Методы обратного вызова

В Lua можно зарегистрировать колбеки – функции, которые будут вызваны при выполнении определённого условия. Мы можем перенести это поведение в сервер на Python с помощью следующего приёма. Сначала мы сохраняем идентификатор функции-колбека, написанной на Python, и передаём его в Lua-код:

class callbacks:
    functions = {}
    
    callbackList = [
        "emu.registerbefore_callback",
        "emu.registerafter_callback",
        "memory.registerexecute_callback",
        "memory.registerwrite_callback",
    ]
    
    @classmethod
    def registerfunction(cls, func):
        if func == None:
            return 0
        hfunc = hash(func)
        callbacks.functions[hfunc] = func
        return hfunc
        
    @classmethod 
    def error(cls, e):
        emu.message("Python error: " + str(e))
    
    @classmethod
    def checkAllCallbacks(cls, cmd):
        #print("check:", cmd)
        for callbackName in callbacks.callbackList:
            if cmd[0] == callbackName:
                hfunc = cmd[1]
                #print("hfunc:", hfunc)
                func = callbacks.functions.get(hfunc)
                #print("func:", func)
                if func:
                    try:
                        func(*cmd[2:]) #skip function name and function hash and save others arguments
                    except Exception as e:
                        callbacks.error(e)
                        pass
                    #TODO: thread locking
                    sender.send(callbackName + "_finished")

Lua-код также сохраняет этот идентификатор и регистрирует обычный Lua-колбек, который будет передавать управление в Python-код. Далее, в Python-коде создаётся отдельный поток, который занимается только тем, что проверяет, не была ли принята команда вызова колбека из Lua:

def callbacksThread():
    cycle = 0
    while True:
        cycle += 1
        try:
            cmd = messages.parseMessages(asyncCall.waitAnswer(), callbacks.callbackList)
            if cmd:
                #print("Callback received:", cmd)
                callbacks.checkAllCallbacks(cmd)
            pass
        except socket.timeout:
            pass
        time.sleep(0.001)

Последний шаг – после выполнения Python-колбека управление возвращается в Lua с помощью команды "CALLBACKNAME_finished", чтобы информировать эмулятор, что колбек закончен.

Как запустить пример

  • У вас должны быть работающие Python 3 и Jupyter Notebook в системе. Необходимо запустить Jupyter командой
    jupyter notebook
    

  • Откройте ноутбук FceuxPythonServer.py.ipynb и запустите первую строку
    Удалённое управление эмулятором Fceux с помощью Python - 2
  • Теперь вы должны запустить эмулятор Fceux, открыть в нём ROM-файл (я использую игру Castlevania (U) (PRG0) [!].nes в своём примере) и запустить Lua-скрипт с именем fceux_listener.lua. Он должен соединиться с сервером, запущенном в ноутбуке Jupyter.

    Эти действия можно выполнить с помощью командной строки:

    fceux.exe -lua fceux_listener.lua "Castlevania (U) (PRG0) [!].nes"
    

  • Теперь снова переключитесь на Jupyter Notebook. Вы должны увидеть сообщение об успешном соединении с эмулятором:

    Удалённое управление эмулятором Fceux с помощью Python - 3

Всё, вы можете посылать команды из ноутбука Jupyter в браузере прямо в эмулятор Fceux.

Можно выполнять все строки ноутбука-примера последовательно и наблюдать за результатом выполнения в эмуляторе.

Полный пример:
https://github.com/spiiin/fceux_luaserver/blob/master/FceuxPythonServer.py.ipynb

Он содержит простые функции вроде чтения памяти:

Удалённое управление эмулятором Fceux с помощью Python - 4

Более сложные примеры с созданием колбеков:

Удалённое управление эмулятором Fceux с помощью Python - 5

И скрипт для конкретной игры, позволяющий перемещать врагов из Super Mario Bros. с помощью мыши:

Удалённое управление эмулятором Fceux с помощью Python - 6

Видео выполнения ноутбука:

Ограничения и применения

Скрипт не имеет защиты от дурака и не оптимизирован по скорости выполнения – лучше было бы использовать бинарный RPC-протокол вместо текстового и группировать сообщения вместе, но моя реализация не требуется компиляции. Скрипт может переключать контексты выполнения из Lua в Python и обратно 500-1000 раз в секунду на моём ноутбуке. Этого достаточно почти для любых применений, кроме специфических случаев попиксельной или построчной отладки видеопроцессора, но Fceux всё равно не позволяет проводить такие операции из Lua, так что это неважно.

Возможные идеи применения:

  • Как пример реализации подобного управления для других эмуляторов и языков
  • Исследование игр
  • Добавление читов или фич для организации TAS-прохождений
  • Вставка или извлечение данных и кода в игры
  • Расширение возможностей эмуляторов — написание отладчиков, скриптов записи и просмотра прохождений, скриптовых библиотек, редакторов игр
  • Сетевая игра, контроль игры с помощью мобильных устройств, удалённых сервисов, джойпадов или других устройств управления, сохранение и патчи в облачных сервисах
  • Кросс-эмуляторные фичи
  • Использование библиотек языков Python или других для анализа данных и управления игрой (создание ботов)

Стек технологий

Я использовал:

Fceuxwww.fceux.com/web/home.html
Это классический эмулятор NES, и большинство людей используют его. Он не обновлялся уже долгое время, и не лучший по возможностям, но он остаётся эмулятором по умолчанию для множества ромхакеров. Также, я выбрал его из-за того, что в него интегрирована поддержка Lua-сокетов, и нет необходимости подключать её самому.

Json.luagithub.com/spiiin/json.lua
Это реализация JSON на чистом Lua. Я выбрал её, потому что хотел сделать пример, который не требует компиляции кода. Но мне всё равно пришлось сделать форк библиотеки, потому что какая-то из встроенных во Fceux библиотек перегружала библиотечную функцию tostring и ломала сериализацию (мой отклонённый пул-реквест автору оригинальной библиотеки).

Python 3www.python.org
Fceux Lua сервер открыват tcp-сокет и слушает команды, полученные от него. Сервер, который посылает команды эмулятору, может быть реализован на любом языке. Я выбрал Python за его философию «Battery included» – большинство модулей включены в стандартную библиотеку (работа с сокетами и JSON в том числе). Также Python известен библиотека работы с нейронными сетями, и мне хочется попробовать использовать их для создания ботов в NES-играх.

Jupyter Notebookjupyter.org
Jupyter Notebook – очень крутая среда для интерактивного выполнения Python-кода. С помощью неё вы можете писать и выполнять команды в табличном редакторе внутри браузера. Он также хорош для создания презентабельных примеров.

Dexpotwww.dexpot.de
Я использовал этот менеджер виртуальных рабочих столов, для того, чтобы закреплять окно эмулятора поверх других. Это очень удобно при разворачивании сервера на полный экран для мгновенного отслеживание изменений в окне эмулятора. Штатные средства Windows не позволяют организовать закрепление окна поверх других.

Ссылки

Собственно, репозиторий проекта.

Nintaco — эмулятор NES на Java с возможностью удалённого управления
Xkeeper0 emu-lua collection — коллекция различных Lua-скриптов
Mesen — современный эмулятор NES на C# с мощными возможностями написания скриптов на Lua. Пока без поддержки сокетов и удалённого управления.
CadEditor — мой проект универсального редактора уровней для NES и других платформ, а также мощные инструменты для исследования игр. Я использую скрипт и сервер, описанные в посте, для того, чтобы исследовать игры и добавлять их в редактор.

Буду признателен за отзывы, тестирование и попытки использования скрипта.

Автор: spiiin

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js