From 06c4e1617bbe322baa6697dde4bb0de094f8d3e9 Mon Sep 17 00:00:00 2001
From: Jake <73198594+jaborsh@users.noreply.github.com>
Date: Sat, 9 May 2026 16:15:46 -0700
Subject: [PATCH] Surface inbound GMCP packets to the delegate
---
.gitignore | 1 +
Sources/SwiftMTH/TelnetSession.swift | 20 ++++
Sources/SwiftMTH/TelnetSessionDelegate.swift | 59 ++++++++++
Tests/MTHTests/TelnetSessionTests.swift | 112 +++++++++++++++++++
4 files changed, 192 insertions(+)
diff --git a/.gitignore b/.gitignore
index aab9b8d..6f2a65f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
kotlin/.gradle/
kotlin/**/build/
+.build/
diff --git a/Sources/SwiftMTH/TelnetSession.swift b/Sources/SwiftMTH/TelnetSession.swift
index e49c54d..a85f98a 100644
--- a/Sources/SwiftMTH/TelnetSession.swift
+++ b/Sources/SwiftMTH/TelnetSession.swift
@@ -738,6 +738,26 @@ public final class TelnetSession {
let sbLen = skipSB(src, at: offset, srclen: srclen)
if sbLen > srclen { return srclen + 1 }
+ // Surface raw module + JSON payload to the delegate before the MSDP
+ // fallback runs. SB framing: [IAC, SB, GMCP,
, IAC, SE].
+ if let delegate = delegate, sbLen >= 5 {
+ let bodyStart = offset + 3
+ let bodyEnd = offset + sbLen - 2
+ if bodyEnd > bodyStart {
+ var splitIdx = bodyStart
+ while splitIdx < bodyEnd && src[splitIdx] != UInt8(ascii: " ") {
+ splitIdx += 1
+ }
+ let module = String(decoding: src[bodyStart..(
+ _ type: T.Type,
+ decoder: JSONDecoder = JSONDecoder()
+ ) throws -> T? {
+ guard !payload.isEmpty else { return nil }
+ return try decoder.decode(type, from: payload)
+ }
+}
+
/// Delegate for `TelnetSession` to communicate with the host application.
///
/// Replaces the C `descriptor_data` extensibility pattern. The session calls
@@ -14,10 +52,31 @@ public protocol TelnetSessionDelegate: AnyObject {
/// Return MSSP (Mud Server Status Protocol) key-value pairs.
/// Called when the client requests MSSP data.
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)]
+
+ /// Inbound GMCP packet, surfaced as a `GMCPPacket` with the raw module
+ /// name plus its (possibly empty) JSON payload — the form most consumers
+ /// actually want for nested-object packages like `Char.Login.Credentials`.
+ ///
+ /// Fires for every well-formed inbound `IAC SB GMCP … IAC SE` block (those
+ /// with a non-empty body — empty SB GMCP blocks have no module name and
+ /// are skipped). Fires regardless of whether the host also has an
+ /// `MSDPManager` attached. The MSDP-flat-var fallback at `processSbGmcp`
+ /// still runs after the delegate returns, so existing flat-var consumers
+ /// (`Core.Hello`, etc.) keep working unchanged.
+ ///
+ /// To consume a packet as a typed value, use the packet's `decode(_:)`:
+ /// ```
+ /// guard packet.module == "Char.Login.Credentials",
+ /// let creds = try packet.decode(Credentials.self)
+ /// else { return }
+ /// // use creds
+ /// ```
+ func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket)
}
/// Default implementations for optional delegate methods.
public extension TelnetSessionDelegate {
func telnetSession(_ session: TelnetSession, log message: String) {}
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)] { [] }
+ func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket) {}
}
diff --git a/Tests/MTHTests/TelnetSessionTests.swift b/Tests/MTHTests/TelnetSessionTests.swift
index b643137..f10791b 100644
--- a/Tests/MTHTests/TelnetSessionTests.swift
+++ b/Tests/MTHTests/TelnetSessionTests.swift
@@ -1,3 +1,4 @@
+import Foundation
import Testing
import MTH
@@ -44,6 +45,7 @@ private final class FakeDelegate: TelnetSessionDelegate {
var writtenChunks: [[UInt8]] = []
var logMessages: [String] = []
var msspPairs: [(key: String, value: String)] = []
+ var gmcpPackets: [GMCPPacket] = []
var allWrittenBytes: [UInt8] { writtenChunks.flatMap { $0 } }
@@ -58,6 +60,10 @@ private final class FakeDelegate: TelnetSessionDelegate {
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)] {
msspPairs
}
+
+ func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket) {
+ gmcpPackets.append(packet)
+ }
}
private func makeSession() -> (TelnetSession, FakeDelegate) {
@@ -550,6 +556,112 @@ private func makeSession() -> (TelnetSession, FakeDelegate) {
#expect(!d.writtenChunks.isEmpty)
}
+@Test func sbGmcpDeliversModuleAndPayloadToDelegate() throws {
+ struct Credentials: Decodable, Equatable {
+ let account: String
+ let token: String
+ }
+
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ let body = Array("Char.Login.Credentials {\"account\":\"Jake\",\"token\":\"abc\"}".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ #expect(d.gmcpPackets.count == 1)
+ let received = try #require(d.gmcpPackets.first)
+ #expect(received.module == "Char.Login.Credentials")
+ #expect(try received.decode(Credentials.self)
+ == Credentials(account: "Jake", token: "abc"))
+}
+
+@Test func sbGmcpModuleOnlyPacketDecodesAsNil() throws {
+ struct AnyShape: Decodable {}
+
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ let body = Array("Core.Ping".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ let received = try #require(d.gmcpPackets.first)
+ #expect(received.module == "Core.Ping")
+ #expect(try received.decode(AnyShape.self) == nil)
+}
+
+@Test func sbGmcpEmptyBodyDoesNotCallDelegate() {
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ // Malformed but technically valid SB framing: zero-byte body.
+ let packet: [UInt8] = [IAC, SB, GMCP, IAC, SE]
+ _ = s.processInput(packet)
+
+ #expect(d.gmcpPackets.isEmpty)
+}
+
+@Test func sbGmcpLeadingSpaceProducesEmptyModuleAndIsSkipped() {
+ // A body that starts with a space would parse as module="" + payload=...,
+ // which contradicts the "module always non-empty" contract. Skip it.
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ let body = Array(" {\"x\":1}".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ #expect(d.gmcpPackets.isEmpty)
+}
+
+@Test func sbGmcpDecodeThrowsOnMalformedJson() {
+ struct Anything: Decodable { let x: Int }
+
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ let body = Array("Some.Module not-valid-json".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ let received = d.gmcpPackets.first!
+ #expect(throws: DecodingError.self) {
+ try received.decode(Anything.self)
+ }
+}
+
+@Test func sbGmcpByteCountReportsPayloadSize() {
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+
+ let json = "{\"x\":1}"
+ let body = Array("Some.Module \(json)".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ #expect(d.gmcpPackets.first?.byteCount == json.utf8.count)
+}
+
+@Test func sbGmcpStillRunsMsdpFallbackForFlatVarPackages() {
+ // The original `sbGmcpProcessesJsonCommand` exercises the flat-var path:
+ // an `MSDP {"LIST":"COMMANDS"}` packet should still produce a GMCP-JSON
+ // response from the MSDPManager. With the delegate hook added, that
+ // response must continue to fire.
+ let (s, d) = makeSession()
+ _ = s.processInput([IAC, DO, GMCP])
+ d.writtenChunks.removeAll()
+ d.gmcpPackets.removeAll()
+
+ let body = Array("MSDP {\"LIST\":\"COMMANDS\"}".utf8)
+ let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
+ _ = s.processInput(packet)
+
+ #expect(d.gmcpPackets.count == 1)
+ #expect(d.gmcpPackets.first?.module == "MSDP")
+ #expect(!d.writtenChunks.isEmpty)
+}
+
#if canImport(CZlib)
// MARK: - MCCP2 (Output Compression)