diff --git a/Docs/README.txt b/Docs/README.txt index 89d90470..4859f1ac 100644 --- a/Docs/README.txt +++ b/Docs/README.txt @@ -1531,6 +1531,52 @@ All settings are case sensitive. ---------------- + Name: Outputs + + Argument: String. + + Description: Manages MAME Hooker compatible outputs. + Valid options are: + - none - default, disables outputs. + - win - Use Windows messages (only on Windows). + - net - Use network messages. + + See OutputsWithLF, OutputsTCPPort and + OutputsUDPBroadcastPort settings when using 'net' option. + Can only be set in the 'Global' section. + + ---------------- + + Name: OutputsWithLF + + Argument: Boolean value (true or false). + + Description: When using 'net' option for 'Outputs', this setting determines whether + messages are terminated with a line feed character. The + default is false. Can only be set in 'Global' section. + + ---------------- + + Name: OutputsTCPPort + + Argument: Integer value. + + Description: When using 'net' option for 'Outputs', this setting determines the + TCP port to use for network messages. Can only be set in 'Global' + section. + + ---------------- + + Name: OutputsUDPBroadcastPort + + Argument: Integer value. + + Description: When using 'net' option for 'Outputs', this setting determines the + UDP broadcast port to use for network messages. Can only be set + in 'Global' section. + + ---------------- + Names: InputStart1 InputStart2 InputCoin1 diff --git a/Makefiles/Rules.inc b/Makefiles/Rules.inc index 6c08735f..2ce15e38 100644 --- a/Makefiles/Rules.inc +++ b/Makefiles/Rules.inc @@ -176,6 +176,7 @@ SRC_FILES = \ Src/Pkgs/imgui/imgui_tables.cpp \ Src/Pkgs/imgui/imgui_widgets.cpp \ Src/ROMSet.cpp \ + Src/OSD/SDL/NetOutputs.cpp \ $(PLATFORM_SRC_FILES) ifeq ($(strip $(NET_BOARD)),1) diff --git a/Scripts/netoutput_tester.py b/Scripts/netoutput_tester.py new file mode 100644 index 00000000..2e25c8c1 --- /dev/null +++ b/Scripts/netoutput_tester.py @@ -0,0 +1,479 @@ +# Supermodel +# A Sega Model 3 Arcade Emulator. +# Copyright 2003-2026 The Supermodel Team +# +# This file is part of Supermodel. +# +# Supermodel is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Supermodel is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with Supermodel. If not, see . + +############################################################################### +# Network output tester for Supermodel - receives messages via UDP and TCP. +# +# This script is designed to test the network output functionality of +# Supermodel by listening for messages sent by the emulator and processing +# them. +# It can be used to verify that Supermodel is sending the correct messages in +# response to various events (e.g., starting/stopping the emulator, pausing, +# etc.). +# +# Usage: +# python netoutput_tester.py [--udp-port UDP_PORT] [--tcp-host TCP_HOST] +# [--tcp-port TCP_PORT] [--max-runtime MAX_RUNTIME] +# +# --tcp-host TCP_HOST TCP host to connect to (default: localhost) +# --tcp-port TCP_PORT TCP port to connect to (default: 8000) +# --udp-port UDP_PORT UDP port to listen on (default: 8001) +# --max-runtime MAX_RUNTIME Maximum runtime in seconds (default: run forever) +# +# The script will print received messages to the console and can be stopped +# with Ctrl+C. +############################################################################### + + +import argparse +import logging +import signal +import socket +import sys +import threading +import time +from collections.abc import Callable +from types import FrameType + +# Configure logging. +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +LOGGER: logging.Logger = logging.getLogger(__name__) + + +class MessageProcessor: + """Processes incoming messages from UDP and TCP connections. + + Handles both static keys (with dedicated handler functions) and dynamic keys + (with a generic handler function). + """ + + def __init__(self) -> None: + """Initialize the message processor with static key handlers.""" + self._static_handlers: dict[str, Callable[[str | None, str], None]] = { + "mame_start": self._handle_mame_start, + "pause": self._handle_pause, + "mame_stop": self._handle_mame_stop, + "tcp": self._handle_tcp, + } + + def parse_message(self, message: str) -> tuple[str, str | None]: + """Parse a message into key and optional value. + + Args: + message (str): Raw message string to parse. + + Returns: + tuple[str, str | None]: A tuple containing the key and optional value. + Value is None if not present. + """ + # Strip blanks from the line. + message = message.strip() + + key: str + value: str | None + if "=" in message: + # Split by '=' and strip blanks from both parts. + parts: list[str] = message.split("=", 1) + key = parts[0].strip() + value = parts[1].strip() if len(parts) > 1 else None + else: + # No value, just a key. + key = message + value = None + + return key, value + + def process_message(self, message: str, source: str) -> None: + """Process a received message by delegating to appropriate handler. + + Args: + message (str): The message string to process. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + if not message: + return + + key: str + value: str | None + key, value = self.parse_message(message) + + if not key: + return + + # Check if it's a static key. + if key in self._static_handlers: + LOGGER.debug("Processing static key from %s: %s = %s", source, key, value) + self._static_handlers[key](value, source) + else: + # Handle as dynamic key. + LOGGER.debug("Processing dynamic key from %s: %s = %s", source, key, value) + self._handle_dynamic_key(key, value, source) + + def _handle_mame_start(self, value: str | None, source: str) -> None: + """Handle the mame_start command. + + Args: + value (str | None): Optional value associated with the command. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + LOGGER.info("MAME START command received from %s with value: %s", source, value) + + def _handle_pause(self, value: str | None, source: str) -> None: + """Handle the pause command. + + Args: + value (str | None): Optional value associated with the command. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + LOGGER.info("PAUSE command received from %s with value: %s", source, value) + + def _handle_mame_stop(self, value: str | None, source: str) -> None: + """Handle the mame_stop command. + + Args: + value (str | None): Optional value associated with the command. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + LOGGER.info("MAME STOP command received from %s with value: %s", source, value) + + def _handle_tcp(self, value: str | None, source: str) -> None: + """Handle the tcp command. + + Args: + value (str | None): Optional value associated with the command. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + LOGGER.info("TCP command received from %s with value: %s", source, value) + + def _handle_dynamic_key(self, key: str, value: str | None, source: str) -> None: + """Handle dynamic keys that are not in the static handler list. + + Args: + key (str): The dynamic key name. + value (str | None): Optional value associated with the key. + source (str): The source of the message (e.g., "UDP" or "TCP"). + """ + LOGGER.info("Dynamic key '%s' received from %s with value: %s", key, source, value) + + +class UDPReceiver: + """Receives and processes UDP messages on a configurable port.""" + + def __init__(self, processor: MessageProcessor, port: int = 8001, buffer_size: int = 4096) -> None: + """Initialize the UDP receiver. + + Args: + processor (MessageProcessor): The message processor to handle received messages. + port (int): The port to listen on (default: 8001). + buffer_size (int): The buffer size for receiving data (default: 4096). + """ + self._processor: MessageProcessor = processor + self._port: int = port + self._buffer_size: int = buffer_size + self._running: bool = False + self._thread: threading.Thread | None = None + self._socket: socket.socket | None = None + + def start(self) -> None: + """Start the UDP receiver in a separate thread.""" + if self._running: + LOGGER.warning("UDP receiver is already running.") + return + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + LOGGER.info("UDP receiver started on port %d.", self._port) + + def stop(self) -> None: + """Stop the UDP receiver.""" + if not self._running: + return + + self._running = False + if self._socket: + self._socket.close() + if self._thread: + self._thread.join(timeout=5.0) + LOGGER.info("UDP receiver stopped.") + + def _run(self) -> None: # pylint: disable=too-many-branches # noqa: PLR0912 + """Main loop for the UDP receiver thread.""" + try: + # Create UDP socket. + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.bind(("", self._port)) + LOGGER.info("UDP socket bound to port %d.", self._port) + # Set a timeout to allow periodic checks for shutdown. + self._socket.settimeout(0.1) + + # Buffer for incomplete messages. + buffer: str = "" + + while self._running: + try: + # Receive data. + data: bytes + addr: tuple[str, int] + data, addr = self._socket.recvfrom(self._buffer_size) + if not data: + continue + + # Decode the data. + text: str = data.decode("utf-8", errors="ignore") + buffer += text + + # Process complete lines. + while "\n" in buffer or "\r" in buffer: + # Split on both \r\n and \n. + line: str + if "\r\n" in buffer: + line, buffer = buffer.split("\r\n", 1) + elif "\n" in buffer: + line, buffer = buffer.split("\n", 1) + elif "\r" in buffer: + line, buffer = buffer.split("\r", 1) + else: + break + + # Process the line. + if line: + LOGGER.debug("UDP received from %s: %s.", addr, line) + self._processor.process_message(line, source="UDP") + + except TimeoutError: + continue + except OSError as e: + if self._running: + LOGGER.error("UDP socket error: %s", e) + break + + except Exception as e: # pylint: disable=broad-except + LOGGER.error("UDP receiver error: %s", e) + finally: + if self._socket: + self._socket.close() + + +class TCPConnector: # pylint: disable=too-many-instance-attributes + """Connects to a TCP server and processes incoming messages.""" + + def __init__( # pylint: disable=too-many-positional-arguments, too-many-arguments + self, + processor: MessageProcessor, + host: str = "localhost", + port: int = 8000, + retry_interval: float = 0.1, + buffer_size: int = 4096, + ) -> None: + """Initialize the TCP connector. + + Args: + processor (MessageProcessor): The message processor to handle received messages. + host (str): The hostname to connect to (default: "localhost"). + port (int): The port to connect to (default: 8000). + retry_interval (float): The interval in seconds between connection attempts + (default: 0.1). + buffer_size (int): The buffer size for receiving data (default: 4096). + """ + self._processor: MessageProcessor = processor + self._host: str = host + self._port: int = port + self._retry_interval: float = retry_interval + self._buffer_size: int = buffer_size + self._running: bool = False + self._thread: threading.Thread | None = None + self._socket: socket.socket | None = None + + def start(self) -> None: + """Start the TCP connector in a separate thread.""" + if self._running: + LOGGER.warning("TCP connector is already running.") + return + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + LOGGER.info("TCP connector started, will connect to %s:%d.", self._host, self._port) + + def stop(self) -> None: + """Stop the TCP connector.""" + if not self._running: + return + + self._running = False + if self._socket: + self._socket.close() + if self._thread: + self._thread.join(timeout=5.0) + LOGGER.info("TCP connector stopped.") + + def _run(self) -> None: # pylint: disable=too-many-branches # noqa: PLR0912,PLR0915,C901 + """Main loop for the TCP connector thread.""" + LOGGER.info("Attempting to connect to %s:%d.", self._host, self._port) + while self._running: # pylint: disable=too-many-nested-blocks + try: + # Try to connect. + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(5.0) + self._socket.connect((self._host, self._port)) + LOGGER.info("Connected to %s:%d.", self._host, self._port) + + # Buffer for incomplete messages. + buffer: str = "" + + # Receive loop. + while self._running: + try: + data: bytes = self._socket.recv(self._buffer_size) + if not data: + LOGGER.info("TCP connection closed by server.") + break + + # Decode the data. + text: str = data.decode("utf-8", errors="ignore") + buffer += text + + # Process complete lines. + while "\n" in buffer or "\r" in buffer: + # Split on both \r\n and \n. + line: str + for delimiter in ("\r\n", "\n", "\r"): + if delimiter in buffer: + line, buffer = buffer.split(delimiter, 1) + break + else: + break + + # Process the line. + if line: + LOGGER.debug("TCP received: %s.", line) + self._processor.process_message(line, source="TCP") + + except TimeoutError: + continue + except OSError as e: + if self._running: + LOGGER.error("TCP socket error: %s", e) + break + + except (TimeoutError, ConnectionRefusedError): + if self._running: + time.sleep(self._retry_interval) + except Exception as e: # pylint: disable=broad-except + if self._running: + LOGGER.error("TCP connection error: %s", e) + LOGGER.info("Retrying in %s seconds...", self._retry_interval) + time.sleep(self._retry_interval) + finally: + if self._socket: + self._socket.close() + self._socket = None + + +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments. + + Returns: + argparse.Namespace: Parsed command-line arguments. + """ + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Network output tester for Supermodel - receives messages via UDP and TCP." + ) + + parser.add_argument("--udp-port", type=int, default=8001, help="UDP port to listen on (default: 8001)") + + parser.add_argument("--tcp-host", type=str, default="localhost", help="TCP host to connect to (default: localhost)") + + parser.add_argument("--tcp-port", type=int, default=8000, help="TCP port to connect to (default: 8000)") + + parser.add_argument( + "--max-runtime", type=int, default=None, help="Maximum runtime in seconds (default: run forever)" + ) + + return parser.parse_args() + + +def main() -> None: + """Main entry point for the network output tester.""" + print(" #### ### ###") + print(" ## ## ## ##") + print(" ### ## ## ## ### #### ## ### ## ## #### ## #### ##") + print(" ### ## ## ## ## ## ## ### ## ####### ## ## ##### ## ## ##") + print(" ### ## ## ## ## ###### ## ## ####### ## ## ## ## ###### ##") + print(" ## ## ## ## ##### ## ## ## # ## ## ## ## ## ## ##") + print(" #### ### ## ## #### #### ## ## #### ### ## #### ####") + print(" ####") + print("") + print("Network Output Tester v1.0.0") + print() + + # Parse command-line arguments. + args: argparse.Namespace = parse_arguments() + + # Create message processor. + processor: MessageProcessor = MessageProcessor() + + # Create and start UDP receiver. + udp_receiver: UDPReceiver = UDPReceiver(processor, port=args.udp_port) + udp_receiver.start() + + # Create and start TCP connector. + tcp_connector: TCPConnector = TCPConnector(processor, host=args.tcp_host, port=args.tcp_port) + tcp_connector.start() + + # Setup signal handler for immediate exit on Ctrl-C. + def signal_handler(sig: int, frame: FrameType | None) -> None: + """Handle SIGINT for immediate shutdown. + + Args: + sig (int): The signal number. + frame (FrameType | None): The current stack frame. + """ + _ = sig, frame # Unused parameters. + LOGGER.info("\nShutting down...") + udp_receiver.stop() + tcp_connector.stop() + LOGGER.info("Shutdown complete.") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + if args.max_runtime: + LOGGER.info("Network output tester is running for %d seconds. Press Ctrl+C to stop.", args.max_runtime) + else: + LOGGER.info("Network output tester is running. Press Ctrl+C to stop.") + + # Keep the main thread alive. + start_time: float = time.time() + while True: + # Check if maximum runtime has been exceeded. + if args.max_runtime: + elapsed: float = time.time() - start_time + if elapsed >= args.max_runtime: + LOGGER.info("Maximum runtime of %d seconds reached. Shutting down...", args.max_runtime) + udp_receiver.stop() + tcp_connector.stop() + break + + time.sleep(0.1) + + +if __name__ == "__main__": + main() diff --git a/Src/OSD/SDL/Main.cpp b/Src/OSD/SDL/Main.cpp index 2a704960..c4ceacf2 100644 --- a/Src/OSD/SDL/Main.cpp +++ b/Src/OSD/SDL/Main.cpp @@ -78,6 +78,7 @@ #include "DirectInputSystem.h" #include "WinOutputs.h" #endif +#include "NetOutputs.h" #include "Supermodel.h" #include "Util/Format.h" @@ -1583,7 +1584,16 @@ Util::Config::Node DefaultConfig() config.Set("SDLVibrateMax", 100, "ForceFeedback", 0, 100); config.Set("SDLConstForceThreshold", 30, "ForceFeedback", 0, 100); #endif - config.Set("Outputs", "none", "Misc", "", "", { "none","win" }); + +#ifdef SUPERMODEL_WIN32 + config.Set("Outputs", "none", "Misc", "", "", { "none","win","net" }); +#else + config.Set("Outputs", "none", "Misc", "", "", { "none","net" }); +#endif + config.Set("OutputsWithLF", false, "Misc"); + config.Set("OutputsTCPPort", 8000, "Misc", 1024, 65535); + config.Set("OutputsUDPBroadcastPort", 8001, "Misc", 1024, 65535); + config.Set("DumpMemory", false, "Misc"); config.Set("DumpTextures", false, "Misc"); @@ -2410,13 +2420,23 @@ int main(int argc, char **argv) goto Exit; // Create outputs -#ifdef SUPERMODEL_WIN32 { std::string outputs = s_runtime_config["Outputs"].ValueAs(); if (outputs == "none") Outputs = NULL; - else if (outputs == "win") + else if (outputs == "net") + { + CNetOutputs* netOutputs = new CNetOutputs(); + if (s_runtime_config["OutputsWithLF"].ValueAs()) + netOutputs->SetFrameEnding(std::string("\r\n")); + netOutputs->SetTcpPort(s_runtime_config["OutputsTCPPort"].ValueAs()); + netOutputs->SetUdpBroadcastPort(s_runtime_config["OutputsUDPBroadcastPort"].ValueAs()); + Outputs = (COutputs*)netOutputs; +#ifdef SUPERMODEL_WIN32 + } else if (outputs == "win") { Outputs = new CWinOutputs(); +#endif // SUPERMODEL_WIN32 + } else { ErrorLog("Unknown outputs: %s\n", outputs.c_str()); @@ -2424,7 +2444,6 @@ int main(int argc, char **argv) goto Exit; } } -#endif // SUPERMODEL_WIN32 // Initialize outputs if (Outputs != NULL && !Outputs->Initialize()) diff --git a/Src/OSD/SDL/NetOutputs.cpp b/Src/OSD/SDL/NetOutputs.cpp new file mode 100644 index 00000000..6a870f14 --- /dev/null +++ b/Src/OSD/SDL/NetOutputs.cpp @@ -0,0 +1,307 @@ +/** + ** Supermodel + ** A Sega Model 3 Arcade Emulator. + ** Copyright 2003-2026 The Supermodel Team + ** + ** This file is part of Supermodel. + ** + ** Supermodel is free software: you can redistribute it and/or modify it under + ** the terms of the GNU General Public License as published by the Free + ** Software Foundation, either version 3 of the License, or (at your option) + ** any later version. + ** + ** Supermodel is distributed in the hope that it will be useful, but WITHOUT + ** ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + ** FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + ** more details. + ** + ** You should have received a copy of the GNU General Public License along + ** with Supermodel. If not, see . + **/ + +#include "NetOutputs.h" + + +CNetOutputs::CNetOutputs() +{ +} + +CNetOutputs::~CNetOutputs() +{ + // Unregister all clients and destroy the TCP server socket. + UnregisterAllClients(); + DestroyTcpServerSocket(); + + // Stop the server thread. + m_running.store(false); + if (m_tcpServerThread.joinable()) { + m_tcpServerThread.join(); + } + + // Free up resources. + if (m_tcpSocketSet) { + SDLNet_FreeSocketSet(m_tcpSocketSet); + } + if (m_serverSocket) { + SDLNet_TCP_Close(m_serverSocket); + } + + SDLNet_Quit(); +} + +bool CNetOutputs::Initialize() +{ + // Create the TCP server socket. + if (!CreateTcpServerSocket()) { + ErrorLog("Unable to create TCP server socket."); + return false; + } + + // Run TCP server thread. + if (!CreateServerThread()) { + ErrorLog("Unable to start thread for TCP server."); + return false; + } + + return true; +} + +void CNetOutputs::Attached() +{ + // Broadcast a startup message. + if (!SendUdpBroadcastWithId()) { + ErrorLog("Unable to notify with udp broadcast."); + } +} + +void CNetOutputs::SendOutput(EOutputs output, UINT8 prevValue, UINT8 value) +{ + // Loop through all registered clients and send them new output values. + for (auto& client : m_clients) { + std::string name = GetOutputName(output); + std::string strvalue = std::to_string(value); + std::string line = name + m_separatorIdAndValue + strvalue + m_frameEnding; + if (!SendTcpData(client, line)) { + ErrorLog("Failed to send output data to client. Unregistering client."); + UnregisterClient(client.ClientSocket); + } + } +} + +bool CNetOutputs::CreateServerThread() +{ + m_running.store(true); + m_tcpServerThread = std::thread([this] { + while (m_running.load()) { + TCPsocket clientSocket = SDLNet_TCP_Accept(m_serverSocket); + if (clientSocket) { + // Handle new client connection (e.g., add to list of clients, send initial state, etc.). + IPaddress* clientIP = SDLNet_TCP_GetPeerAddress(clientSocket); + if (clientIP) { + const char* host = SDLNet_ResolveIP(clientIP); + InfoLog("Client connected from %s:%d", host, clientIP->port); + } else { + ErrorLog("Failed to get client IP address: %s", SDLNet_GetError()); + } + RegisterClient(clientSocket); + } + // Sleep to prevent busy waiting. + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + }); + if (!m_tcpServerThread.joinable()) { + ErrorLog("Failed to create thread for TCP server."); + return false; + } + + return true; + +} + +bool CNetOutputs::CreateTcpServerSocket() +{ + SDLNet_Init(); + + // Resolve the host (NULL means any host) and port for the server to listen on. + IPaddress ip; + if (SDLNet_ResolveHost(&ip, NULL, m_tcpPort) == -1) { + ErrorLog("ResolveHost error: %s", SDLNet_GetError()); + return false; + } + + // Create the TCP socket and bind it to the specified port. + m_serverSocket = SDLNet_TCP_Open(&ip); + if (!m_serverSocket) { + ErrorLog("TCP_Open error: %s", SDLNet_GetError()); + return false; + } + + // Create the socket set to manage multiple clients. + // It is +1 to include the server socket itself. + m_tcpSocketSet = SDLNet_AllocSocketSet(m_maxClients + 1); + SDLNet_TCP_AddSocket(m_tcpSocketSet, m_serverSocket); + + return true; + +} + +void CNetOutputs::DestroyTcpServerSocket() +{ + if (m_serverSocket) { + SDLNet_TCP_Close(m_serverSocket); + m_serverSocket = nullptr; + } +} + +bool CNetOutputs::SendUdpBroadcastWithId() +{ + // Open a UDP socket on any available port. + UDPsocket udpSocket = SDLNet_UDP_Open(0); + if (!udpSocket) { + ErrorLog("UDP_Open error: %s", SDLNet_GetError()); + return false; + } + + // Set up the broadcast address. + IPaddress broadcastAddr; + if (SDLNet_ResolveHost(&broadcastAddr, "255.255.255.255", m_udpBroadcastPort) == -1) { + ErrorLog("ResolveHost error: %s", SDLNet_GetError()); + return false; + } + + // Build the broadcast packet. + UDPpacket* packet = SDLNet_AllocPacket(512); + std::string lines = "mame_start" + m_separatorIdAndValue + GetGame().name + m_frameEnding + "tcp" + m_separatorIdAndValue + std::to_string(m_tcpPort) + m_frameEnding; + + memcpy(packet->data, lines.c_str(), lines.length()); + packet->len = lines.length(); + packet->address = broadcastAddr; + + // Send the broadcast packet. + if (SDLNet_UDP_Send(udpSocket, -1, packet) == 0) { + ErrorLog("UDP_Send error: %s", SDLNet_GetError()); + } else { + DebugLog("Broadcast sent!"); + } + + // Clean up. + SDLNet_FreePacket(packet); + SDLNet_UDP_Close(udpSocket); + return true; +} + +bool CNetOutputs::RegisterClient(TCPsocket socket) +{ + // Check that given client is not already registered. + for (auto& client : m_clients) { + if (client.ClientSocket == socket) { + // If so, just send it current state of all outputs. + SendAllDataToClient(client); + return false; + } + } + + // Add the new client socket to the socket set. + SDLNet_TCP_AddSocket(m_tcpSocketSet, socket); + + // If not, store details about client and send it current state of all outputs. + RegisteredClientTcp client; + client.ClientSocket = socket; + m_clients.push_back(client); + + // Send the data to the new client. + if (!SendGameIdString(client)) { + ErrorLog("Failed to send game id string to client. Unregistering client."); + UnregisterClient(socket); + return false; + } + SendAllDataToClient(client); + + return true; +} + +void CNetOutputs::SendAllDataToClient(RegisteredClientTcp& client) +{ + // Loop through all known outputs and send their current state to given client. + for (unsigned i = 0; i < NUM_OUTPUTS; i++) { + EOutputs output = (EOutputs)i; + if (HasValue(output)) { + std::string name = GetOutputName(output); + std::string strvalue = std::to_string(GetValue(output)); + std::string line = name + m_separatorIdAndValue + strvalue + m_frameEnding; + if (!SendTcpData(client, line)) { + ErrorLog("Failed to send all output data to client. Unregistering client."); + UnregisterClient(client.ClientSocket); + break; + } + } + } +} + +bool CNetOutputs::UnregisterAllClients() +{ + for (auto& client : m_clients) { + UnregisterClient(client.ClientSocket); + } + m_clients.clear(); + return true; +} + +bool CNetOutputs::UnregisterClient(TCPsocket socket) +{ + // Find any matching clients and remove them. + bool found = false; + std::vector::iterator it = m_clients.begin(); + while (it != m_clients.end()) { + if (it->ClientSocket == socket) { + // Client found, send stop message and remove it. + SendStopString(*it, true); + it = m_clients.erase(it); + found = true; + // Remove from socket set and close the socket. + SDLNet_TCP_DelSocket(m_tcpSocketSet, socket); + SDLNet_TCP_Close(socket); + } else { + it++; + } + } + + // Return error if no matches found. + return found; +} + +bool CNetOutputs::SendGameIdString(RegisteredClientTcp& client) +{ + std::string line = "mame_start" + m_separatorIdAndValue + GetGame().name + m_frameEnding; + return SendTcpData(client, line); +} + +bool CNetOutputs::SendStopString(RegisteredClientTcp& client, bool stopped) +{ + std::string line = "mame_stop" + m_separatorIdAndValue + std::string((stopped ? "1" : "0")) + m_frameEnding; + return SendTcpData(client, line); +} + +bool CNetOutputs::SendPauseString(RegisteredClientTcp& client, bool paused) +{ + std::string line = "pause" + m_separatorIdAndValue + std::string((paused ? "1" : "0")) + m_frameEnding; + return SendTcpData(client, line); +} + +bool CNetOutputs::SendTcpData(RegisteredClientTcp& client, const std::string& data) +{ + auto sentDataLen = SDLNet_TCP_Send(client.ClientSocket, data.c_str(), data.length()); + DebugLog("Sent to client: %s", data.c_str()); + DebugLog("Bytes sent: %d", sentDataLen); + DebugLog("Expected bytes: %zu", data.length()); + // Check for errors in sending data. + if (sentDataLen < static_cast(data.length())) { + ErrorLog("Failed to send TCP data to client: %s (tried to send %zu bytes, sent %d bytes).", + SDLNet_GetError(), + data.length(), + sentDataLen + ); + return false; + } + return true; +} diff --git a/Src/OSD/SDL/NetOutputs.h b/Src/OSD/SDL/NetOutputs.h new file mode 100644 index 00000000..8ed7adb2 --- /dev/null +++ b/Src/OSD/SDL/NetOutputs.h @@ -0,0 +1,199 @@ +/** + ** Supermodel + ** A Sega Model 3 Arcade Emulator. + ** Copyright 2003-2026 The Supermodel Team + ** + ** This file is part of Supermodel. + ** + ** Supermodel is free software: you can redistribute it and/or modify it under + ** the terms of the GNU General Public License as published by the Free + ** Software Foundation, either version 3 of the License, or (at your option) + ** any later version. + ** + ** Supermodel is distributed in the hope that it will be useful, but WITHOUT + ** ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + ** FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + ** more details. + ** + ** You should have received a copy of the GNU General Public License along + ** with Supermodel. If not, see . + **/ + + /* + * NetOutputs.h + * + * Implementation of COutputs that sends MAMEHooker compatible messages via TCP and UDP. + */ + +#pragma once + +#include "Logger.h" +#include "Outputs.h" +#include "SDLIncludes.h" + +#include +#include +#include +#include +#include +#include +#include + +// Default values for configurable settings. +const unsigned int NET_OUTPUTS_DEFAULT_TCP_PORT = 8000; +const unsigned int NET_OUTPUTS_DEFAULT_UDP_BROADCAST_PORT = 8001; +const std::string NET_OUTPUTS_DEFAULT_FRAME_ENDING = std::string("\r"); +const std::string NET_OUTPUTS_DEFAULT_SEPARATOR_ID_AND_VALUE = std::string(" = "); + +// Maximum number of clients that can be registered with the emulator at once. +// This was chosen somewhat arbitrarily, but seems reasonable given the use +// case of MAMEHooker and similar tools. +const unsigned int NET_OUTPUTS_MAX_CLIENTS = 10; + +// Struct that represents a client (eg MAMEHooker) currently registered with the emulator. +struct RegisteredClientTcp +{ + TCPsocket ClientSocket; +}; + + +class CNetOutputs : public COutputs +{ +public: + /* + * CNetOutputs(): + * ~CNetOutputs(): + * + * Constructor and destructor. + */ + CNetOutputs(); + virtual ~CNetOutputs(); + + /* + * Initialize(): + * + * Initializes this class. + */ + bool Initialize(); + + /* + * Attached(): + * + * Lets the class know that it has been attached to the emulator. + */ + void Attached(); + + void SetFrameEnding(const std::string& ending) { m_frameEnding = ending; } + void SetTcpPort(unsigned int port) { m_tcpPort = port; } + void SetUdpBroadcastPort(unsigned int port) { m_udpBroadcastPort = port; } +protected: + /* + * SendOutput(): + * + * Sends the appropriate output message to all registered clients. + */ + void SendOutput(EOutputs output, UINT8 prevValue, UINT8 value); + +private: + // Configurable values. + unsigned int m_tcpPort = NET_OUTPUTS_DEFAULT_TCP_PORT; + unsigned int m_udpBroadcastPort = NET_OUTPUTS_DEFAULT_UDP_BROADCAST_PORT; + // Defaults to "\r", can also be "\r\n", see configuration. + std::string m_frameEnding = NET_OUTPUTS_DEFAULT_FRAME_ENDING; + + // Internal data. + std::string m_separatorIdAndValue = NET_OUTPUTS_DEFAULT_SEPARATOR_ID_AND_VALUE; + unsigned int m_maxClients = NET_OUTPUTS_MAX_CLIENTS; + std::atomic m_running = false; + std::vector m_clients{}; + TCPsocket m_serverSocket; + SDLNet_SocketSet m_tcpSocketSet; + std::thread m_tcpServerThread; + + /* + * CreateServerThread(): + * + * Wait for new client and handle it appropriately. + */ + bool CreateServerThread(); + + /* + * CreateTcpServerSocket(): + * + * Creates the TCP server socket. + */ + bool CreateTcpServerSocket(); + + /* + * DestroyTcpServerSocket(): + * + * Destroys the TCP server socket. + */ + void DestroyTcpServerSocket(); + + /* + * SendUdpBroadcastWithId(): + * + * Broadcasts a message over UDP to show that the emulator is running and to provide + * the TCP port number. + */ + bool SendUdpBroadcastWithId(); + + /* + * RegisterClient(hwnd, id): + * + * Registers a client (eg MAMEHooker) with the emulator. + */ + bool RegisterClient(TCPsocket socket); + + /* + * SendAllToClient(client): + * + * Sends the current state of all the outputs to the given registered client. + * Called whenever a client is registered with the emulator. + */ + void SendAllDataToClient(RegisteredClientTcp& client); + + /* + * UnregisterClient(hwnd, id): + * + * Unregisters a client from the emulator. + */ + bool UnregisterClient(TCPsocket socket); + + /* + * UnregisterAllClients(): + * + * Unregisters all clients from the emulator. + */ + bool UnregisterAllClients(); + + /* + * SendGameIdString(hwnd, id): + * + * Sends the name of the current running game. + */ + bool SendGameIdString(RegisteredClientTcp& client); + + /* + * SendStopString(hwnd, id): + * + * Sends the mame stops message. + */ + bool SendStopString(RegisteredClientTcp& client, bool stopped); + + /* + * SendPauseString(hwnd, id): + * + * Sends the name of the current running game. + */ + bool SendPauseString(RegisteredClientTcp& client, bool paused); + + /* + * SendTcpData(client, data): + * + * Sends the given data string to the given client over TCP. + */ + bool SendTcpData(RegisteredClientTcp& client, const std::string& data); +}; + diff --git a/VS2008/Supermodel.vcproj b/VS2008/Supermodel.vcproj index c59ce671..a91dc470 100755 --- a/VS2008/Supermodel.vcproj +++ b/VS2008/Supermodel.vcproj @@ -2099,6 +2099,10 @@ RelativePath="..\Src\OSD\SDL\Types.h" > + + + diff --git a/VS2008/Supermodel.vcxproj.filters b/VS2008/Supermodel.vcxproj.filters index 5da1e344..2d012976 100644 --- a/VS2008/Supermodel.vcxproj.filters +++ b/VS2008/Supermodel.vcxproj.filters @@ -637,6 +637,9 @@ Header Files\OSD\Windows + + Header Files\OSD\SDL + Header Files\Pkgs