Skip to content

Add GMCP Clock Broadcasting Feature#475

Open
Halvance wants to merge 2 commits intoMUME:masterfrom
Halvance:Shimrod
Open

Add GMCP Clock Broadcasting Feature#475
Halvance wants to merge 2 commits intoMUME:masterfrom
Halvance:Shimrod

Conversation

@Halvance
Copy link

@Halvance Halvance commented Mar 19, 2026

GMCP Protocol Infrastructure:

GmcpModule.h - Added MUME_TIME module type (module count 7 → 8) GmcpMessage.h - Added MUME_TIME_INFO message type (message count 30 → 31)

Configuration:

configuration.h - Added gmcpBroadcast (default: true) and gmcpBroadcastInterval (default: 2500ms) to MumeClockSettings configuration.cpp - Added read/write persistence for the two new settings

Telnet Callback Pipeline:

UserTelnet.h - Added onGmcpModuleEnabled() / virt_onGmcpModuleEnabled() to UserTelnetOutputs interface UserTelnet.cpp - Calls m_outputs.onGmcpModuleEnabled() when a supported module is enabled/disabled

Proxy Broadcaster:

proxy.h - Added m_clockBroadcastTimer, startClockBroadcaster(), stopClockBroadcaster(), broadcastClockInfo() proxy.cpp - Full implementation:
virt_onGmcpModuleEnabled callback in LocalUserTelnetOutputs starts/stops broadcaster when MUME_TIME module is toggled startClockBroadcaster() creates a QTimer that fires at the configured interval broadcastClockInfo() builds a JSON payload with year, month, day, hour, minute, precision, season, timeOfDay, moonPhase, moonVisibility, moonLevel, dawnHour, duskHour, syncEpoch stopClockBroadcaster() in destructor for cleanup

Config Dialog (Mume Protocol Page):

mumeprotocolpage.h - Added slots for checkbox and spinbox mumeprotocolpage.cpp - Signal connections, config loading, and slot implementations mumeprotocolpage.ui - Added "GMCP Clock Broadcasting" group box with: Checkbox: "Broadcast clock to connected clients (GMCP MUME.Time)" SpinBox: Update interval 500-60000ms, step 500, default 2500

How it works:
Client connects and negotiates GMCP, requesting MUME.Time 1 UserTelnet detects the module enable and calls onGmcpModuleEnabled(MUME_TIME, true) Proxy's startClockBroadcaster() creates a timer that fires every 2500ms (configurable) Each tick builds a JSON object from MumeClock data and sends it as MUME.Time.Info via GMCP to the user client

Summary by Sourcery

Introduce configurable GMCP-based broadcasting of MUME clock information to connected clients and wire it into the GMCP module negotiation flow.

New Features:

  • Add MUME.Time GMCP module and MUME.Time.Info message for exposing in-game time metadata to clients.
  • Broadcast current MUME clock state over GMCP at a configurable interval when the client enables the MUME.Time module.
  • Expose UI controls to enable/disable GMCP clock broadcasting and adjust its update interval in the Mume Protocol preferences page.

Enhancements:

  • Extend UserTelnet output callbacks to notify when GMCP modules are enabled or disabled, allowing the proxy to react to module negotiation.
  • Persist GMCP clock broadcasting settings in configuration, including defaults for enablement and interval.
  • Ensure GMCP clock broadcasting timer is properly started, stopped, and cleaned up with the proxy lifecycle.

GMCP Protocol Infrastructure:

GmcpModule.h - Added MUME_TIME module type (module count 7 → 8)
GmcpMessage.h - Added MUME_TIME_INFO message type (message count 30 → 31)

Configuration:

configuration.h - Added gmcpBroadcast (default: true) and gmcpBroadcastInterval (default: 2500ms) to MumeClockSettings
configuration.cpp - Added read/write persistence for the two new settings

Telnet Callback Pipeline:

UserTelnet.h - Added onGmcpModuleEnabled() / virt_onGmcpModuleEnabled() to UserTelnetOutputs interface
UserTelnet.cpp - Calls m_outputs.onGmcpModuleEnabled() when a supported module is enabled/disabled

