Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/engine/shared/config_variables_tclient.h
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

People running their own stuff will just use libretranslate. You can remove this comment and the ftapi backend

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)")
Expand Down
9 changes: 8 additions & 1 deletion src/game/client/components/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

To reduce diff, make translate outgoing swallow messages like ChatDoSpecId above it

// 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();
Expand Down
111 changes: 111 additions & 0 deletions src/game/client/components/tclient/menus_tclient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,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[] = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The backend should determine the language options. Google has many more than libretranslate

"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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Magic number! (don't fix by static asserting english is 5)

};

{
static std::vector<const char *> s_TranslateBackendNames = {"Google", "FreeTranslateAPI (defunct)", "LibreTranslate"};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These values are duplicated from ibackend::name

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;

Expand Down
147 changes: 138 additions & 9 deletions src/game/client/components/tclient/translate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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')
Expand Down Expand Up @@ -289,19 +289,108 @@ 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));

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Link to the repository or a more alive one

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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be much better if you could put it in the body


CreateHttpRequest(Http, aBuf);
}
};

static std::unique_ptr<ITranslateBackend> CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang)
{
if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0)
return std::make_unique<CTranslateBackendLibretranslate>(Http, pText, pTargetLang);
if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0)
return std::make_unique<CTranslateBackendFtapi>(Http, pText, pTargetLang);
if(str_comp_nocase(g_Config.m_TcTranslateBackend, "google") == 0)
return std::make_unique<CTranslateBackendGoogle>(Http, pText, pTargetLang);
return nullptr;
}

void CTranslate::ConTranslate(IConsole::IResult *pResult, void *pUserData)
{
const char *pName;
Expand Down Expand Up @@ -398,11 +487,8 @@ void CTranslate::Translate(CChat::CLine &Line, bool ShowProgress)
Job.m_pTranslateResponse = std::make_shared<CTranslateResponse>();
Job.m_pLine->m_pTranslateResponse = Job.m_pTranslateResponse;

if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0)
Job.m_pBackend = std::make_unique<CTranslateBackendLibretranslate>(*Http(), Job.m_pLine->m_aText);
else if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0)
Job.m_pBackend = std::make_unique<CTranslateBackendFtapi>(*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;
Expand All @@ -428,6 +514,23 @@ void CTranslate::OnRender()
{
const auto Time = time();
auto ForEach = [&](CTranslateJob &Job) {
if(Job.m_IsOutgoing)
{
const std::optional<bool> 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<bool> Done = Job.m_pBackend->Update(*Job.m_pTranslateResponse);
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

, don't make the player wait
The game is an unrelated concept from the chat

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<CTranslateResponse>();

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));
}
8 changes: 8 additions & 0 deletions src/game/client/components/tclient/translate.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class CTranslate : public CComponent
// For chat translations
CChat::CLine *m_pLine = nullptr;
std::shared_ptr<CTranslateResponse> 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<CTranslateJob> m_vJobs;

Expand All @@ -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
Loading