Text pipeline
Every displayed line goes through providers declared in data, translation tables first, then machine translation and re-voicing the player turns on.
Format in progress, engine in build (2026-10-25). The pipe exists in the engine (UTextPipelineSubsystem); the models it can call do not ship with the game. Nothing here knows a vendor: a provider is a table with a URL, headers, a body template and a response path.
A line of a table scene has an identity: <scene>.<beat>.<n>, a speaker, a mood, a text (Scenes as tables). That is what makes the rest possible: a translation is a table keyed by that id, and a re-voicing receives the line with who says it, in what mood, where. A scripted scene's T("…") goes through the same pipe with an id derived from the text.
Providers
ul_Base/Text/providers.ulproviders, a Lua list a mod may extend or redefine entry by entry in its own Text/providers.ulproviders (the same id redefines):
luareturn {
{ id = "loc_table", stage = "translate", impl = "engine.LocTable",
doc = "The mod's own translation table, Loc/<lang>.lua, keyed by line id or by the English text. Always on." },
{ id = "mt_http", stage = "translate", impl = "engine.Http", setting = "text.mt_http",
url = "{setting:url}", method = "POST",
headers = { ["Authorization"] = "Bearer {setting:key}" },
body = '{"q": "{text}", "source": "{source}", "target": "{lang}", "format": "text"}',
responsePath = "translatedText", timeoutSeconds = 8 },
{ id = "revoice", stage = "revoice", impl = "engine.OpenAIChat", setting = "text.revoice",
url = "{setting:url}", method = "POST",
headers = { ["Authorization"] = "Bearer {setting:key}" },
model = "{setting:model}",
prompt = "You rewrite one line of a life-simulation game in the voice of the character who says it. Speaker: {speaker}. Mood: {mood}. Personality: {personality}. Scene: {scene}. Place: {place}. …",
body = '{"model": "{model}", "temperature": 0.7, "max_tokens": 200, "messages": [{"role": "system", "content": "{prompt}"}, {"role": "user", "content": "{text}"}]}',
responsePath = "choices.0.message.content", timeoutSeconds = 20 },
}
| Key | Means |
|---|---|
id |
The provider's name; its settings live under text.<id>.*. |
stage |
"translate" or "revoice": a label the player's text.order sorts by. |
impl |
"engine.LocTable" (the mod's tables), "engine.Http" (a generic HTTP call), "engine.OpenAIChat" (a chat-completions call). |
setting |
The on/off setting key; absent means always on. |
url, method, headers, body, responsePath, timeoutSeconds |
The HTTP call. {text}, {source}, {lang}, {speaker}, {mood}, {personality}, {scene}, {place} are filled from the line; {setting:key} reads text.<id>.<key> from the player's settings. |
prompt, model |
engine.OpenAIChat only: the system prompt template and the model name. |
Contract of a provider: (line id, source text, target language, context) -> text or nil. Within a stage the first one that answers wins; a stage with no answer leaves the text as it is.
Tables: Loc/<lang>.lua
The official translations. One file per language in the mod's Loc/ folder, keyed by line id, with the English text as a fallback key for scripted lines:
luareturn {
["ul_db_press_visit.start.3"] = "Vous savez qui nous envoie. On n'est pas là pour être désagréables.",
["Three knocks, evenly spaced."] = "Trois coups, bien espacés.",
}
An extraction tool walks the .ulscene, .ulaction, .ulstat and .ulmod files and writes Loc/en.lua; the other languages are the same file. loc_table looks in the scene's mod first, then in ul_Base for shared lines. It does nothing while text.language is en.
The player's settings
| Setting | Means |
|---|---|
text.language |
The target language (en, fr…). |
text.order |
translate,revoice (the model speaks the target language) or revoice,translate (the model writes in English, then it is translated: better with a model weak in languages). A player setting, by the owner's decision. |
text.mt_http |
On/off for machine translation of the lines no table has. |
text.mt_http.url, text.mt_http.key |
The endpoint, shaped for LibreTranslate out of the box (http://localhost:5000/translate, key optional); a mod redefines the provider's body and responsePath for DeepL, Google or anything else. |
text.revoice |
On/off for re-voicing. Off until the player turns it on. |
text.revoice.url, text.revoice.key, text.revoice.model |
An OpenAI chat-completions endpoint: http://localhost:8080/v1/chat/completions for llama.cpp, or any hosted one (vLLM, Ollama, OpenRouter, a subscription model). |
Machine translation at runtime is the player's choice, not the game's: the official translations stay tables. Re-voicing changes the text only; every decision of the scene stays deterministic.
How it behaves
- Asynchronous with a fallback. Beyond the tables everything is a request: the source line shows at once and is replaced when the answer arrives. A scene's lines are requested when the beat is entered, not when each line is displayed, so most answers are there in time.
- Cached on disk.
Saved/TextCache, per (provider, language, line id, source hash): a line is never sent twice. - What the re-voicer receives. The text, the speaker's first name (empty for the narrator), the mood, a short description of the speaker's personality when the game knows one, the scene and the place. It keeps the meaning, the facts, the names and roughly the length, and answers with the line only.
Writing for it
Give every line a speaker and a mood; put a value that changes at runtime in S.vars and read it as {=name} rather than concatenating strings; keep one sentence per line table when a line may be cut. A scripted scene should wrap its text in T() as the localization plan asks (Reference/LOCALIZATION_PLAN.md in the game repository, phase 2); that is the only place T() is still needed.