Proxy Broadcaster:

proxy.h - Added m_clockBroadcastTimer, startClockBroadcaster(), stopClockBroadcaster(), broadcastClockInfo()
proxy.cpp - Full implementation:
virt_onGmcpModuleEnabled callback in LocalUserTelnetOutputs starts/stops broadcaster when MUME_TIME module is toggled
startClockBroadcaster() creates a QTimer that fires at the configured interval
broadcastClockInfo() builds a JSON payload with year, month, day, hour, minute, precision, season, timeOfDay, moonPhase, moonVisibility, moonLevel, dawnHour, duskHour, syncEpoch
stopClockBroadcaster() in destructor for cleanup

Config Dialog (Mume Protocol Page):

mumeprotocolpage.h - Added slots for checkbox and spinbox
mumeprotocolpage.cpp - Signal connections, config loading, and slot implementations
mumeprotocolpage.ui - Added "GMCP Clock Broadcasting" group box with:
Checkbox: "Broadcast clock to connected clients (GMCP MUME.Time)"
SpinBox: Update interval 500-60000ms, step 500, default 2500

How it works:
Client connects and negotiates GMCP, requesting MUME.Time 1
UserTelnet detects the module enable and calls onGmcpModuleEnabled(MUME_TIME, true)
Proxy's startClockBroadcaster() creates a timer that fires every 2500ms (configurable)
Each tick builds a JSON object from MumeClock data and sends it as MUME.Time.Info via GMCP to the user client
@sourcery-ai
Copy link

sourcery-ai bot commented Mar 19, 2026

Reviewer's Guide

Implements a GMCP-based MUME clock broadcaster tied to the MUME.Time module, with periodic JSON payloads sent via a new MUME.Time.Info GMCP message, configuration persistence, and UI controls for enabling and tuning the broadcast interval, plus a small Windows OpenGL prober include fix.

Sequence diagram for GMCP MUME.Time enable and periodic clock broadcasting

sequenceDiagram
    actor Client
    participant UserTelnet
    participant Proxy
    participant MumeClock
    participant QTimer

    Client->>UserTelnet: Negotiate GMCP, enable module MUME.Time
    UserTelnet->>UserTelnet: receiveGmcpModule(MUME_TIME, true)
    UserTelnet->>Proxy: onGmcpModuleEnabled(MUME_TIME, true)
    Proxy->>Proxy: startClockBroadcaster()
    Proxy->>Proxy: getConfig().mumeClock
    Proxy->>Proxy: isUserGmcpModuleEnabled(MUME_TIME)
    alt Broadcasting enabled in config
        Proxy->>QTimer: create QTimer if null
        Proxy->>QTimer: setInterval(gmcpBroadcastInterval)
        Proxy->>QTimer: start()
        Proxy->>Proxy: broadcastClockInfo() initial
        Proxy->>MumeClock: getMumeMoment(), getPrecision(), getLastSyncEpoch()
        Proxy->>Proxy: build JSON payload (season, timeOfDay, moon data, dawn/dusk)
        Proxy->>Client: gmcpToUser(MUME.Time.Info JSON)
    else Broadcasting disabled in config
        Proxy-->>Proxy: Do not start timer
    end

    loop Every gmcpBroadcastInterval ms
        QTimer->>Proxy: timeout
        Proxy->>Proxy: broadcastClockInfo()
        Proxy->>Proxy: isUserGmcpModuleEnabled(MUME_TIME)
        alt Module still enabled
            Proxy->>MumeClock: getMumeMoment(), getPrecision(), getLastSyncEpoch()
            Proxy->>Proxy: rebuild JSON payload
            Proxy->>Client: gmcpToUser(MUME.Time.Info JSON)
        else Module disabled
            Proxy-->>Proxy: return without sending
        end
    end

    Client->>UserTelnet: Disable module MUME.Time
    UserTelnet->>Proxy: onGmcpModuleEnabled(MUME_TIME, false)
    Proxy->>Proxy: stopClockBroadcaster()
    Proxy->>QTimer: stop()
Loading

Updated class diagram for GMCP clock broadcasting and configuration

