diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 7e3599dfb..72ef5ed2e 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -273,6 +273,8 @@ ConstString KEY_AUDIO_OUTPUT_DEVICE = "Audio output device"; ConstString KEY_MAXIMUM_NUMBER_OF_PATHS = "maximum number of paths"; ConstString KEY_MULTIPLE_CONNECTIONS_PENALTY = "multiple connections penalty"; ConstString KEY_MUME_START_EPOCH = "Mume start epoch"; +ConstString KEY_GMCP_BROADCAST_CLOCK = "GMCP broadcast clock"; +ConstString KEY_GMCP_BROADCAST_INTERVAL = "GMCP broadcast interval"; ConstString KEY_NUMBER_OF_ANTI_ALIASING_SAMPLES = "Number of anti-aliasing samples"; ConstString KEY_PROXY_CONNECTION_STATUS = "Proxy connection status"; ConstString KEY_PROXY_LISTENS_ON_ANY_INTERFACE = "Proxy listens on any interface"; @@ -752,6 +754,8 @@ void Configuration::MumeClockSettings::read(const QSettings &conf) // NOTE: old values might be stored as int32 startEpoch = conf.value(KEY_MUME_START_EPOCH, 1517443173).toLongLong(); display = conf.value(KEY_DISPLAY_CLOCK, true).toBool(); + gmcpBroadcast = conf.value(KEY_GMCP_BROADCAST_CLOCK, true).toBool(); + gmcpBroadcastInterval = conf.value(KEY_GMCP_BROADCAST_INTERVAL, 2500).toInt(); } void Configuration::AdventurePanelSettings::read(const QSettings &conf) @@ -933,6 +937,8 @@ void Configuration::MumeClockSettings::write(QSettings &conf) const // Note: There's no QVariant(int64_t) constructor. conf.setValue(KEY_MUME_START_EPOCH, static_cast(startEpoch)); conf.setValue(KEY_DISPLAY_CLOCK, display); + conf.setValue(KEY_GMCP_BROADCAST_CLOCK, gmcpBroadcast); + conf.setValue(KEY_GMCP_BROADCAST_INTERVAL, gmcpBroadcastInterval); } void Configuration::AdventurePanelSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index bd535c640..6ade92f19 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -311,6 +311,8 @@ class NODISCARD Configuration final { int64_t startEpoch = 0; bool display = false; + bool gmcpBroadcast = true; + int gmcpBroadcastInterval = 2500; private: SUBGROUP(); diff --git a/src/opengl/OpenGLProber.cpp b/src/opengl/OpenGLProber.cpp index c0160bf3b..64e76c499 100644 --- a/src/opengl/OpenGLProber.cpp +++ b/src/opengl/OpenGLProber.cpp @@ -13,6 +13,7 @@ #include #ifdef WIN32 +#include extern "C" { // Prefer discrete nVidia and AMD GPUs by default on Windows __declspec(dllexport) DWORD NvOptimusEnablement = 0x00000001; diff --git a/src/preferences/mumeprotocolpage.cpp b/src/preferences/mumeprotocolpage.cpp index b1016b09c..4d45d710d 100644 --- a/src/preferences/mumeprotocolpage.cpp +++ b/src/preferences/mumeprotocolpage.cpp @@ -30,6 +30,14 @@ MumeProtocolPage::MumeProtocolPage(QWidget *parent) &QAbstractButton::clicked, this, &MumeProtocolPage::slot_externalEditorBrowseButtonClicked); + connect(ui->gmcpBroadcastCheckBox, + &QCheckBox::stateChanged, + this, + &MumeProtocolPage::slot_gmcpBroadcastCheckBoxChanged); + connect(ui->gmcpIntervalSpinBox, + QOverload::of(&QSpinBox::valueChanged), + this, + &MumeProtocolPage::slot_gmcpIntervalSpinBoxChanged); } MumeProtocolPage::~MumeProtocolPage() @@ -49,6 +57,11 @@ void MumeProtocolPage::slot_loadConfig() if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { ui->externalEditorRadioButton->setDisabled(true); } + + const auto &clockSettings = getConfig().mumeClock; + ui->gmcpBroadcastCheckBox->setChecked(clockSettings.gmcpBroadcast); + ui->gmcpIntervalSpinBox->setValue(clockSettings.gmcpBroadcastInterval); + ui->gmcpIntervalSpinBox->setEnabled(clockSettings.gmcpBroadcast); } void MumeProtocolPage::slot_internalEditorRadioButtonChanged(bool /*unused*/) @@ -78,3 +91,15 @@ void MumeProtocolPage::slot_externalEditorBrowseButtonClicked(bool /*unused*/) command = quotedFileName; } } + +void MumeProtocolPage::slot_gmcpBroadcastCheckBoxChanged(int /*unused*/) +{ + const bool enabled = ui->gmcpBroadcastCheckBox->isChecked(); + setConfig().mumeClock.gmcpBroadcast = enabled; + ui->gmcpIntervalSpinBox->setEnabled(enabled); +} + +void MumeProtocolPage::slot_gmcpIntervalSpinBoxChanged(int value) +{ + setConfig().mumeClock.gmcpBroadcastInterval = value; +} diff --git a/src/preferences/mumeprotocolpage.h b/src/preferences/mumeprotocolpage.h index 583d528d9..d39411f93 100644 --- a/src/preferences/mumeprotocolpage.h +++ b/src/preferences/mumeprotocolpage.h @@ -31,4 +31,6 @@ public slots: void slot_internalEditorRadioButtonChanged(bool); void slot_externalEditorCommandTextChanged(QString); void slot_externalEditorBrowseButtonClicked(bool); + void slot_gmcpBroadcastCheckBoxChanged(int); + void slot_gmcpIntervalSpinBoxChanged(int); }; diff --git a/src/preferences/mumeprotocolpage.ui b/src/preferences/mumeprotocolpage.ui index e509334ef..1ed062d61 100644 --- a/src/preferences/mumeprotocolpage.ui +++ b/src/preferences/mumeprotocolpage.ui @@ -120,6 +120,51 @@ + + + + GMCP Clock Broadcasting + + + + + + Broadcast clock to connected clients (GMCP MUME.Time) + + + true + + + + + + + Update interval (ms): + + + + + + + How often to send clock updates to clients (default: 2500ms = 1 MUME minute) + + + 500 + + + 60000 + + + 500 + + + 2500 + + + + + + diff --git a/src/proxy/GmcpMessage.h b/src/proxy/GmcpMessage.h index 806e3b4a1..9e172e558 100644 --- a/src/proxy/GmcpMessage.h +++ b/src/proxy/GmcpMessage.h @@ -54,6 +54,7 @@ class ParseEvent; X(ROOM_CHARS_UPDATE, RoomCharsUpdate, "room.chars.update", "Room.Chars.Update") \ X(ROOM_INFO, RoomInfo, "room.info", "Room.Info") \ X(ROOM_UPDATE_EXITS, RoomUpdateExits, "room.update.exits", "Room.Update.Exits") \ + X(MUME_TIME_INFO, MumeTimeInfo, "mume.time.info", "MUME.Time.Info") \ /* define gmcp message types above */ enum class NODISCARD GmcpMessageTypeEnum { @@ -66,7 +67,7 @@ enum class NODISCARD GmcpMessageTypeEnum { #define X_COUNT(...) +1 static constexpr const size_t NUM_GMCP_MESSAGES = XFOREACH_GMCP_MESSAGE_TYPE(X_COUNT); #undef X_COUNT -static_assert(NUM_GMCP_MESSAGES == 30); +static_assert(NUM_GMCP_MESSAGES == 31); DEFINE_ENUM_COUNT(GmcpMessageTypeEnum, NUM_GMCP_MESSAGES) namespace tags { diff --git a/src/proxy/GmcpModule.h b/src/proxy/GmcpModule.h index 90f03d26e..9e42f7c51 100644 --- a/src/proxy/GmcpModule.h +++ b/src/proxy/GmcpModule.h @@ -23,6 +23,7 @@ X(MUME_CLIENT, MumeClient, "mume.client", "MUME.Client") \ X(ROOM_CHARS, RoomChars, "room.chars", "Room.Chars") \ X(ROOM, Room, "room", "Room") \ + X(MUME_TIME, MumeTime, "mume.time", "MUME.Time") \ /* define gmcp module types above */ enum class NODISCARD GmcpModuleTypeEnum { @@ -35,7 +36,7 @@ enum class NODISCARD GmcpModuleTypeEnum { #define X_COUNT(...) +1 static constexpr const size_t NUM_GMCP_MODULES = XFOREACH_GMCP_MODULE_TYPE(X_COUNT); #undef X_COUNT -static_assert(NUM_GMCP_MODULES == 7); +static_assert(NUM_GMCP_MODULES == 8); DEFINE_ENUM_COUNT(GmcpModuleTypeEnum, NUM_GMCP_MODULES) namespace tags { diff --git a/src/proxy/UserTelnet.cpp b/src/proxy/UserTelnet.cpp index e02e2fdb3..45f78c5ff 100644 --- a/src/proxy/UserTelnet.cpp +++ b/src/proxy/UserTelnet.cpp @@ -261,12 +261,14 @@ void UserTelnet::receiveGmcpModule(const GmcpModule &mod, const bool enabled) m_gmcp.modules.insert(mod); if (mod.isSupported()) { m_gmcp.supported[mod.getType()] = mod.getVersion(); + m_outputs.onGmcpModuleEnabled(mod.getType(), true); } } else { m_gmcp.modules.erase(mod); if (mod.isSupported()) { m_gmcp.supported[mod.getType()] = DEFAULT_GMCP_MODULE_VERSION; + m_outputs.onGmcpModuleEnabled(mod.getType(), false); } } } diff --git a/src/proxy/UserTelnet.h b/src/proxy/UserTelnet.h index 8cf7f4fbd..2692e5ea5 100644 --- a/src/proxy/UserTelnet.h +++ b/src/proxy/UserTelnet.h @@ -29,6 +29,10 @@ struct NODISCARD UserTelnetOutputs { virt_onRelayTermTypeFromUserToMud(bytes); } + void onGmcpModuleEnabled(const GmcpModuleTypeEnum type, const bool enabled) + { + virt_onGmcpModuleEnabled(type, enabled); + } private: virtual void virt_onAnalyzeUserStream(const RawBytes &, bool) = 0; @@ -36,6 +40,7 @@ struct NODISCARD UserTelnetOutputs virtual void virt_onRelayGmcpFromUserToMud(const GmcpMessage &) = 0; virtual void virt_onRelayNawsFromUserToMud(int, int) = 0; virtual void virt_onRelayTermTypeFromUserToMud(const TelnetTermTypeBytes &) = 0; + virtual void virt_onGmcpModuleEnabled(GmcpModuleTypeEnum, bool) = 0; }; class NODISCARD UserTelnet final : public AbstractTelnet diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 78a62f3bd..d78756c47 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -41,11 +41,14 @@ #include #include +#include +#include #include #include #include #include #include +#include using mmqt::makeQPointer; @@ -177,6 +180,8 @@ Proxy::~Proxy() sendNewlineToUser(); sendStatusToUser("MMapper proxy is shutting down."); + stopClockBroadcaster(); + { qDebug() << "disconnecting mud socket..."; getMudSocket().disconnectFromHost(); @@ -408,6 +413,16 @@ void Proxy::allocUserTelnet() // forwarded (to mud) getMudTelnet().onRelayTermType(bytes); } + void virt_onGmcpModuleEnabled(const GmcpModuleTypeEnum type, const bool enabled) final + { + if (type == GmcpModuleTypeEnum::MUME_TIME) { + if (enabled) { + getProxy().startClockBroadcaster(); + } else { + getProxy().stopClockBroadcaster(); + } + } + } }; auto &pipe = getPipeline(); @@ -884,6 +899,143 @@ void Proxy::gmcpToUser(const GmcpMessage &msg) getUserTelnet().onGmcpToUser(msg); } +namespace { + +NODISCARD constexpr const char *toJsonString(const MumeClockPrecisionEnum precision) +{ + switch (precision) { + case MumeClockPrecisionEnum::UNSET: return "unset"; + case MumeClockPrecisionEnum::DAY: return "day"; + case MumeClockPrecisionEnum::HOUR: return "hour"; + case MumeClockPrecisionEnum::MINUTE: return "minute"; + } + return "unknown"; +} + +NODISCARD constexpr const char *toJsonString(const MumeSeasonEnum season) +{ + switch (season) { + case MumeSeasonEnum::WINTER: return "winter"; + case MumeSeasonEnum::SPRING: return "spring"; + case MumeSeasonEnum::SUMMER: return "summer"; + case MumeSeasonEnum::AUTUMN: return "autumn"; + case MumeSeasonEnum::UNKNOWN: return "unknown"; + } + return "unknown"; +} + +NODISCARD constexpr const char *toJsonString(const MumeTimeEnum time) +{ + switch (time) { + case MumeTimeEnum::DAWN: return "dawn"; + case MumeTimeEnum::DAY: return "day"; + case MumeTimeEnum::DUSK: return "dusk"; + case MumeTimeEnum::NIGHT: return "night"; + case MumeTimeEnum::UNKNOWN: return "unknown"; + } + return "unknown"; +} + +NODISCARD constexpr const char *toJsonString(const MumeMoonPhaseEnum phase) +{ + switch (phase) { + case MumeMoonPhaseEnum::NEW_MOON: return "new_moon"; + case MumeMoonPhaseEnum::WAXING_CRESCENT: return "waxing_crescent"; + case MumeMoonPhaseEnum::FIRST_QUARTER: return "first_quarter"; + case MumeMoonPhaseEnum::WAXING_GIBBOUS: return "waxing_gibbous"; + case MumeMoonPhaseEnum::FULL_MOON: return "full_moon"; + case MumeMoonPhaseEnum::WANING_GIBBOUS: return "waning_gibbous"; + case MumeMoonPhaseEnum::THIRD_QUARTER: return "third_quarter"; + case MumeMoonPhaseEnum::WANING_CRESCENT: return "waning_crescent"; + case MumeMoonPhaseEnum::UNKNOWN: return "unknown"; + } + return "unknown"; +} + +NODISCARD constexpr const char *toJsonString(const MumeMoonVisibilityEnum visibility) +{ + switch (visibility) { + case MumeMoonVisibilityEnum::BRIGHT: return "bright"; + case MumeMoonVisibilityEnum::DIM: return "dim"; + case MumeMoonVisibilityEnum::INVISIBLE: return "invisible"; + case MumeMoonVisibilityEnum::UNKNOWN: return "unknown"; + } + return "unknown"; +} + +} // namespace + +void Proxy::startClockBroadcaster() +{ + const auto &config = getConfig(); + + if (!config.mumeClock.gmcpBroadcast + || !isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME)) { + stopClockBroadcaster(); + return; + } + + if (m_clockBroadcastTimer == nullptr) { + m_clockBroadcastTimer = new QTimer(this); + QObject::connect(m_clockBroadcastTimer, &QTimer::timeout, this, &Proxy::broadcastClockInfo); + } + + // Clamp interval to a sane range to prevent tight timer loops + static constexpr int MIN_INTERVAL_MS = 500; + static constexpr int MAX_INTERVAL_MS = 60000; + const int interval = std::clamp(config.mumeClock.gmcpBroadcastInterval, + MIN_INTERVAL_MS, + MAX_INTERVAL_MS); + m_clockBroadcastTimer->setInterval(interval); + m_clockBroadcastTimer->start(); + + // Send initial update immediately + broadcastClockInfo(); +} + +void Proxy::stopClockBroadcaster() +{ + if (m_clockBroadcastTimer != nullptr && m_clockBroadcastTimer->isActive()) { + m_clockBroadcastTimer->stop(); + } +} + +void Proxy::broadcastClockInfo() +{ + if (!getConfig().mumeClock.gmcpBroadcast + || !isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME)) { + return; + } + + const MumeMoment moment = m_mumeClock.getMumeMoment(); + const auto precision = m_mumeClock.getPrecision(); + const auto [dawnHour, duskHour] = MumeClock::getDawnDusk(moment.month); + + QJsonObject json; + json["year"] = moment.year; + json["month"] = moment.month; + json["day"] = moment.day; + json["hour"] = moment.hour; + json["minute"] = moment.minute; + + json["precision"] = toJsonString(precision); + json["season"] = toJsonString(moment.toSeason()); + json["timeOfDay"] = toJsonString(moment.toTimeOfDay()); + json["moonPhase"] = toJsonString(moment.moonPhase()); + json["moonVisibility"] = toJsonString(moment.moonVisibility()); + + json["moonLevel"] = moment.moonLevel(); + json["dawnHour"] = dawnHour; + json["duskHour"] = duskHour; + json["syncEpoch"] = static_cast(m_mumeClock.getLastSyncEpoch()); + + const QJsonDocument doc(json); + const QString jsonString = QString::fromUtf8(doc.toJson(QJsonDocument::Compact)); + + const GmcpMessage msg(GmcpMessageTypeEnum::MUME_TIME_INFO, GmcpJson{jsonString.toStdString()}); + gmcpToUser(msg); +} + void Proxy::sendToMud(const QString &s) { // REVISIT: this bypasses game observer, but it also appears to be unused. diff --git a/src/proxy/proxy.h b/src/proxy/proxy.h index 3bb78a08f..11213cb54 100644 --- a/src/proxy/proxy.h +++ b/src/proxy/proxy.h @@ -153,6 +153,9 @@ class NODISCARD_QOBJECT Proxy final : public QObject // it outlives this object when the connection closes. QPointer m_remoteEdit; + // Clock GMCP broadcaster + QTimer *m_clockBroadcastTimer = nullptr; + enum class NODISCARD ServerStateEnum { Initialized, Offline, @@ -227,6 +230,11 @@ class NODISCARD_QOBJECT Proxy final : public QObject void gmcpToMud(const GmcpMessage &msg); void gmcpToUser(const GmcpMessage &msg); +private: + void startClockBroadcaster(); + void stopClockBroadcaster(); + void broadcastClockInfo(); + private: void sendToMud(const QString &s); void sendToUser(SendToUserSourceEnum source, const QString &ba);