Table of Contents

About

Netplay for libretro/RetroArch can be activated in order to play with friends or random players over the Internet. The method consists roughly in starting a game, enabling netplay and waiting for clients to connect. Clients would then have to scan the lobby for public games and join the game that a server has made. Unfortunately, once a player connects to the server, RetroArch does emit a notification on the server screen but the notification does not stick, is very difficult to see and the client might go unnoticed on the host machine.

The following program adds a way to forward netplay activity to an MQTT server thereby allowing further processing and automation with packages such as node-red.

Requirements

Setup

(sleep 10 && /storage/.local/opt/netplayMQTT/netplayMQTT.py --daemon) &

depending on where the files from this project are located in order to make sure that netplayMQTT.py is started on boot.

Lakka

If RetroArch is running on Lakka, then further steps are necessary:

wget https://bootstrap.pypa.io/get-pip.py
pip install pyyaml
pip install watchdog
pip install paho-mqtt
pip install daemonize

The commands will install modules under /storage/.local/ and will not interfere with Lakka.

Published Netplay Events

The following events, defined in constants.py will be published to the MQTT broker:

Event Parameters Description RetroArch Role
announcing RetroArch has started hosting and is currently announcing the game. Host
waiting RetroArch has started hosting and is waiting for a client. Host
playerjoined nick, controls A player with nickname nick has successfully joined the host and has been allocated slot controls. Host
playerdisconnected nick A player with nickname nick has disconnected from the netplay session. Host
connecting RetroArch is attempting to connect to a host. Client
joined controls RetroArch has joined a host and has been allocated controls controls. Client

in JSON format where the event and all parameters for the event are encoded as top-level key-value pairs.

Automating Using Node Red and Alexa Text-To-Speech

Leveraging alexatts from a former TTS project and combining with Node Red, Alexa can be made to announce whenever a player has joined the game. A Node Red flow seems trivial in this case:

The flow performs the following actions, in order, whenever netplayMQTT.py publishes an event to the MQTT broker (the green msg.payload node is just for debugging purposes and has no effect on the flow):

and then re-publishes the message to the same MQTT broker with a topic corresponding to how alexatts has been set up. Alexatts is then expected to speak out the message A player has joined the netplay session.. Note that the parameters for playerjoined, namely nick and controls are not used in this flow - nick would be problematic since it may include non-speakable characters which a TTS engine will have no choice except to spell them out individually.

Code

All files have to be placed under the same directory.

netplayMQTT.py

netplayMQTT.py
#!/usr/bin/env python
###########################################################################
##  Copyright (C) Wizardry and Steamworks 2019 - License: GNU GPLv3      ##
###########################################################################
# Reads RetroArch logs looking for NetPlay events and publishes to MQTT   #
###########################################################################
#
# wget https://bootstrap.pypa.io/get-pip.py
# python get-pip.py
#
# pip install pyyaml
# pip install watchdog
# pip install paho-mqtt
# pip install daemonize
 
import yaml, subprocess, os, argparse, signal, sys, time, mmap, re, json
import paho.mqtt.client as mqtt
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from os import path
from daemonize import Daemonize
 
import constants
 
# Called when the file has been modified.
class RetroArchLogUpdated(FileSystemEventHandler):
    def __init__(self, client):
        self.client = client
 
    # Called when the monitored file is modified.
    def on_modified(self, event):
        line = self.readlast(event.src_path)
        for event in logevents:
            match = logevents[event].match(line)
            if match:
                #print list(match.groups())
                payload = {}
                payload['event'] = event
                if event == 'announcing':
                    pass
                if event == 'waiting':
                    pass
                if event == 'playerjoined':
                    payload['nick'] = match.group(1)
                    payload['controls'] = match.group(2)
                if event == 'playerdisconnected':
                    payload['nick'] = match.groups(1)
                if event == 'connecting':
                    pass
                if event == 'joined':
                    payload['controls'] = match.groups(1)
                #print json.dumps(payload, ensure_ascii=False)
                # Publishes to MQTT.
                self.client.publish(cfg['mqtt']['topic'], json.dumps(payload, ensure_ascii=False))
 
    # Gets the last line of a file.
    @staticmethod
    def readlast(filename):
        with open(filename) as f:
            mapping = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
            i = mapping.rfind('\n')
            j = mapping.rfind('\n', 0, i)
            return mapping[j + 1:i]
 
# The callback for when the client receives a CONNACK response from the server.
def on_connect(client, userdata, flags, rc):
    client.subscribe(cfg['mqtt']['topic'])
 
# Callback when a message is received on the MQTT bus.
def on_message(client, userdata, msg):
    pass
 
def main():
    # Set up MQTT client.
    client = mqtt.Client()
    client.on_connect = on_connect
    client.on_message = on_message
 
    client.connect(cfg['mqtt']['host'], cfg['mqtt']['port'], 60)
 
    # Set up log watcher.
    event_handler = RetroArchLogUpdated(client)
    observer = Observer()
    observer.schedule(event_handler, path=cfg['retroarch']['logdir'], recursive=False)
    observer.start()
 
    # Blocking call.
    client.loop_forever()
 
def daemon():
    try:
        if path.exists(cfg['pid']):
            with open(cfg['pid'], 'r') as pid_file:
                pid = pid_file.read()
                os.kill(int(pid), signal.SIGHUP)
            os.remove(cfg['pid'])
    except Exception, e:
        print 'Unable to kill previous process: ', str(e)
        return
 
    daemon = Daemonize(app='mqttSwitch', pid=cfg['pid'], action=main)
    daemon.start()
 
with open(os.path.join(os.path.dirname(sys.argv[0]), 'config.yml'), 'r') as ymlfile:
    cfg = yaml.load(ymlfile, Loader=yaml.FullLoader)
 
logevents = {}
 
for event in constants.NETPLAY_PATTERNS:
    logevents[event] = re.compile(constants.NETPLAY_PATTERNS[event], re.IGNORECASE)
 
parser = argparse.ArgumentParser()
parser.add_argument('--daemon', action='store_true', help='run as a daemon')
parser.add_argument('--foreground', action='store_true', help='run as a daemon')
args = parser.parse_args()
 
if(args.daemon):
    daemon()
elif(args.foreground):
    main()
 
parser.print_help()

constants.py

constants.py
NETPLAY_PATTERNS = {
    "announcing": "^\[INFO\] \[netplay\] announcing netplay game.*?$",
    "waiting": "\[INFO\] \[netplay\] Waiting for client",
    "playerjoined": "\[INFO\] \[netplay\] ([^\s]+?) has joined as player ([0-9])+?",
    "playerdisconnected": "\[INFO\] \[netplay\] ([^\s]+?) has disconnected",
    "connecting": "\[INFO\] \[netplay\] Connecting to netplay host",
    "joined": "\[INFO\] \[netplay\] You have joined as player ([0-9])+?",
}

config.yml

config.yml
pid: '/var/run/mqttNetplay.pid'

retroarch:
    logdir: '/storage/.config/retroarch/logs/'

mqtt:
    host: 192.168.1.1
    port: 1883
    topic: 'retroarch'