classDiagram
    class Proxy {
        QTimer* m_clockBroadcastTimer
        +gmcpToUser(GmcpMessage msg)
        -startClockBroadcaster()
        -stopClockBroadcaster()
        -broadcastClockInfo()
        -~Proxy()
    }

    class MumeClockSettings {
        int64_t startEpoch
        bool display
        bool gmcpBroadcast
        int gmcpBroadcastInterval
        +read(QSettings conf)
        +write(QSettings conf) const
    }

    class Configuration {
        +MumeClockSettings mumeClock
    }

    class MumeProtocolPage {
        +slot_loadConfig()
        +slot_internalEditorRadioButtonChanged(bool value)
        +slot_externalEditorCommandTextChanged(QString text)
        +slot_externalEditorBrowseButtonClicked(bool checked)
        +slot_gmcpBroadcastCheckBoxChanged(int state)
        +slot_gmcpIntervalSpinBoxChanged(int value)
    }

    class UserTelnetOutputs {
        +onAnalyzeUserStream(RawBytes bytes, bool fromUser)
        +onRelayGmcpFromUserToMud(GmcpMessage msg)
        +onRelayNawsFromUserToMud(int width, int height)
        +onRelayTermTypeFromUserToMud(TelnetTermTypeBytes bytes)
        +onGmcpModuleEnabled(GmcpModuleTypeEnum type, bool enabled)
        <<abstract>>
        -virt_onAnalyzeUserStream(RawBytes bytes, bool fromUser)
        -virt_onRelayGmcpFromUserToMud(GmcpMessage msg)
        -virt_onRelayNawsFromUserToMud(int width, int height)
        -virt_onRelayTermTypeFromUserToMud(TelnetTermTypeBytes bytes)
        -virt_onGmcpModuleEnabled(GmcpModuleTypeEnum type, bool enabled)
    }

    class UserTelnet {
        +receiveGmcpModule(GmcpModule mod, bool enabled)
        -UserTelnetOutputs m_outputs
    }

    class GmcpModuleTypeEnum {
        <<enumeration>>
        MUME_INFO
        CHAR
        CHAR_VITALS
        MUME_CLIENT
        ROOM_CHARS
        ROOM
        MUME_TIME
    }

    class GmcpMessageTypeEnum {
        <<enumeration>>
        CORE_HELLO
        CORE_SUPPORTS_SET
        CORE_SUPPORTS_ADD
        CORE_SUPPORTS_REMOVE
        CHAR_LOGIN
        CHAR_NAME
        ROOM_INFO
        ROOM_CHARS_UPDATE
        ROOM_UPDATE_EXITS
        MUME_TIME_INFO
    }

    class GmcpMessage {
        +GmcpMessage(GmcpMessageTypeEnum type, GmcpJson payload)
    }

    class MumeClock {
        +getMumeMoment() MumeMoment
        +getPrecision() MumeClockPrecisionEnum
        +getLastSyncEpoch() int64_t
        +static getDawnDusk(int month) std_pair_int_int
    }

    class MumeMoment {
        int year
        int month
        int day
        int hour
        int minute
        +toSeason() MumeSeasonEnum
        +toTimeOfDay() MumeTimeEnum
        +moonPhase() MumeMoonPhaseEnum
        +moonVisibility() MumeMoonVisibilityEnum
        +moonLevel() int
    }

    Proxy --> MumeClock : uses
    Proxy --> GmcpMessage : sends
    Proxy --> GmcpMessageTypeEnum : uses
    Proxy --> Configuration : reads mumeClock
    Configuration --> MumeClockSettings : aggregates

    MumeProtocolPage --> Configuration : reads_writes
    MumeProtocolPage --> MumeClockSettings : configures

    UserTelnet --> UserTelnetOutputs : has
    UserTelnetOutputs --> GmcpModuleTypeEnum : notifies
    UserTelnet --> GmcpModuleTypeEnum : uses

    GmcpMessage --> GmcpMessageTypeEnum : typed

    GmcpModuleTypeEnum <.. Proxy : checks MUME_TIME
    GmcpModuleTypeEnum <.. UserTelnetOutputs : reports MUME_TIME

    MumeClock --> MumeMoment : returns
