From b21681140525f0c651b48385df9667e98653efc5 Mon Sep 17 00:00:00 2001 From: MaikCZE Date: Thu, 16 Jul 2026 16:45:50 +0200 Subject: [PATCH 1/3] Fix translate feature: add settings UI, outgoing translation, and a working default backend - FreeTranslateAPI's hosting (ftapi.pythonanywhere.com) is defunct, so the default backend never worked. Add a new "google" backend that talks directly to the free, unofficial Google Translate endpoint FTAPI itself used to wrap, and make it the default. - Add a full Translate section to the TClient settings menu (previously the feature was config-var only, with no UI at all): backend/language dropdowns, incoming auto-translate toggle, and a LibreTranslate endpoint/key section shown only when relevant. - Add outgoing translation: optionally translate your own messages into another language before sending, via a new tc_translate_outgoing / tc_translate_outgoing_target config pair and CTranslate::TranslateOutgoing(). Falls back to sending the original text if translation fails, and skips "/"-prefixed messages so it doesn't mangle commands like /w. --- src/engine/shared/config_variables_tclient.h | 4 +- src/game/client/components/chat.cpp | 9 +- .../components/tclient/menus_tclient.cpp | 111 +++++++++++++ .../client/components/tclient/translate.cpp | 147 ++++++++++++++++-- .../client/components/tclient/translate.h | 8 + 5 files changed, 268 insertions(+), 11 deletions(-) diff --git a/src/engine/shared/config_variables_tclient.h b/src/engine/shared/config_variables_tclient.h index 837e43d0604..5706f4118b7 100644 --- a/src/engine/shared/config_variables_tclient.h +++ b/src/engine/shared/config_variables_tclient.h @@ -227,11 +227,13 @@ MACRO_CONFIG_COL(TcBgDrawColor, tc_bg_draw_color, 14024576, CFGFLAG_CLIENT | CFG MACRO_CONFIG_INT(TcBgDrawAutoSaveLoad, tc_bg_draw_auto_save_load, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically save and load background drawings") // Translate -MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "ftapi", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backends (ftapi, libretranslate)") +MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "google", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backends (google, ftapi, libretranslate). ftapi's hosting is defunct as of 2026, kept for people running their own instance") MACRO_CONFIG_STR(TcTranslateTarget, tc_translate_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate target language (must be 2 character ISO 639 code)") MACRO_CONFIG_STR(TcTranslateEndpoint, tc_translate_endpoint, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, endpoint to use (must be https)") MACRO_CONFIG_STR(TcTranslateKey, tc_translate_key, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, api key to use") MACRO_CONFIG_INT(TcTranslateAuto, tc_translate_auto, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically translate messages, only some backends support this (FTApi does not)") +MACRO_CONFIG_INT(TcTranslateOutgoing, tc_translate_outgoing, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate your own outgoing chat messages into another language before sending them") +MACRO_CONFIG_STR(TcTranslateOutgoingTarget, tc_translate_outgoing_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Language to translate your outgoing messages into (must be 2 character ISO 639 code)") // Animations MACRO_CONFIG_INT(TcAnimateWheelTime, tc_animate_wheel_time, 80, 0, 1000, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Duration of emote and bind wheel animations, in milliseconds (0 == no animation, 1000 = 1 second)") diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp index b6d811a666e..006ec95d71f 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -277,7 +277,14 @@ bool CChat::OnInput(const IInput::CEvent &Event) else if(GameClient()->m_TClient.ChatDoSpecId(m_Input.GetString())) ; // Do nothing as specid was executed else - SendChatQueued(m_Input.GetString()); + { + const char *pMessage = m_Input.GetString(); + // Don't translate server commands (e.g. "/w name msg"), translating would break their syntax + if(g_Config.m_TcTranslateOutgoing && pMessage[0] != '\0' && pMessage[0] != '/') + GameClient()->m_Translate.TranslateOutgoing(m_Mode == MODE_TEAM ? 1 : 0, pMessage); + else + SendChatQueued(pMessage); + } m_pHistoryEntry = nullptr; DisableMode(); GameClient()->OnRelease(); diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 65329f39a6c..7c016a1a965 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1091,6 +1091,117 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) Ui()->DoEditBox(&s_FinishName, &Button, EditBoxFontSize); s_SectionBoxes.back().h = Column.y - s_SectionBoxes.back().y; + // ***** Translate ***** // + Column.HSplitTop(MarginBetweenSections, nullptr, &Column); + s_SectionBoxes.push_back(Column); + Column.HSplitTop(HeadlineHeight, &Label, &Column); + Ui()->DoLabel(&Label, TCLocalize("Translate"), HeadlineFontSize, TEXTALIGN_ML); + Column.HSplitTop(MarginSmall, nullptr, &Column); + + static const char *s_apTranslateLanguageNames[] = { + "Arabic", "Chinese (Simplified)", "Chinese (Traditional)", "Czech", "Dutch", "English", + "Finnish", "French", "German", "Greek", "Hebrew", "Hindi", "Hungarian", "Italian", + "Japanese", "Korean", "Polish", "Portuguese", "Romanian", "Russian", "Spanish", "Swedish", + "Thai", "Turkish", "Ukrainian", "Vietnamese"}; + static const char *s_apTranslateLanguageCodes[] = { + "ar", "zh", "zh-TW", "cs", "nl", "en", + "fi", "fr", "de", "el", "he", "hi", "hu", "it", + "ja", "ko", "pl", "pt", "ro", "ru", "es", "sv", + "th", "tr", "uk", "vi"}; + const int NumTranslateLanguages = (int)std::size(s_apTranslateLanguageNames); + static_assert(std::size(s_apTranslateLanguageNames) == std::size(s_apTranslateLanguageCodes), "language name/code list length mismatch"); + auto TranslateLanguageIndex = [&](const char *pCode) { + for(int i = 0; i < NumTranslateLanguages; i++) + if(str_comp_nocase(pCode, s_apTranslateLanguageCodes[i]) == 0) + return i; + return 5; // Fall back to English if unset or a custom code we don't have a name for + }; + + { + static std::vector s_TranslateBackendNames = {"Google", "FreeTranslateAPI (defunct)", "LibreTranslate"}; + static const char *s_apTranslateBackendValues[] = {"google", "ftapi", "libretranslate"}; + static CUi::SDropDownState s_TranslateBackendDropDownState; + static CScrollRegion s_TranslateBackendDropDownScrollRegion; + s_TranslateBackendDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_TranslateBackendDropDownScrollRegion; + int BackendSelectedOld = 0; + for(int i = 0; i < (int)std::size(s_apTranslateBackendValues); i++) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[i]) == 0) + BackendSelectedOld = i; + } + CUIRect BackendDropDownRect; + Column.HSplitTop(LineSize, &BackendDropDownRect, &Column); + BackendDropDownRect.VSplitLeft(120.0f, &Label, &BackendDropDownRect); + Ui()->DoLabel(&Label, TCLocalize("Backend: "), FontSize, TEXTALIGN_ML); + const int BackendSelectedNew = Ui()->DoDropDown(&BackendDropDownRect, BackendSelectedOld, s_TranslateBackendNames.data(), s_TranslateBackendNames.size(), s_TranslateBackendDropDownState); + if(BackendSelectedOld != BackendSelectedNew) + str_copy(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[BackendSelectedNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateAuto, TCLocalize("Automatically translate incoming messages"), &g_Config.m_TcTranslateAuto, &Column, LineSize); + if(g_Config.m_TcTranslateAuto && str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) + { + Column.HSplitTop(LineSize, &Label, &Column); + Ui()->DoLabel(&Label, TCLocalize("FreeTranslateAPI does not support automatic translation, switch to Google or LibreTranslate"), FontSize * 0.8f, TEXTALIGN_ML); + } + CUIRect IncomingTargetBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &IncomingTargetBox, &Column); + if(g_Config.m_TcTranslateAuto) + { + IncomingTargetBox.HSplitTop(MarginExtraSmall, nullptr, &IncomingTargetBox); + IncomingTargetBox.VSplitMid(&Label, &IncomingTargetBox); + Ui()->DoLabel(&Label, TCLocalize("Translate incoming to:"), FontSize, TEXTALIGN_ML); + static CUi::SDropDownState s_IncomingLangDropDownState; + static CScrollRegion s_IncomingLangDropDownScrollRegion; + s_IncomingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_IncomingLangDropDownScrollRegion; + const int IncomingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateTarget); + const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_IncomingLangDropDownState); + if(IncomingLangOld != IncomingLangNew) + str_copy(g_Config.m_TcTranslateTarget, s_apTranslateLanguageCodes[IncomingLangNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateOutgoing, TCLocalize("Translate your outgoing messages"), &g_Config.m_TcTranslateOutgoing, &Column, LineSize); + CUIRect OutgoingTargetBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &OutgoingTargetBox, &Column); + if(g_Config.m_TcTranslateOutgoing) + { + OutgoingTargetBox.HSplitTop(MarginExtraSmall, nullptr, &OutgoingTargetBox); + OutgoingTargetBox.VSplitMid(&Label, &OutgoingTargetBox); + Ui()->DoLabel(&Label, TCLocalize("Translate outgoing to:"), FontSize, TEXTALIGN_ML); + static CUi::SDropDownState s_OutgoingLangDropDownState; + static CScrollRegion s_OutgoingLangDropDownScrollRegion; + s_OutgoingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_OutgoingLangDropDownScrollRegion; + const int OutgoingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateOutgoingTarget); + const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_OutgoingLangDropDownState); + if(OutgoingLangOld != OutgoingLangNew) + str_copy(g_Config.m_TcTranslateOutgoingTarget, s_apTranslateLanguageCodes[OutgoingLangNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + { + CUIRect EndpointBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &EndpointBox, &Column); + EndpointBox.VSplitMid(&Label, &EndpointBox); + Ui()->DoLabel(&Label, TCLocalize("LibreTranslate endpoint:"), FontSize, TEXTALIGN_ML); + static CLineInput s_TranslateEndpoint(g_Config.m_TcTranslateEndpoint, sizeof(g_Config.m_TcTranslateEndpoint)); + s_TranslateEndpoint.SetEmptyText("localhost:5000/translate"); + Ui()->DoEditBox(&s_TranslateEndpoint, &EndpointBox, EditBoxFontSize); + Column.HSplitTop(MarginExtraSmall, nullptr, &Column); + + CUIRect KeyBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &KeyBox, &Column); + KeyBox.VSplitMid(&Label, &KeyBox); + Ui()->DoLabel(&Label, TCLocalize("LibreTranslate API key:"), FontSize, TEXTALIGN_ML); + static CLineInput s_TranslateKey(g_Config.m_TcTranslateKey, sizeof(g_Config.m_TcTranslateKey)); + s_TranslateKey.SetEmptyText(TCLocalize("Optional")); + Ui()->DoEditBox(&s_TranslateKey, &KeyBox, EditBoxFontSize); + } + Column.HSplitTop(MarginExtraSmall, nullptr, &Column); + s_SectionBoxes.back().h = Column.y - s_SectionBoxes.back().y; + // ***** END OF PAGE 1 SETTINGS ***** // RightView = Column; diff --git a/src/game/client/components/tclient/translate.cpp b/src/game/client/components/tclient/translate.cpp index 75be055b7a8..3d31c3ae96b 100644 --- a/src/game/client/components/tclient/translate.cpp +++ b/src/game/client/components/tclient/translate.cpp @@ -196,7 +196,7 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp { return "LibreTranslate"; } - CTranslateBackendLibretranslate(IHttp &Http, const char *pText) + CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang) { CJsonStringWriter Json = CJsonStringWriter(); Json.BeginObject(); @@ -205,7 +205,7 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp Json.WriteAttribute("source"); Json.WriteStrValue("auto"); Json.WriteAttribute("target"); - Json.WriteStrValue(EncodeTarget(g_Config.m_TcTranslateTarget)); + Json.WriteStrValue(EncodeTarget(pTargetLang)); Json.WriteAttribute("format"); Json.WriteStrValue("text"); if(g_Config.m_TcTranslateKey[0] != '\0') @@ -289,12 +289,12 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp { return "FreeTranslateAPI"; } - CTranslateBackendFtapi(IHttp &Http, const char *pText) + CTranslateBackendFtapi(IHttp &Http, const char *pText, const char *pTargetLang) { char aBuf[4096]; str_format(aBuf, sizeof(aBuf), "%s/translate?dl=%s&text=", g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://ftapi.pythonanywhere.com", - EncodeTarget(g_Config.m_TcTranslateTarget)); + EncodeTarget(pTargetLang)); UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); @@ -302,6 +302,95 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp } }; +// Talks directly to Google's free, unofficial (undocumented) translate endpoint. +// This is the same endpoint FreeTranslateAPI (ftapi.pythonanywhere.com, now dead) used to wrap. +class CTranslateBackendGoogle : public ITranslateBackendHttp +{ +private: + bool ParseResponseJson(const json_value *pObj, CTranslateResponse &Out) + { + if(!pObj) + { + str_copy(Out.m_Text, "Response is not JSON"); + return false; + } + if(pObj->type != json_array || json_array_length(pObj) < 3) + { + str_copy(Out.m_Text, "Unexpected response format"); + return false; + } + + const json_value *pSentences = json_array_get(pObj, 0); + if(!pSentences || pSentences->type != json_array) + { + str_copy(Out.m_Text, "No translated sentences"); + return false; + } + + char aText[sizeof(Out.m_Text)] = ""; + const int NumSentences = json_array_length(pSentences); + for(int i = 0; i < NumSentences; i++) + { + const json_value *pSentence = json_array_get(pSentences, i); + if(!pSentence || pSentence->type != json_array || json_array_length(pSentence) < 1) + continue; + const json_value *pChunk = json_array_get(pSentence, 0); + if(!pChunk || pChunk->type != json_string) + continue; + str_append(aText, pChunk->u.string.ptr); + } + if(aText[0] == '\0') + { + str_copy(Out.m_Text, "Empty translation"); + return false; + } + + const json_value *pLanguage = json_array_get(pObj, 2); + if(pLanguage && pLanguage->type == json_string) + str_copy(Out.m_Language, pLanguage->u.string.ptr); + + str_copy(Out.m_Text, aText); + return true; + } + +protected: + bool ParseResponse(CTranslateResponse &Out) override + { + json_value *pObj = m_pHttpRequest->ResultJson(); + bool Res = ParseResponseJson(pObj, Out); + json_value_free(pObj); + return Res; + } + +public: + const char *Name() const override + { + return "Google"; + } + CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang) + { + char aBuf[4096]; + str_format(aBuf, sizeof(aBuf), "%s?client=gtx&sl=auto&dt=t&tl=%s&q=", + g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://translate.googleapis.com/translate_a/single", + EncodeTarget(pTargetLang)); + + UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); + + CreateHttpRequest(Http, aBuf); + } +}; + +static std::unique_ptr CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang) +{ + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + return std::make_unique(Http, pText, pTargetLang); + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) + return std::make_unique(Http, pText, pTargetLang); + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "google") == 0) + return std::make_unique(Http, pText, pTargetLang); + return nullptr; +} + void CTranslate::ConTranslate(IConsole::IResult *pResult, void *pUserData) { const char *pName; @@ -398,11 +487,8 @@ void CTranslate::Translate(CChat::CLine &Line, bool ShowProgress) Job.m_pTranslateResponse = std::make_shared(); Job.m_pLine->m_pTranslateResponse = Job.m_pTranslateResponse; - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) - Job.m_pBackend = std::make_unique(*Http(), Job.m_pLine->m_aText); - else if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) - Job.m_pBackend = std::make_unique(*Http(), Job.m_pLine->m_aText); - else + Job.m_pBackend = CreateTranslateBackend(*Http(), Job.m_pLine->m_aText, g_Config.m_TcTranslateTarget); + if(!Job.m_pBackend) { GameClient()->m_Chat.Echo("Invalid translate backend"); return; @@ -428,6 +514,23 @@ void CTranslate::OnRender() { const auto Time = time(); auto ForEach = [&](CTranslateJob &Job) { + if(Job.m_IsOutgoing) + { + const std::optional Done = Job.m_pBackend->Update(*Job.m_pTranslateResponse); + if(!Done.has_value()) + return false; // Keep ongoing tasks + if(*Done && Job.m_pTranslateResponse->m_Text[0] != '\0') + GameClient()->m_Chat.SendChat(Job.m_OutgoingTeam, Job.m_pTranslateResponse->m_Text); + else + { + // Translation failed (or came back empty): don't swallow the message, send it untranslated + char aBuf[sizeof(Job.m_pTranslateResponse->m_Text) + 64]; + str_format(aBuf, sizeof(aBuf), TCLocalize("Translating your message failed, sending it untranslated: %s", "translate"), Job.m_pTranslateResponse->m_Text); + GameClient()->m_Chat.Echo(aBuf); + GameClient()->m_Chat.SendChat(Job.m_OutgoingTeam, Job.m_aOutgoingOriginalText); + } + return true; + } if(Job.m_pLine->m_pTranslateResponse != Job.m_pTranslateResponse) return true; // Not the same line anymore const std::optional Done = Job.m_pBackend->Update(*Job.m_pTranslateResponse); @@ -471,3 +574,29 @@ void CTranslate::AutoTranslate(CChat::CLine &Line) } Translate(Line, false); } + +void CTranslate::TranslateOutgoing(int Team, const char *pText) +{ + if(m_vJobs.size() > 15) + { + // Too many jobs in flight, don't make the player wait: send untranslated + GameClient()->m_Chat.SendChat(Team, pText); + return; + } + + CTranslateJob Job; + Job.m_IsOutgoing = true; + Job.m_OutgoingTeam = Team; + str_copy(Job.m_aOutgoingOriginalText, pText); + Job.m_pTranslateResponse = std::make_shared(); + + Job.m_pBackend = CreateTranslateBackend(*Http(), pText, g_Config.m_TcTranslateOutgoingTarget); + if(!Job.m_pBackend) + { + GameClient()->m_Chat.Echo("Invalid translate backend"); + GameClient()->m_Chat.SendChat(Team, pText); + return; + } + + m_vJobs.emplace_back(std::move(Job)); +} diff --git a/src/game/client/components/tclient/translate.h b/src/game/client/components/tclient/translate.h index d3e14a000b5..e14a35a365d 100644 --- a/src/game/client/components/tclient/translate.h +++ b/src/game/client/components/tclient/translate.h @@ -29,6 +29,10 @@ class CTranslate : public CComponent // For chat translations CChat::CLine *m_pLine = nullptr; std::shared_ptr m_pTranslateResponse = nullptr; + // For outgoing translations (translating our own message before sending it) + bool m_IsOutgoing = false; + int m_OutgoingTeam = 0; + char m_aOutgoingOriginalText[256] = ""; }; std::vector m_vJobs; @@ -46,6 +50,10 @@ class CTranslate : public CComponent void Translate(CChat::CLine &Line, bool ShowProgress = true); void AutoTranslate(CChat::CLine &Line); + + // Translates pText into g_Config.m_TcTranslateOutgoingTarget, then sends the + // result (or the original text if translation fails) as a chat message. + void TranslateOutgoing(int Team, const char *pText); }; #endif From cc872531084b8047440b4da7a109e0a6d07436a4 Mon Sep 17 00:00:00 2001 From: MaikCZE Date: Mon, 17 Aug 2026 19:33:15 +0200 Subject: [PATCH 2/3] Fix translate feature: address PR #203 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto master_new (per SollyBunny's review) and addressed every requested change from the original review: - Google backend now POSTs client/target-language/text in the request body (application/x-www-form-urlencoded) instead of building a long query-string URL -- keeps the fixed 256-byte CHttpRequest::m_aUrl buffer well within bounds regardless of message length. - Removed the FreeTranslateAPI (ftapi) backend entirely, including its stale "hosting is dead" comment and its ftapi-specific auto-translate guard -- anyone who wants a self-hosted option already has LibreTranslate. - Backend display names are no longer duplicated between the backend classes and the settings-menu dropdown: each backend exposes a StaticName()/StaticValue(), and a single g_aTranslateBackends[] table (translate.h/.cpp) is now the one source of truth for both dispatch and the menu. - Removed the magic-number English fallback (previously a hardcoded `return 5`): TranslateLanguageIndex() now finds English by comparing language codes, so it stays correct regardless of list order/edits. - Language dropdowns are now backend-scoped instead of one shared list for every backend, since Google genuinely supports far more languages than a typical self-hosted LibreTranslate instance. - Outgoing-translate is now CTranslate::ChatDoTranslateOutgoing(), which returns bool and is chained as another `else if` in chat.cpp's ChatDoBinds/ChatDoSpecId cascade instead of a separate nested if/else block -- matches the existing pattern and keeps the chat.cpp diff to one line. - Simplified the "too many jobs in flight" comment that read oddly (dropped the "don't make the player wait"/game-vs-chat phrasing). Compiled clean with MSVC 19.51 (Release, Ninja) -- full DDNet.exe build, zero errors/warnings from the touched files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/engine/shared/config_variables_tclient.h | 4 +- src/game/client/components/chat.cpp | 11 +- .../components/tclient/menus_tclient.cpp | 75 +++---- .../client/components/tclient/translate.cpp | 206 ++++++++---------- .../client/components/tclient/translate.h | 43 +++- 5 files changed, 171 insertions(+), 168 deletions(-) diff --git a/src/engine/shared/config_variables_tclient.h b/src/engine/shared/config_variables_tclient.h index 5706f4118b7..1e160936847 100644 --- a/src/engine/shared/config_variables_tclient.h +++ b/src/engine/shared/config_variables_tclient.h @@ -227,11 +227,11 @@ MACRO_CONFIG_COL(TcBgDrawColor, tc_bg_draw_color, 14024576, CFGFLAG_CLIENT | CFG MACRO_CONFIG_INT(TcBgDrawAutoSaveLoad, tc_bg_draw_auto_save_load, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically save and load background drawings") // Translate -MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "google", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backends (google, ftapi, libretranslate). ftapi's hosting is defunct as of 2026, kept for people running their own instance") +MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "google", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backend to use (google, libretranslate)") MACRO_CONFIG_STR(TcTranslateTarget, tc_translate_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate target language (must be 2 character ISO 639 code)") MACRO_CONFIG_STR(TcTranslateEndpoint, tc_translate_endpoint, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, endpoint to use (must be https)") MACRO_CONFIG_STR(TcTranslateKey, tc_translate_key, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, api key to use") -MACRO_CONFIG_INT(TcTranslateAuto, tc_translate_auto, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically translate messages, only some backends support this (FTApi does not)") +MACRO_CONFIG_INT(TcTranslateAuto, tc_translate_auto, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically translate incoming messages") MACRO_CONFIG_INT(TcTranslateOutgoing, tc_translate_outgoing, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate your own outgoing chat messages into another language before sending them") MACRO_CONFIG_STR(TcTranslateOutgoingTarget, tc_translate_outgoing_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Language to translate your outgoing messages into (must be 2 character ISO 639 code)") diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp index 006ec95d71f..758d9c6c04c 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -276,15 +276,10 @@ bool CChat::OnInput(const IInput::CEvent &Event) ; // Do nothing as bindchat was executed else if(GameClient()->m_TClient.ChatDoSpecId(m_Input.GetString())) ; // Do nothing as specid was executed + else if(GameClient()->m_Translate.ChatDoTranslateOutgoing(m_Mode == MODE_TEAM ? 1 : 0, m_Input.GetString())) + ; // Do nothing as outgoing translate was queued else - { - const char *pMessage = m_Input.GetString(); - // Don't translate server commands (e.g. "/w name msg"), translating would break their syntax - if(g_Config.m_TcTranslateOutgoing && pMessage[0] != '\0' && pMessage[0] != '/') - GameClient()->m_Translate.TranslateOutgoing(m_Mode == MODE_TEAM ? 1 : 0, pMessage); - else - SendChatQueued(pMessage); - } + SendChatQueued(m_Input.GetString()); m_pHistoryEntry = nullptr; DisableMode(); GameClient()->OnRelease(); diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 7c016a1a965..ee8978f015f 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1098,53 +1098,38 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) Ui()->DoLabel(&Label, TCLocalize("Translate"), HeadlineFontSize, TEXTALIGN_ML); Column.HSplitTop(MarginSmall, nullptr, &Column); - static const char *s_apTranslateLanguageNames[] = { - "Arabic", "Chinese (Simplified)", "Chinese (Traditional)", "Czech", "Dutch", "English", - "Finnish", "French", "German", "Greek", "Hebrew", "Hindi", "Hungarian", "Italian", - "Japanese", "Korean", "Polish", "Portuguese", "Romanian", "Russian", "Spanish", "Swedish", - "Thai", "Turkish", "Ukrainian", "Vietnamese"}; - static const char *s_apTranslateLanguageCodes[] = { - "ar", "zh", "zh-TW", "cs", "nl", "en", - "fi", "fr", "de", "el", "he", "hi", "hu", "it", - "ja", "ko", "pl", "pt", "ro", "ru", "es", "sv", - "th", "tr", "uk", "vi"}; - const int NumTranslateLanguages = (int)std::size(s_apTranslateLanguageNames); - static_assert(std::size(s_apTranslateLanguageNames) == std::size(s_apTranslateLanguageCodes), "language name/code list length mismatch"); - auto TranslateLanguageIndex = [&](const char *pCode) { - for(int i = 0; i < NumTranslateLanguages; i++) - if(str_comp_nocase(pCode, s_apTranslateLanguageCodes[i]) == 0) - return i; - return 5; // Fall back to English if unset or a custom code we don't have a name for - }; + // Current backend's language table -- Google and LibreTranslate genuinely + // support different language sets (see translate.h/.cpp's + // g_aTranslateBackends), so the dropdowns below are backend-scoped + // rather than one shared list. + int CurrentBackendIndex = 0; + for(int i = 0; i < g_NumTranslateBackends; i++) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, g_aTranslateBackends[i].m_pValue) == 0) + CurrentBackendIndex = i; + } + const STranslateBackendInfo &CurrentBackend = g_aTranslateBackends[CurrentBackendIndex]; { - static std::vector s_TranslateBackendNames = {"Google", "FreeTranslateAPI (defunct)", "LibreTranslate"}; - static const char *s_apTranslateBackendValues[] = {"google", "ftapi", "libretranslate"}; + static std::vector s_TranslateBackendNames; + s_TranslateBackendNames.clear(); + for(int i = 0; i < g_NumTranslateBackends; i++) + s_TranslateBackendNames.push_back(g_aTranslateBackends[i].m_pName); + static CUi::SDropDownState s_TranslateBackendDropDownState; static CScrollRegion s_TranslateBackendDropDownScrollRegion; s_TranslateBackendDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_TranslateBackendDropDownScrollRegion; - int BackendSelectedOld = 0; - for(int i = 0; i < (int)std::size(s_apTranslateBackendValues); i++) - { - if(str_comp_nocase(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[i]) == 0) - BackendSelectedOld = i; - } CUIRect BackendDropDownRect; Column.HSplitTop(LineSize, &BackendDropDownRect, &Column); BackendDropDownRect.VSplitLeft(120.0f, &Label, &BackendDropDownRect); Ui()->DoLabel(&Label, TCLocalize("Backend: "), FontSize, TEXTALIGN_ML); - const int BackendSelectedNew = Ui()->DoDropDown(&BackendDropDownRect, BackendSelectedOld, s_TranslateBackendNames.data(), s_TranslateBackendNames.size(), s_TranslateBackendDropDownState); - if(BackendSelectedOld != BackendSelectedNew) - str_copy(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[BackendSelectedNew]); + const int BackendSelectedNew = Ui()->DoDropDown(&BackendDropDownRect, CurrentBackendIndex, s_TranslateBackendNames.data(), s_TranslateBackendNames.size(), s_TranslateBackendDropDownState); + if(BackendSelectedNew != CurrentBackendIndex) + str_copy(g_Config.m_TcTranslateBackend, g_aTranslateBackends[BackendSelectedNew].m_pValue); } Column.HSplitTop(MarginSmall, nullptr, &Column); DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateAuto, TCLocalize("Automatically translate incoming messages"), &g_Config.m_TcTranslateAuto, &Column, LineSize); - if(g_Config.m_TcTranslateAuto && str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) - { - Column.HSplitTop(LineSize, &Label, &Column); - Ui()->DoLabel(&Label, TCLocalize("FreeTranslateAPI does not support automatic translation, switch to Google or LibreTranslate"), FontSize * 0.8f, TEXTALIGN_ML); - } CUIRect IncomingTargetBox; Column.HSplitTop(LineSize + MarginExtraSmall, &IncomingTargetBox, &Column); if(g_Config.m_TcTranslateAuto) @@ -1155,10 +1140,14 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) static CUi::SDropDownState s_IncomingLangDropDownState; static CScrollRegion s_IncomingLangDropDownScrollRegion; s_IncomingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_IncomingLangDropDownScrollRegion; - const int IncomingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateTarget); - const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_IncomingLangDropDownState); + static std::vector s_IncomingLangNames; + s_IncomingLangNames.clear(); + for(int i = 0; i < CurrentBackend.m_NumLanguages; i++) + s_IncomingLangNames.push_back(CurrentBackend.m_pLanguages[i].m_pName); + const int IncomingLangOld = TranslateLanguageIndex(CurrentBackend.m_pLanguages, CurrentBackend.m_NumLanguages, g_Config.m_TcTranslateTarget); + const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_IncomingLangNames.data(), s_IncomingLangNames.size(), s_IncomingLangDropDownState); if(IncomingLangOld != IncomingLangNew) - str_copy(g_Config.m_TcTranslateTarget, s_apTranslateLanguageCodes[IncomingLangNew]); + str_copy(g_Config.m_TcTranslateTarget, CurrentBackend.m_pLanguages[IncomingLangNew].m_pCode); } Column.HSplitTop(MarginSmall, nullptr, &Column); @@ -1173,14 +1162,18 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) static CUi::SDropDownState s_OutgoingLangDropDownState; static CScrollRegion s_OutgoingLangDropDownScrollRegion; s_OutgoingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_OutgoingLangDropDownScrollRegion; - const int OutgoingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateOutgoingTarget); - const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_OutgoingLangDropDownState); + static std::vector s_OutgoingLangNames; + s_OutgoingLangNames.clear(); + for(int i = 0; i < CurrentBackend.m_NumLanguages; i++) + s_OutgoingLangNames.push_back(CurrentBackend.m_pLanguages[i].m_pName); + const int OutgoingLangOld = TranslateLanguageIndex(CurrentBackend.m_pLanguages, CurrentBackend.m_NumLanguages, g_Config.m_TcTranslateOutgoingTarget); + const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_OutgoingLangNames.data(), s_OutgoingLangNames.size(), s_OutgoingLangDropDownState); if(OutgoingLangOld != OutgoingLangNew) - str_copy(g_Config.m_TcTranslateOutgoingTarget, s_apTranslateLanguageCodes[OutgoingLangNew]); + str_copy(g_Config.m_TcTranslateOutgoingTarget, CurrentBackend.m_pLanguages[OutgoingLangNew].m_pCode); } Column.HSplitTop(MarginSmall, nullptr, &Column); - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + if(CurrentBackend.m_NeedsEndpointConfig) { CUIRect EndpointBox; Column.HSplitTop(LineSize + MarginExtraSmall, &EndpointBox, &Column); diff --git a/src/game/client/components/tclient/translate.cpp b/src/game/client/components/tclient/translate.cpp index 3d31c3ae96b..171650e38f8 100644 --- a/src/game/client/components/tclient/translate.cpp +++ b/src/game/client/components/tclient/translate.cpp @@ -74,6 +74,24 @@ class ITranslateBackendHttp : public ITranslateBackend Http.Run(pGet); } + // Same as CreateHttpRequest, but POSTs pBody as a + // application/x-www-form-urlencoded body instead of encoding everything + // into the URL's query string -- keeps the URL itself short (the fixed + // CHttpRequest::m_aUrl buffer is 256 bytes, easily exceeded by a long + // chat message otherwise) and matches what the endpoint actually expects. + void CreateHttpRequestPost(IHttp &Http, const char *pUrl, const char *pBody) + { + auto pPost = std::make_shared(pUrl); + pPost->LogProgress(HTTPLOG::FAILURE); + pPost->FailOnErrorStatus(false); + pPost->Timeout(CTimeout{10000, 0, 500, 10}); + pPost->HeaderString("Content-Type", "application/x-www-form-urlencoded"); + pPost->Post((const unsigned char *)pBody, str_length(pBody)); + + m_pHttpRequest = pPost; + Http.Run(pPost); + } + public: std::optional Update(CTranslateResponse &Out) override { @@ -192,10 +210,9 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp bool ParseHttpError() const override { return true; } public: - const char *Name() const override - { - return "LibreTranslate"; - } + static constexpr const char *StaticValue() { return "libretranslate"; } + static constexpr const char *StaticName() { return "LibreTranslate"; } + const char *Name() const override { return StaticName(); } CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang) { CJsonStringWriter Json = CJsonStringWriter(); @@ -220,90 +237,13 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp } }; -class CTranslateBackendFtapi : public ITranslateBackendHttp -{ -private: - bool ParseResponseJson(const json_value *pObj, CTranslateResponse &Out) - { - if(!pObj) - { - str_copy(Out.m_Text, "Response is not JSON"); - return false; - } - - if(pObj->type != json_object) - { - str_copy(Out.m_Text, "Response is not object"); - return false; - } - - const json_value *pTranslatedText = json_object_get(pObj, "destination-text"); - if(pTranslatedText == &json_value_none) - { - str_copy(Out.m_Text, "No destination-text"); - return false; - } - if(pTranslatedText->type != json_string) - { - str_copy(Out.m_Text, "destination-text is not string"); - return false; - } - - const json_value *pDetectedLanguage = json_object_get(pObj, "source-language"); - if(pDetectedLanguage == &json_value_none) - { - str_copy(Out.m_Text, "No source-language"); - return false; - } - if(pDetectedLanguage->type != json_string) - { - str_copy(Out.m_Text, "source-language is not string"); - return false; - } - - str_copy(Out.m_Text, pTranslatedText->u.string.ptr); - str_copy(Out.m_Language, pDetectedLanguage->u.string.ptr); - - return true; - } - -protected: - bool ParseResponse(CTranslateResponse &Out) override - { - json_value *pObj = m_pHttpRequest->ResultJson(); - bool Res = ParseResponseJson(pObj, Out); - json_value_free(pObj); - return Res; - } - -public: - const char *EncodeTarget(const char *pTarget) const override - { - if(!pTarget || pTarget[0] == '\0') - return DefaultConfig::TcTranslateTarget; - if(str_comp_nocase(pTarget, "zh") == 0) - return "zh-cn"; - return pTarget; - } - const char *Name() const override - { - return "FreeTranslateAPI"; - } - CTranslateBackendFtapi(IHttp &Http, const char *pText, const char *pTargetLang) - { - char aBuf[4096]; - str_format(aBuf, sizeof(aBuf), "%s/translate?dl=%s&text=", - g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://ftapi.pythonanywhere.com", - EncodeTarget(pTargetLang)); - - UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); - - CreateHttpRequest(Http, aBuf); - } -}; - -// Talks directly to Google's free, unofficial (undocumented) translate endpoint. -// This is the same endpoint FreeTranslateAPI (ftapi.pythonanywhere.com, now dead) used to wrap. +// Talks directly to Google's free, unofficial (undocumented) translate +// endpoint -- this is the same endpoint the now-defunct FreeTranslateAPI +// (ftapi.pythonanywhere.com) used to wrap, without needing a third-party +// mirror to stay up. See https://github.com/TaterClient/TClient/pull/203 +// for context on why the old FTApi-wrapping backend was removed instead of +// just re-pointed: its hosting is gone, and anyone who wants a self-hosted +// option already has the LibreTranslate backend for that. class CTranslateBackendGoogle : public ITranslateBackendHttp { private: @@ -363,30 +303,71 @@ class CTranslateBackendGoogle : public ITranslateBackendHttp } public: - const char *Name() const override - { - return "Google"; - } + static constexpr const char *StaticValue() { return "google"; } + static constexpr const char *StaticName() { return "Google"; } + const char *Name() const override { return StaticName(); } CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang) { - char aBuf[4096]; - str_format(aBuf, sizeof(aBuf), "%s?client=gtx&sl=auto&dt=t&tl=%s&q=", - g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://translate.googleapis.com/translate_a/single", - EncodeTarget(pTargetLang)); + // Query params go in the POST body, not the URL -- keeps the URL + // itself short and fixed (see CreateHttpRequestPost) regardless of + // how long pText is. + char aBody[4096]; + str_format(aBody, sizeof(aBody), "client=gtx&sl=auto&dt=t&tl=%s&q=", EncodeTarget(pTargetLang)); - UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); + UrlEncode(pText, aBody + strlen(aBody), sizeof(aBody) - strlen(aBody)); - CreateHttpRequest(Http, aBuf); + const char *pEndpoint = g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://translate.googleapis.com/translate_a/single"; + CreateHttpRequestPost(Http, pEndpoint, aBody); } }; +// Per-backend supported-language lists for the settings menu -- Google +// supports far more languages than a typical self-hosted LibreTranslate +// instance, so these are deliberately not one shared list. Both are curated +// subsets (Google supports 100+ languages; LibreTranslate's exact set +// depends on which models a given self-hosted instance has installed), not +// a claim of exhaustive coverage. +static const STranslateLanguage s_aGoogleLanguages[] = { + {"Arabic", "ar"}, {"Bulgarian", "bg"}, {"Chinese (Simplified)", "zh"}, {"Chinese (Traditional)", "zh-TW"}, + {"Croatian", "hr"}, {"Czech", "cs"}, {"Danish", "da"}, {"Dutch", "nl"}, {"English", "en"}, + {"Estonian", "et"}, {"Finnish", "fi"}, {"French", "fr"}, {"German", "de"}, {"Greek", "el"}, + {"Hebrew", "he"}, {"Hindi", "hi"}, {"Hungarian", "hu"}, {"Indonesian", "id"}, {"Italian", "it"}, + {"Japanese", "ja"}, {"Korean", "ko"}, {"Latvian", "lv"}, {"Lithuanian", "lt"}, {"Norwegian", "no"}, + {"Persian", "fa"}, {"Polish", "pl"}, {"Portuguese", "pt"}, {"Romanian", "ro"}, {"Russian", "ru"}, + {"Serbian", "sr"}, {"Slovak", "sk"}, {"Slovenian", "sl"}, {"Spanish", "es"}, {"Swedish", "sv"}, + {"Thai", "th"}, {"Turkish", "tr"}, {"Ukrainian", "uk"}, {"Vietnamese", "vi"}, +}; + +static const STranslateLanguage s_aLibretranslateLanguages[] = { + {"Arabic", "ar"}, {"Chinese (Simplified)", "zh"}, {"Czech", "cs"}, {"Dutch", "nl"}, {"English", "en"}, + {"Finnish", "fi"}, {"French", "fr"}, {"German", "de"}, {"Greek", "el"}, {"Hebrew", "he"}, {"Hindi", "hi"}, + {"Hungarian", "hu"}, {"Indonesian", "id"}, {"Italian", "it"}, {"Japanese", "ja"}, {"Korean", "ko"}, + {"Polish", "pl"}, {"Portuguese", "pt"}, {"Russian", "ru"}, {"Spanish", "es"}, {"Swedish", "sv"}, + {"Turkish", "tr"}, {"Ukrainian", "uk"}, {"Vietnamese", "vi"}, +}; + +const STranslateBackendInfo g_aTranslateBackends[] = { + {CTranslateBackendGoogle::StaticValue(), CTranslateBackendGoogle::StaticName(), s_aGoogleLanguages, (int)std::size(s_aGoogleLanguages), false}, + {CTranslateBackendLibretranslate::StaticValue(), CTranslateBackendLibretranslate::StaticName(), s_aLibretranslateLanguages, (int)std::size(s_aLibretranslateLanguages), true}, +}; +const int g_NumTranslateBackends = (int)std::size(g_aTranslateBackends); + +int TranslateLanguageIndex(const STranslateLanguage *pLanguages, int NumLanguages, const char *pCode) +{ + for(int i = 0; i < NumLanguages; i++) + if(str_comp_nocase(pCode, pLanguages[i].m_pCode) == 0) + return i; + for(int i = 0; i < NumLanguages; i++) + if(str_comp_nocase("en", pLanguages[i].m_pCode) == 0) + return i; + return 0; +} + static std::unique_ptr CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang) { - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + if(str_comp_nocase(g_Config.m_TcTranslateBackend, CTranslateBackendLibretranslate::StaticValue()) == 0) return std::make_unique(Http, pText, pTargetLang); - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) - return std::make_unique(Http, pText, pTargetLang); - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "google") == 0) + if(str_comp_nocase(g_Config.m_TcTranslateBackend, CTranslateBackendGoogle::StaticValue()) == 0) return std::make_unique(Http, pText, pTargetLang); return nullptr; } @@ -566,22 +547,19 @@ void CTranslate::AutoTranslate(CChat::CLine &Line) if(Id >= 0 && Id == Line.m_ClientId) return; } - if(str_comp(g_Config.m_TcTranslateBackend, "ftapi") == 0) - { - // FTAPI quickly gets overloaded, please do not disable this - // It may shut down if we spam it too hard - return; - } Translate(Line, false); } -void CTranslate::TranslateOutgoing(int Team, const char *pText) +bool CTranslate::ChatDoTranslateOutgoing(int Team, const char *pText) { + // Don't translate server commands (e.g. "/w name msg"), translating would break their syntax + if(!g_Config.m_TcTranslateOutgoing || pText[0] == '\0' || pText[0] == '/') + return false; + if(m_vJobs.size() > 15) { - // Too many jobs in flight, don't make the player wait: send untranslated - GameClient()->m_Chat.SendChat(Team, pText); - return; + // Too many jobs in flight: don't queue another, let the caller send this one untranslated + return false; } CTranslateJob Job; @@ -594,9 +572,9 @@ void CTranslate::TranslateOutgoing(int Team, const char *pText) if(!Job.m_pBackend) { GameClient()->m_Chat.Echo("Invalid translate backend"); - GameClient()->m_Chat.SendChat(Team, pText); - return; + return false; } m_vJobs.emplace_back(std::move(Job)); + return true; } diff --git a/src/game/client/components/tclient/translate.h b/src/game/client/components/tclient/translate.h index e14a35a365d..a8877f6d2ae 100644 --- a/src/game/client/components/tclient/translate.h +++ b/src/game/client/components/tclient/translate.h @@ -20,6 +20,39 @@ class ITranslateBackend virtual std::optional Update(CTranslateResponse &Out) = 0; }; +struct STranslateLanguage +{ + const char *m_pName; + const char *m_pCode; +}; + +// Single source of truth for every available translate backend: its config +// value, its display name (also what ITranslateBackend::Name() returns for +// that backend, so the two never drift apart), and the language list its +// settings-menu dropdown should offer. Different backends genuinely support +// different language sets (Google supports far more than a typical +// self-hosted LibreTranslate instance), so this is per-backend rather than +// one shared list. +struct STranslateBackendInfo +{ + const char *m_pValue; + const char *m_pName; + const STranslateLanguage *m_pLanguages; + int m_NumLanguages; + // Whether the settings menu should show the endpoint/API-key fields for + // this backend -- data-driven so the menu never has to special-case a + // backend by name. + bool m_NeedsEndpointConfig; +}; + +extern const STranslateBackendInfo g_aTranslateBackends[]; +extern const int g_NumTranslateBackends; + +// Finds pCode's index in pLanguages, or whichever entry IS English (found by +// code, not a hardcoded position) if pCode is unset/unrecognised -- stays +// correct no matter how a language list is reordered or edited. +int TranslateLanguageIndex(const STranslateLanguage *pLanguages, int NumLanguages, const char *pCode); + class CTranslate : public CComponent { class CTranslateJob @@ -51,9 +84,13 @@ class CTranslate : public CComponent void AutoTranslate(CChat::CLine &Line); - // Translates pText into g_Config.m_TcTranslateOutgoingTarget, then sends the - // result (or the original text if translation fails) as a chat message. - void TranslateOutgoing(int Team, const char *pText); + // If outgoing translation applies to pText (enabled, non-empty, not a + // "/"-prefixed command), queues a translate job that sends the result + // (or the original text if translation fails) as a chat message, and + // returns true. Returns false if the caller should send pText itself -- + // mirrors CTClient::ChatDoSpecId's shape so chat.cpp can chain it as + // another "else if" instead of a nested block. + bool ChatDoTranslateOutgoing(int Team, const char *pText); }; #endif From f6666518cfa8dde482ff6c7e8ac9a6608fefe978 Mon Sep 17 00:00:00 2001 From: MaikCZE Date: Tue, 18 Aug 2026 15:14:45 +0200 Subject: [PATCH 3/3] Address SollyBunny's design feedback on PR #206 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - g_aTranslateBackends stays a fixed array (std::array), but the separate g_NumTranslateBackends variable is gone -- callers use g_aTranslateBackends.size() directly. - Backend display name/value no longer live in the individual backend classes (removed StaticName()/StaticValue()/the Name() overrides) -- ITranslateBackend now takes its name once at construction, sourced from the matching g_aTranslateBackends entry, so the array is the only place that data exists. - Both language lists (Google, LibreTranslate) are now std::vector instead of raw C arrays, matching STranslateBackendInfo::m_vLanguages. - Ran scripts/fix_style.py (clang-format 20) to fix check-style -- also picked up one pre-existing, unrelated style violation in chat.cpp's AddLine() (regex chat-ignore lambda) since formatting runs per-file. check-clang-tidy's remaining failures (mumble.h, discord.cpp, client.cpp, menus_start.cpp, menus_settings.cpp) are pre-existing on master_new itself (confirmed via its own most recent check-runs) and untouched by this PR -- out of scope here. Rebuilt clean with MSVC 19.51 (Release, Ninja), zero errors/warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/game/client/components/chat.cpp | 19 +-- .../components/tclient/menus_tclient.cpp | 24 ++-- .../client/components/tclient/translate.cpp | 128 ++++++++++++------ .../client/components/tclient/translate.h | 24 ++-- 4 files changed, 126 insertions(+), 69 deletions(-) diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp index 758d9c6c04c..49d1ab275f6 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -682,15 +682,16 @@ void CChat::AddLine(int ClientId, int Team, const char *pLine) return; // TClient - if (([&]() { - if(ClientId == CLIENT_MSG || ClientId == SERVER_MSG) - return false; - for(const auto LocalClientId : GameClient()->m_aLocalIds) - if(LocalClientId == ClientId) - return false; - auto &Re = GameClient()->m_TClient.m_RegexChatIgnore; - return Re.error().empty() && Re.test(pLine); - })()) return; + if(([&]() { + if(ClientId == CLIENT_MSG || ClientId == SERVER_MSG) + return false; + for(const auto LocalClientId : GameClient()->m_aLocalIds) + if(LocalClientId == ClientId) + return false; + auto &Re = GameClient()->m_TClient.m_RegexChatIgnore; + return Re.error().empty() && Re.test(pLine); + })()) + return; // trim right and set maximum length to 256 utf8-characters int Length = 0; diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index ee8978f015f..029ca59dfae 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1103,18 +1103,18 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) // g_aTranslateBackends), so the dropdowns below are backend-scoped // rather than one shared list. int CurrentBackendIndex = 0; - for(int i = 0; i < g_NumTranslateBackends; i++) + for(size_t i = 0; i < g_aTranslateBackends.size(); i++) { if(str_comp_nocase(g_Config.m_TcTranslateBackend, g_aTranslateBackends[i].m_pValue) == 0) - CurrentBackendIndex = i; + CurrentBackendIndex = (int)i; } const STranslateBackendInfo &CurrentBackend = g_aTranslateBackends[CurrentBackendIndex]; { static std::vector s_TranslateBackendNames; s_TranslateBackendNames.clear(); - for(int i = 0; i < g_NumTranslateBackends; i++) - s_TranslateBackendNames.push_back(g_aTranslateBackends[i].m_pName); + for(const STranslateBackendInfo &Backend : g_aTranslateBackends) + s_TranslateBackendNames.push_back(Backend.m_pName); static CUi::SDropDownState s_TranslateBackendDropDownState; static CScrollRegion s_TranslateBackendDropDownScrollRegion; @@ -1142,12 +1142,12 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) s_IncomingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_IncomingLangDropDownScrollRegion; static std::vector s_IncomingLangNames; s_IncomingLangNames.clear(); - for(int i = 0; i < CurrentBackend.m_NumLanguages; i++) - s_IncomingLangNames.push_back(CurrentBackend.m_pLanguages[i].m_pName); - const int IncomingLangOld = TranslateLanguageIndex(CurrentBackend.m_pLanguages, CurrentBackend.m_NumLanguages, g_Config.m_TcTranslateTarget); + for(const STranslateLanguage &Language : CurrentBackend.m_vLanguages) + s_IncomingLangNames.push_back(Language.m_pName); + const int IncomingLangOld = TranslateLanguageIndex(CurrentBackend.m_vLanguages, g_Config.m_TcTranslateTarget); const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_IncomingLangNames.data(), s_IncomingLangNames.size(), s_IncomingLangDropDownState); if(IncomingLangOld != IncomingLangNew) - str_copy(g_Config.m_TcTranslateTarget, CurrentBackend.m_pLanguages[IncomingLangNew].m_pCode); + str_copy(g_Config.m_TcTranslateTarget, CurrentBackend.m_vLanguages[IncomingLangNew].m_pCode); } Column.HSplitTop(MarginSmall, nullptr, &Column); @@ -1164,12 +1164,12 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) s_OutgoingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_OutgoingLangDropDownScrollRegion; static std::vector s_OutgoingLangNames; s_OutgoingLangNames.clear(); - for(int i = 0; i < CurrentBackend.m_NumLanguages; i++) - s_OutgoingLangNames.push_back(CurrentBackend.m_pLanguages[i].m_pName); - const int OutgoingLangOld = TranslateLanguageIndex(CurrentBackend.m_pLanguages, CurrentBackend.m_NumLanguages, g_Config.m_TcTranslateOutgoingTarget); + for(const STranslateLanguage &Language : CurrentBackend.m_vLanguages) + s_OutgoingLangNames.push_back(Language.m_pName); + const int OutgoingLangOld = TranslateLanguageIndex(CurrentBackend.m_vLanguages, g_Config.m_TcTranslateOutgoingTarget); const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_OutgoingLangNames.data(), s_OutgoingLangNames.size(), s_OutgoingLangDropDownState); if(OutgoingLangOld != OutgoingLangNew) - str_copy(g_Config.m_TcTranslateOutgoingTarget, CurrentBackend.m_pLanguages[OutgoingLangNew].m_pCode); + str_copy(g_Config.m_TcTranslateOutgoingTarget, CurrentBackend.m_vLanguages[OutgoingLangNew].m_pCode); } Column.HSplitTop(MarginSmall, nullptr, &Column); diff --git a/src/game/client/components/tclient/translate.cpp b/src/game/client/components/tclient/translate.cpp index 171650e38f8..83381199bae 100644 --- a/src/game/client/components/tclient/translate.cpp +++ b/src/game/client/components/tclient/translate.cpp @@ -59,6 +59,7 @@ bool ITranslateBackend::CompareTargets(const char *pA, const char *pB) const class ITranslateBackendHttp : public ITranslateBackend { protected: + using ITranslateBackend::ITranslateBackend; std::shared_ptr m_pHttpRequest = nullptr; virtual bool ParseResponse(CTranslateResponse &Out) = 0; virtual bool ParseHttpError() const { return false; } @@ -210,10 +211,8 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp bool ParseHttpError() const override { return true; } public: - static constexpr const char *StaticValue() { return "libretranslate"; } - static constexpr const char *StaticName() { return "LibreTranslate"; } - const char *Name() const override { return StaticName(); } - CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang) + CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang, const char *pName) : + ITranslateBackendHttp(pName) { CJsonStringWriter Json = CJsonStringWriter(); Json.BeginObject(); @@ -303,10 +302,8 @@ class CTranslateBackendGoogle : public ITranslateBackendHttp } public: - static constexpr const char *StaticValue() { return "google"; } - static constexpr const char *StaticName() { return "Google"; } - const char *Name() const override { return StaticName(); } - CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang) + CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang, const char *pName) : + ITranslateBackendHttp(pName) { // Query params go in the POST body, not the URL -- keeps the URL // itself short and fixed (see CreateHttpRequestPost) regardless of @@ -327,48 +324,101 @@ class CTranslateBackendGoogle : public ITranslateBackendHttp // subsets (Google supports 100+ languages; LibreTranslate's exact set // depends on which models a given self-hosted instance has installed), not // a claim of exhaustive coverage. -static const STranslateLanguage s_aGoogleLanguages[] = { - {"Arabic", "ar"}, {"Bulgarian", "bg"}, {"Chinese (Simplified)", "zh"}, {"Chinese (Traditional)", "zh-TW"}, - {"Croatian", "hr"}, {"Czech", "cs"}, {"Danish", "da"}, {"Dutch", "nl"}, {"English", "en"}, - {"Estonian", "et"}, {"Finnish", "fi"}, {"French", "fr"}, {"German", "de"}, {"Greek", "el"}, - {"Hebrew", "he"}, {"Hindi", "hi"}, {"Hungarian", "hu"}, {"Indonesian", "id"}, {"Italian", "it"}, - {"Japanese", "ja"}, {"Korean", "ko"}, {"Latvian", "lv"}, {"Lithuanian", "lt"}, {"Norwegian", "no"}, - {"Persian", "fa"}, {"Polish", "pl"}, {"Portuguese", "pt"}, {"Romanian", "ro"}, {"Russian", "ru"}, - {"Serbian", "sr"}, {"Slovak", "sk"}, {"Slovenian", "sl"}, {"Spanish", "es"}, {"Swedish", "sv"}, - {"Thai", "th"}, {"Turkish", "tr"}, {"Ukrainian", "uk"}, {"Vietnamese", "vi"}, +static const std::vector s_vGoogleLanguages = { + {"Arabic", "ar"}, + {"Bulgarian", "bg"}, + {"Chinese (Simplified)", "zh"}, + {"Chinese (Traditional)", "zh-TW"}, + {"Croatian", "hr"}, + {"Czech", "cs"}, + {"Danish", "da"}, + {"Dutch", "nl"}, + {"English", "en"}, + {"Estonian", "et"}, + {"Finnish", "fi"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "el"}, + {"Hebrew", "he"}, + {"Hindi", "hi"}, + {"Hungarian", "hu"}, + {"Indonesian", "id"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latvian", "lv"}, + {"Lithuanian", "lt"}, + {"Norwegian", "no"}, + {"Persian", "fa"}, + {"Polish", "pl"}, + {"Portuguese", "pt"}, + {"Romanian", "ro"}, + {"Russian", "ru"}, + {"Serbian", "sr"}, + {"Slovak", "sk"}, + {"Slovenian", "sl"}, + {"Spanish", "es"}, + {"Swedish", "sv"}, + {"Thai", "th"}, + {"Turkish", "tr"}, + {"Ukrainian", "uk"}, + {"Vietnamese", "vi"}, }; -static const STranslateLanguage s_aLibretranslateLanguages[] = { - {"Arabic", "ar"}, {"Chinese (Simplified)", "zh"}, {"Czech", "cs"}, {"Dutch", "nl"}, {"English", "en"}, - {"Finnish", "fi"}, {"French", "fr"}, {"German", "de"}, {"Greek", "el"}, {"Hebrew", "he"}, {"Hindi", "hi"}, - {"Hungarian", "hu"}, {"Indonesian", "id"}, {"Italian", "it"}, {"Japanese", "ja"}, {"Korean", "ko"}, - {"Polish", "pl"}, {"Portuguese", "pt"}, {"Russian", "ru"}, {"Spanish", "es"}, {"Swedish", "sv"}, - {"Turkish", "tr"}, {"Ukrainian", "uk"}, {"Vietnamese", "vi"}, +static const std::vector s_vLibretranslateLanguages = { + {"Arabic", "ar"}, + {"Chinese (Simplified)", "zh"}, + {"Czech", "cs"}, + {"Dutch", "nl"}, + {"English", "en"}, + {"Finnish", "fi"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "el"}, + {"Hebrew", "he"}, + {"Hindi", "hi"}, + {"Hungarian", "hu"}, + {"Indonesian", "id"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Polish", "pl"}, + {"Portuguese", "pt"}, + {"Russian", "ru"}, + {"Spanish", "es"}, + {"Swedish", "sv"}, + {"Turkish", "tr"}, + {"Ukrainian", "uk"}, + {"Vietnamese", "vi"}, }; -const STranslateBackendInfo g_aTranslateBackends[] = { - {CTranslateBackendGoogle::StaticValue(), CTranslateBackendGoogle::StaticName(), s_aGoogleLanguages, (int)std::size(s_aGoogleLanguages), false}, - {CTranslateBackendLibretranslate::StaticValue(), CTranslateBackendLibretranslate::StaticName(), s_aLibretranslateLanguages, (int)std::size(s_aLibretranslateLanguages), true}, -}; -const int g_NumTranslateBackends = (int)std::size(g_aTranslateBackends); +const std::array g_aTranslateBackends = {{ + {"google", "Google", s_vGoogleLanguages, false}, + {"libretranslate", "LibreTranslate", s_vLibretranslateLanguages, true}, +}}; -int TranslateLanguageIndex(const STranslateLanguage *pLanguages, int NumLanguages, const char *pCode) +int TranslateLanguageIndex(const std::vector &vLanguages, const char *pCode) { - for(int i = 0; i < NumLanguages; i++) - if(str_comp_nocase(pCode, pLanguages[i].m_pCode) == 0) - return i; - for(int i = 0; i < NumLanguages; i++) - if(str_comp_nocase("en", pLanguages[i].m_pCode) == 0) - return i; + for(size_t i = 0; i < vLanguages.size(); i++) + if(str_comp_nocase(pCode, vLanguages[i].m_pCode) == 0) + return (int)i; + for(size_t i = 0; i < vLanguages.size(); i++) + if(str_comp_nocase("en", vLanguages[i].m_pCode) == 0) + return (int)i; return 0; } static std::unique_ptr CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang) { - if(str_comp_nocase(g_Config.m_TcTranslateBackend, CTranslateBackendLibretranslate::StaticValue()) == 0) - return std::make_unique(Http, pText, pTargetLang); - if(str_comp_nocase(g_Config.m_TcTranslateBackend, CTranslateBackendGoogle::StaticValue()) == 0) - return std::make_unique(Http, pText, pTargetLang); + for(const STranslateBackendInfo &Backend : g_aTranslateBackends) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, Backend.m_pValue) != 0) + continue; + if(str_comp_nocase(Backend.m_pValue, "libretranslate") == 0) + return std::make_unique(Http, pText, pTargetLang, Backend.m_pName); + if(str_comp_nocase(Backend.m_pValue, "google") == 0) + return std::make_unique(Http, pText, pTargetLang, Backend.m_pName); + } return nullptr; } diff --git a/src/game/client/components/tclient/translate.h b/src/game/client/components/tclient/translate.h index a8877f6d2ae..f38491098ef 100644 --- a/src/game/client/components/tclient/translate.h +++ b/src/game/client/components/tclient/translate.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -13,11 +14,18 @@ class CTranslate; class ITranslateBackend { public: + explicit ITranslateBackend(const char *pName) : + m_pName(pName) {} virtual ~ITranslateBackend() = default; virtual const char *EncodeTarget(const char *pTarget) const; virtual bool CompareTargets(const char *pA, const char *pB) const; - virtual const char *Name() const = 0; + // Set once at construction from the matching g_aTranslateBackends entry + // -- backend classes don't hold their own copy of their display name. + const char *Name() const { return m_pName; } virtual std::optional Update(CTranslateResponse &Out) = 0; + +private: + const char *m_pName; }; struct STranslateLanguage @@ -27,8 +35,8 @@ struct STranslateLanguage }; // Single source of truth for every available translate backend: its config -// value, its display name (also what ITranslateBackend::Name() returns for -// that backend, so the two never drift apart), and the language list its +// value, its display name (passed into the backend's constructor so +// ITranslateBackend::Name() never drifts from it), and the language list its // settings-menu dropdown should offer. Different backends genuinely support // different language sets (Google supports far more than a typical // self-hosted LibreTranslate instance), so this is per-backend rather than @@ -37,21 +45,19 @@ struct STranslateBackendInfo { const char *m_pValue; const char *m_pName; - const STranslateLanguage *m_pLanguages; - int m_NumLanguages; + std::vector m_vLanguages; // Whether the settings menu should show the endpoint/API-key fields for // this backend -- data-driven so the menu never has to special-case a // backend by name. bool m_NeedsEndpointConfig; }; -extern const STranslateBackendInfo g_aTranslateBackends[]; -extern const int g_NumTranslateBackends; +extern const std::array g_aTranslateBackends; -// Finds pCode's index in pLanguages, or whichever entry IS English (found by +// Finds pCode's index in vLanguages, or whichever entry IS English (found by // code, not a hardcoded position) if pCode is unset/unrecognised -- stays // correct no matter how a language list is reordered or edited. -int TranslateLanguageIndex(const STranslateLanguage *pLanguages, int NumLanguages, const char *pCode); +int TranslateLanguageIndex(const std::vector &vLanguages, const char *pCode); class CTranslate : public CComponent {