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