Loading

File-Level Changes

Change Details Files
Wire GMCP module enable/disable events from the telnet layer into the proxy so it can react to MUME.Time being toggled.
  • Extend UserTelnetOutputs with an onGmcpModuleEnabled() wrapper and virtual hook.
  • Invoke onGmcpModuleEnabled() when GMCP modules are enabled/disabled and supported in UserTelnet::receiveGmcpModule.
  • Implement virt_onGmcpModuleEnabled in LocalUserTelnetOutputs to start/stop the clock broadcaster when the MUME_TIME module is toggled.
src/proxy/UserTelnet.h
src/proxy/UserTelnet.cpp
src/proxy/proxy.cpp
Add a GMCP clock broadcaster in Proxy that periodically sends MUME.Time.Info JSON messages based on the current MumeClock state.
  • Add a QTimer member to Proxy for periodic broadcasts and ensure it is stopped in the destructor.
  • Implement startClockBroadcaster() to check configuration and module enablement, initialize/configure/start the timer, and trigger an immediate initial broadcast.
  • Implement stopClockBroadcaster() to safely stop the timer if active.
  • Implement broadcastClockInfo() to read MumeClock state, derive season/time-of-day/moon data, serialize it into a QJsonObject/QJsonDocument, wrap it in a GmcpMessage of type MUME_TIME_INFO, and send it via gmcpToUser().
src/proxy/proxy.h
src/proxy/proxy.cpp
Extend the GMCP protocol definitions with a new MUME.Time module and MUME.Time.Info message type.
  • Add MUME_TIME to the GMCP module list and update the module count/static_assert.
  • Add MUME_TIME_INFO to the GMCP message list and update the message count/static_assert.
src/proxy/GmcpModule.h
src/proxy/GmcpMessage.h
Persist GMCP clock broadcasting configuration (enabled flag and interval) and expose it in the preferences UI.
  • Extend MumeClockSettings with gmcpBroadcast and gmcpBroadcastInterval fields and persist them via QSettings with new keys.
  • Load these settings into the MUME protocol preferences page, initializing checkbox and spinbox state and enabling/disabling the interval control based on the checkbox.
  • Add slots and signal connections so changes in the checkbox/spinbox update the configuration in memory.
src/configuration/configuration.h
src/configuration/configuration.cpp
src/preferences/mumeprotocolpage.h
src/preferences/mumeprotocolpage.cpp
src/preferences/mumeprotocolpage.ui
Fix Windows builds by including the Windows headers needed for GPU selection exports.
  • Include <windows.h> in OpenGLProber.cpp under WIN32 before exporting GPU preference symbols.
src/opengl/OpenGLProber.cpp

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • When the GMCP broadcast checkbox or interval is changed in the preferences while a session is active, the existing broadcaster timer is not restarted or stopped, so consider wiring these config changes to start/stop the timer and update its interval immediately instead of only reacting to module enable/disable.
  • The long series of switch blocks in broadcastClockInfo for mapping enums to string literals is verbose and error-prone to update; consider extracting reusable helper functions or lookup tables for these enum-to-string conversions to keep the logic more concise and centralized.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- When the GMCP broadcast checkbox or interval is changed in the preferences while a session is active, the existing broadcaster timer is not restarted or stopped, so consider wiring these config changes to start/stop the timer and update its interval immediately instead of only reacting to module enable/disable.
- The long series of switch blocks in broadcastClockInfo for mapping enums to string literals is verbose and error-prone to update; consider extracting reusable helper functions or lookup tables for these enum-to-string conversions to keep the logic more concise and centralized.

## Individual Comments

### Comment 1
<location path="src/proxy/proxy.cpp" line_range="906-924" />
<code_context>
+        QObject::connect(m_clockBroadcastTimer, &QTimer::timeout, this, &Proxy::broadcastClockInfo);
+    }
+
+    m_clockBroadcastTimer->setInterval(config.mumeClock.gmcpBroadcastInterval);
+    m_clockBroadcastTimer->start();
+
+    // Send initial update immediately
</code_context>
<issue_to_address>
**suggestion:** Consider validating or clamping the GMCP broadcast interval from configuration before using it as a QTimer interval.

If `gmcpBroadcastInterval` is 0 or negative (e.g., from manual config edits or legacy data), `QTimer` will either fire as fast as possible or not at all. It would be safer to clamp this to a sane min/max range before passing it to `setInterval()` instead of using the raw stored value.

```suggestion
    if (!config.mumeClock.gmcpBroadcast
        || !isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME)) {
        return;
    }

    if (m_clockBroadcastTimer == nullptr) {
        m_clockBroadcastTimer = new QTimer(this);
        QObject::connect(m_clockBroadcastTimer, &QTimer::timeout, this, &Proxy::broadcastClockInfo);
    }

    // Clamp GMCP broadcast interval to a sane range before using it as a QTimer interval.
    // This protects against 0/negative or excessively large values from configuration.
    constexpr int kMinGmcpBroadcastIntervalMs = 1000;   // 1 second
    constexpr int kMaxGmcpBroadcastIntervalMs = 60000;  // 60 seconds

    int intervalMs = config.mumeClock.gmcpBroadcastInterval;
    if (intervalMs < kMinGmcpBroadcastIntervalMs) {
        intervalMs = kMinGmcpBroadcastIntervalMs;
    } else if (intervalMs > kMaxGmcpBroadcastIntervalMs) {
        intervalMs = kMaxGmcpBroadcastIntervalMs;
    }

    m_clockBroadcastTimer->setInterval(intervalMs);
    m_clockBroadcastTimer->start();

    // Send initial update immediately
    broadcastClockInfo();
}

void Proxy::stopClockBroadcaster()
{
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • broadcastClockInfo() only checks whether the module is enabled; consider also checking the gmcpBroadcast setting there so that disabling broadcasting in the configuration immediately stops sending updates even if the timer is still running.
  • broadcastClockInfo() has several long switch blocks that map enums to string literals; consider extracting these mappings into small helper functions or lookup tables to reduce repetition and make it easier to keep the string representations consistent across the codebase.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- broadcastClockInfo() only checks whether the module is enabled; consider also checking the gmcpBroadcast setting there so that disabling broadcasting in the configuration immediately stops sending updates even if the timer is still running.
- broadcastClockInfo() has several long switch blocks that map enums to string literals; consider extracting these mappings into small helper functions or lookup tables to reduce repetition and make it easier to keep the string representations consistent across the codebase.

## Individual Comments

### Comment 1
<location path="src/proxy/proxy.cpp" line_range="916" />
<code_context>
+        QObject::connect(m_clockBroadcastTimer, &QTimer::timeout, this, &Proxy::broadcastClockInfo);
+    }
+
+    m_clockBroadcastTimer->setInterval(config.mumeClock.gmcpBroadcastInterval);
+    m_clockBroadcastTimer->start();
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against very small/zero GMCP broadcast intervals to avoid a potential tight timer loop.

If `gmcpBroadcastInterval` is zero or extremely small (e.g. from a malformed/edited config), `QTimer::setInterval` will effectively fire as fast as the event loop allows, causing a busy loop and high CPU usage. Consider clamping to a sane minimum (e.g. `qMax(100, gmcpBroadcastInterval)` or at least `1`), or enforcing this when loading the configuration.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Interval clamping (proxy.cpp:983-987): std::clamp bounds the interval to 500-60000ms, preventing tight timer loops from corrupt/manual config edits.

Runtime config check (proxy.cpp:1005): broadcastClockInfo() now checks gmcpBroadcast config setting, so disabling broadcast in preferences immediately halts updates even if the timer is still running.

Stop on config disable (proxy.cpp:908): startClockBroadcaster() now calls stopClockBroadcaster() when the config is disabled, so re-evaluating the broadcaster properly stops it.

Enum-to-string helpers (proxy.cpp:902-968): Extracted 5 constexpr toJsonString() overloads in an anonymous namespace, replacing ~80 lines of verbose switch/break blocks with clean one-liners in broadcastClockInfo().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants