diff --git a/.gitignore b/.gitignore index bb36f0826..b428a8bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,10 @@ AGENTS.md /data/ /save/ +# The game's message files exported as JSON (darkcloud --export-text, docs/LOCALIZATION.md): the +# disc's own text, never committed. +/lang-export/ + # Flatpak builds (docs/FLATPAK.md) /.flatpak-builder/ /build-dir/ diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 000000000..8907dcb2d --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,270 @@ +# Localization + +Game text can come from JSON files instead of the disc's `.mes` message files. A JSON file holds only the +messages it changes; every message it leaves out, and every file it does not mention, is read from the +disc as before. Nothing from the disc is shipped: a translator starts from an export of their own +extracted data (below), and the files the port distributes hold only text written for the port. + +The files are laid out as Minecraft's language files are: one flat JSON file per language, named +`language_COUNTRY.json`, each an object of `"dotted.key": "text"`, with the same keys in every language. + +## Where the files go + +One file per language, named by the language the game is set to: + +| Language | `LanguageCode` | File | +|---|---|---| +| Japanese | 0 | `ja_jp.json` | +| English (US) | 1 | `en_us.json` | +| English (UK) | 2 | `en_gb.json` | +| Francais | 3 | `fr_fr.json` | +| Deutsch | 4 | `de_de.json` | +| Italiano | 5 | `it_it.json` | +| Espanol | 6 | `es_es.json` | + +The port looks in two folders, in this order, and a later file's messages replace an earlier one's: + +1. `lang/` beside the executable, +2. `lang/` in the save folder (`--save`, or `DC_SAVE`). + +The language is looked up whenever the game asks for a file, so a change of language takes effect on the +next file the game loads; the JSON itself is read once per language, so restart the game after editing a +file. + +## Format + +```json +{ + "dun.script.d01.d01.0": "Bienvenue.", + "dun.script.d01.d01.12": "Il y a {-1021}trois{-1024} coffres ici.\nPrenez-les !", + "commenu.dungeon.dunmenu5.pak.allmenu.11": "\"Objet\"\nUtiliser un objet ou\nregler les objets." +} +``` + +- A key is **the message file's key, a dot, and the message id**. The id is the number the game gives the + message, an s16 (`-32768` to `32767`); an id the disc's file does not have is added. +- The message file's key is the same in every language, so `fr_fr.json` and `de_de.json` have the same keys + for the same messages and can be set side by side. It is the path the game loads with the language taken + out, the `.mes` taken off and the slashes made dots: + + | The game loads | Key | + |---|---| + | `dun/script/d01/d01_3.mes` (and `d01_2.mes`, `d01_4.mes`...) | `dun.script.d01.d01` | + | `meswin/system14_2.mes` | `meswin.system14` | + | `gedit/e01/e01talk_3.mes` | `gedit.e01.e01talk` | + | `commenu/a_fre/dungeon/dunmenu5.pak/allmenu.mes` (`a_eng/`, `a_ger/`...) | `commenu.dungeon.dunmenu5.pak.allmenu` | + | `noda_w/dun00_eng.mes` (no language in its name) | `noda_w.dun00_eng` | + + A message file inside a pack (`.pak`, `.pac`) is the pack's path and the entry's name, as in the last + rows. The "language" is the last number of a file's name (`_0` to `_6`) or a menu folder named `a_xxx/`. +- A key that does not end in a message id is one of **the port's own strings**, the ones it draws itself + (the Options screen). They are in the same files, under `options.`; see "The port's own text" below. +- A text is UTF-8. `\n` is a line break. `{N}` writes the code N as it is, which is how colours, pauses, + icons and the game's name and number fields appear (in the disc's English menu text a coloured word is + wrapped in `{-1021}` and `{-1024}`); `{{` is a `{`. The export writes every message in this form, so + copy those codes across unchanged. + +## The port's own text + +The Options screen is the port's, not the disc's, so its words are not in the disc's message files. They are +in the language files too, under keys the code derives from each setting, so a new row gets its keys without +any other change: + +| Key | Is | +|---|---| +| `options.page.game` (`display`, `audio`, `controls`) | a page's name | +| `options..label` | the row's name; `` is the name in `config.json`, `game.map`, `video.aspect`... | +| `options..help` | the help window's text for a setting the port describes itself | +| `options..choice.` | the n-th choice of the setting, counting from 0 (`On`, `Off`...) | +| `options..value.` | a value the row works out itself, by its English (`Desktop`, `Unlimited`) | +| `options.shortcuts`, `options.help.*`, `options.display.*` | the hints at the bottom and the warning texts | + +A text may hold `%1`, `%2`... where the program puts a number or a name, and the pad's button glyphs as +their `{N}` codes (`{-759}` square, `{-761}` triangle, `{-762}` circle, `{-760}` cross, `{-766}` L1, +`{-765}` R1); keep both as the English has them. Help windows hold four lines of about 22 characters, and a +label about 20; a longer one runs past its window. + +The settings the game describes itself (the help for Message Speed, Clock...) are the disc's messages and +come from the disc's language files as before. + +`port/lang/` ships these strings in French, German, Italian and Spanish; the build copies it to `lang/` +beside the executable, where the game reads it, so they need no export. English is in the code, and is what +shows where a language has no text for a key. The export lists every one of them with the text the language +would show now, so a translator starts from the shipped text. A translation written in `/lang/` takes +the place of the shipped one, key by key. The translations are first drafts: a native speaker should look +at them. + +## Choosing the language + +The language screen at the start of the game sets the language, as retail does. To skip it, and to change +the language at any time, set it in `config.json` or on the Options screen (Game page, Language row): + +```json +{"game": {"language": "francais"}} +``` + +`ask` (the default) shows the language screen; `english`, `francais`, `deutsch`, `italiano` and `espanol` +start in that language without asking, going straight to the attract movie where the language screen would +have led. Changing the row on the Options screen switches the game to that language once the Options screen +closes: the town or title it is in is loaded again, so every message, menu and image is the new language. +A dungeon floor is not restarted for it; the language takes hold when the next area loads. `ask` leaves the +current language alone. + +## Exporting the disc's text + +The port does this for you: the first start with data in place (right after the first-run extraction, or +any `dcdata extract`) writes the export to `/lang-export/`, unless `en_gb.json` is already there. +Delete that folder to have it made again. The same by hand: + +``` +darkcloud --data --export-text +``` + +writes one flat file per language into ``, from every message file under the data folder (loose +`.mes` files and the `.mes` entries of `.pak` and `.pac` packs; 855 files on the PAL disc): + +| File | Holds | +|---|---| +| `en_gb.json`, `fr_fr.json`, `de_de.json`, `it_it.json`, `es_es.json`, `en_us.json`, `ja_jp.json` | the messages of that language's files, all keyed as above | +| `hashes/.json` | the same keys, each with the hash of the message's text, for patches | + +A file that names no language and is not Japanese (`noda_w/dun00_eng.mes`) is in every language's file. +The menu packs left in `commenu/` that are Japanese go to `ja_jp.json`. Where a file's path names its +language it wins over one that does not. Two files of one language that come to the same key are told on +stderr (there are none on the PAL disc). + +The export checks as it goes that every message encodes back to the disc's own codes, and prints how many +files do not (none, on the PAL disc). Messages that have no end code in the disc's file (one unused id in +`allmenu.mes`) are left out. + +The export is the disc's own text. It is the user's data and must not be committed or shared: +`/lang-export/` is in `.gitignore`, and anything you put in a `lang/` folder next to a build you +distribute should be your own text, not an export. + +## Translating + +1. Export, then copy the language's file to `/lang/` (`fr_fr.json`...). +2. Delete every message you are not changing; what stays is the part you have written. A file with all of + the disc's messages in it works, but it hides what you changed. +3. Edit the texts. Keep every `{N}` code and line break the disc's text has unless you know what it does. +4. Start the game in that language and look at each screen. Check stderr for `localize:` lines (overlong + texts, characters the font lacks). + +## Comparing the languages + +``` +python scripts/port/compare_text.py /lang-export [] +``` + +writes one CSV per language (fr_fr, de_de, it_it, es_es) with `en_gb.json` as the reference, one row per +message that differs in a way worth looking at: `missing` (English has it, this language does not), `extra`, +`empty`, `same` (the English text, often untranslated, and every message of a file that names no +language), and `long` (more than twice or under half English's length, where a window may not fit it), and +a `summary.txt` of the counts. The CSVs hold the disc's own text: keep them out of git. + +## Patches + +A patch changes a message the disc has without carrying the disc's text. Patch files are `patches/*.json` +in either `lang/` folder, read in name order for every language. They are applied to the disc's text each +time the file loads, so updating a patch needs no re-export: + +```json +{"patches": [ + {"file": "dun/script/d01/d01_3.mes", "id": 12, "replace": [["teh", "the"], ["recieve", "receive"]]}, + {"file": "commenu.emenu.pak.allmenu", "id": 11, "original": "0a1b2c3d4e5f6a7b", + "text": "\"Item\"\nUse or equip items."} +]} +``` + +- `file` is the path the game loads or the message file's key. +- `replace` is a list of `[find, with]` pairs; every occurrence of `find` becomes `with`. A pair whose `find` + is not in the message is skipped and reported; if none match, the patch is. +- `text` replaces the message whole. Give exactly one of `replace` and `text`. +- `original` (optional) is the hash of the disc's whole message (a 64-bit FNV-1a of the UTF-8 text, as 16 hex + digits; the export lists them in `hashes/`). The patch applies only when it matches, so a different disc, + or a message already changed, is left alone. +- A message that the language's own file also has is not patched: the explicit text wins. +- Patches in one file chain in order, so a later `find` sees an earlier patch's result. +- The shipped patches hold only the words that change, so the corrected text exists only on the user's + machine, built from their own disc. + +## File encoding + +Every file the port writes is UTF-8 without a byte-order mark, and letters are written as themselves, never +as `\uXXXX` escapes, so `Crème` and `Jäger-Ohrring` read as they are in any editor. The port reads UTF-8 +files (a BOM is not expected). + +**Japanese is the exception, for now.** The Japanese messages are not text but numbers: each is the index +(0 to 784) of a glyph in the game's own Japanese font, which is not Unicode, so `ja_jp.json` has them as +`{237}{9}{2}...`. Writing them as real Japanese needs a table from those indices to characters, which the +port does not have yet. Until then they stay as the codes, which still round-trip exactly, and the other +languages are unaffected. + +## What the game font can draw + +The PAL font has the 88 letters, digits and marks of its `gaiji.img` and the accented letters +`ÀÁÂÄÈÉÊËÌÍÎÏÑÒÓÔÖÙÚÛÜàáâäèéêëìíîïñòóôöùúûü`, `œ Œ ß Ç ç ¡ ¿`, and the curly quotes and dashes become the +plain ones. Anything else (`;`, `_`, `~`, a Cyrillic or Greek letter) is drawn as `?`, and the port tells you +once per file how many characters did that. The full table is in [PC.md](PC.md#game-text). + +## Crisp text from a TrueType font + +The game draws its text from 14x20 bitmaps, which blur when the picture is scaled up. Put a TrueType file +called `font.ttf` in a `lang/` folder (the save folder's, then the executable's) and the port draws every +character the font has from it instead, rasterized at the screen's own resolution, in the cell the game laid +the bitmap character out in (size, colour, outline and the cursor row's flashing are the game's own). A +character the font lacks, and the icons and pad buttons, still draw as bitmaps; a font that has blank +placeholders for the accented letters, the cedilla, `¡`, `¿`, `œ` and `Œ` gets them built from its own +letters and marks. The port logs `font: message text from ` when it loads one. + +- It works for every message the game draws through its message windows, whatever its source: the disc's + text, the JSON's, item and monster names. +- It does not touch text that is a picture: the large menu words (ITEM, WEAPON...), the title screen's and + the like are textures, and stay as they are. +- A word keeps the cells the game gave it: its letters are spaced by the font inside the word and the word + is centred on its cells, so rows, indents and the icons between words stay where the game put them. +- The font is not shipped with the port: each user adds a file they have the right to use. + +## Limits, and what happens when they are passed + +A text that does not fit is left as the disc has it, and the port says so once on stderr (`localize: ...`): + +- a window shows at most 720 characters (`{N}` fields count as what they expand to); +- a message file's offsets are s16, so a file is at most about 32,000 characters in all; +- a message file the game reads from a **loose file** (`dun/script/...`, `gedit/...`, `meswin/...`) is read + into a buffer sized for the disc's file, rounded up to whole 2048-byte sectors. A translated file that + comes to more than that keeps the disc's text for the whole file. Translate longer by shortening + elsewhere in the file, or put the text in a message file the game reads out of a pack, which has no such + limit. + +Bad JSON (not JSON, a key that is not a file key and an id, a value that is not a string) is reported once +and the file is ignored; the game runs on disc text. + +## Memory + +The JSON for the current language (the language's file, from both folders) is held parsed in memory: about +100 bytes of bookkeeping per message plus the text. A translation that changes some messages is a few +hundred KB; a full export of the disc's English is about 3.5 MB. A message file taken from a pack is rebuilt +once and kept (about 80 KB each, about 2 MB for every pack). Loose message files are rebuilt each time the +game loads them and not kept. Reading a file costs a transient 3 to 4 times its size. With `font.ttf`, the +glyph atlas is one 2048 x 2048 texture (16 MB of video memory). + +Set `DC_LOCALIZE_TRACE=1` to log every message file the game loads and whether the JSON has an entry for it. + +## Not done + +- Japanese as characters (above). +- Other text the port may draw itself later: a screen that adds rows only has to follow the key scheme above. +- Reloading a JSON file without restarting the game. + +## In the code + +`port/src/localize.{hpp,cpp}`: `LocalizeText` and `LocalizeFormat` give the port's own strings, +`LocalizeMessageKey` makes a file's key, `LocalizeParse` reads a language's +file, `LocalizeMessages` lays texts over a disc message file (through `GameTextFile`), `LocalizeFile` and +`LocalizePack` are the hooks, and `LocalizeExport` is `--export-text`. The Options screen asks +`LocalizeText` for each of its words (`port/src/menu_option.cpp`; `OptionStrings()` lists them for the export), +and lays its text out again when it is opened in another language. The hooks sit in +`port/src/dataread.cpp`: `LoadFile2` and `LoadFileBG` pass each file they read to `LocalizeLoaded` and +`LocalizeFile`, and the port's `GetPackFile` gives a message entry through `LocalizePack`. Tests: +`port/src/tests/localize_test.cpp`. diff --git a/docs/PC.md b/docs/PC.md index fb0035735..bfe857804 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -1197,6 +1197,9 @@ without one does. A message file is s16 throughout: an id is -0x8000 to the last message by id may extend beyond that range. `GameTextFile::Set` gives back -1 and leaves the file as it was where either would not hold. +The text of retail's message files can be replaced from JSON, one file per language, with the +disc's text as the fallback: see [LOCALIZATION.md](LOCALIZATION.md). + ## The Options screen The game's Options screen, from the title, the town menu and the dungeon menu, diff --git a/port/CMakeLists.txt b/port/CMakeLists.txt index 84caae52b..daaf0d501 100644 --- a/port/CMakeLists.txt +++ b/port/CMakeLists.txt @@ -34,6 +34,11 @@ FetchContent_Declare(nlohmann_json FIND_PACKAGE_ARGS CONFIG) FetchContent_MakeAvailable(nlohmann_json) +# The message font's rasterizer (platform/ttffont.cpp): a single-file library, public domain or MIT, fetched at a pinned commit. +set(TTF_DIR ${CMAKE_BINARY_DIR}/stb_truetype) +file(DOWNLOAD https://raw.githubusercontent.com/nothings/stb/2c980bb59875b0d32144a71867fbdebb2f77cd20/stb_truetype.h + ${TTF_DIR}/stb_truetype.h EXPECTED_HASH SHA256=ecd30b05e0dd4fea3a13c26810dd9e1992dc379049482c393d5a19e6b5090aab) + set(PS2_DIR ${CMAKE_SOURCE_DIR}/ps2/src) set(PORT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) file(GLOB_RECURSE PS2_SOURCES CONFIGURE_DEPENDS RELATIVE ${PS2_DIR} ${PS2_DIR}/*.cpp) @@ -177,12 +182,12 @@ file(CONFIGURE OUTPUT ${SHADER_HEADER_DIR}/icon/chronicle.hpp CONTENT "#pragma once\n#include \ninline constexpr int kIconSize = 128;\ninline constexpr std::uint8_t kIconRgba[] = {${icon_bytes}};\n") add_library(dc_host OBJECT ${HOST_SOURCES} ${SHADER_HEADERS}) -target_include_directories(dc_host PRIVATE ${PORT_DIR} ${SHADER_HEADER_DIR}) +target_include_directories(dc_host PRIVATE ${PORT_DIR} ${SHADER_HEADER_DIR} ${TTF_DIR}) target_link_libraries(dc_host PRIVATE SDL3::SDL3 Vulkan::Vulkan nlohmann_json::nlohmann_json) target_compile_options(dc_host PRIVATE -Wall -ffunction-sections -fdata-sections) add_library(dc_port OBJECT ${PORT_LIB_SOURCES}) -target_link_libraries(dc_port PRIVATE dc_options SDL3::SDL3 Vulkan::Vulkan) +target_link_libraries(dc_port PRIVATE dc_options SDL3::SDL3 Vulkan::Vulkan nlohmann_json::nlohmann_json) # Replacement units reach the host side as "gfx/gfx.hpp", "platform/window.hpp". target_include_directories(dc_port PRIVATE ${PORT_DIR}) # The game's headers trip a few warnings that only describe how MWCC's source @@ -233,6 +238,14 @@ endif() include(GNUInstallDirs) install(TARGETS darkcloud RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +# The strings the port draws itself, translated (docs/LOCALIZATION.md): lang/ beside the executable, where the +# game reads its language files. +add_custom_target(dc_lang + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/lang $/lang + COMMENT "Copying the port's translated strings") +add_dependencies(darkcloud dc_lang) +install(DIRECTORY lang/ DESTINATION ${CMAKE_INSTALL_BINDIR}/lang) + # The tests use an installed GoogleTest, or fetch one at configure time; a build with neither turns them off. option(DC_BUILD_TESTS "Build darkcloud_tests" ON) if(DC_BUILD_TESTS) diff --git a/port/lang/de_de.json b/port/lang/de_de.json new file mode 100644 index 000000000..a6c466771 --- /dev/null +++ b/port/lang/de_de.json @@ -0,0 +1,142 @@ +{ + "options.shortcuts": "{-759} Standard\n{-761} Verwerfen\n{-762} Verlassen", + "options.help.save_failed": "Speichern misslang.\nÄnderungen gelten.\nVerlassen: erneut\nconfig.json schreiben.", + "options.help.display_kept": "Die Anzeige konnte\nnicht wechseln und\nbleibt im alten Modus.\nAnderen Modus wählen.", + "options.help.tabs": "\"Optionen\"\n{-766} {-765} oder links/\nrechts: Seite wechseln.", + "options.help.exit": "{-760} Verlassen\n{-759} Seitenstandard\n{-761} Alles verwerfen\n{-762} Verlassen (Zeile)", + "options.help.display_now": "Die Anzeige konnte\nnicht wechseln. Sie ist\njetzt %1", + "options.display.fullscreen_now": "Vollbild.", + "options.display.window_now": "ein Fenster\nvon %1 x %2.", + "options.page.game": "Spiel", + "options.game.save_cursor_position.label": "Cursor merken", + "options.game.save_cursor_position.choice.0": "An", + "options.game.save_cursor_position.choice.1": "Aus", + "options.game.message_speed.label": "Textgeschwindigkeit", + "options.game.message_speed.choice.0": "Normal", + "options.game.message_speed.choice.1": "Schnell", + "options.game.clock.label": "Uhr", + "options.game.clock.choice.0": "An", + "options.game.clock.choice.1": "Aus", + "options.game.time_speed.label": "Zeitgeschwindigkeit", + "options.game.time_speed.choice.0": "Normal", + "options.game.time_speed.choice.1": "Schnell", + "options.game.map.label": "Dungeon-Karte", + "options.game.map.choice.0": "1", + "options.game.map.choice.1": "2", + "options.game.map.choice.2": "3", + "options.game.map.choice.3": "Aus", + "options.game.enemy_damage.label": "Gegnerschaden", + "options.game.enemy_damage.choice.0": "An", + "options.game.enemy_damage.choice.1": "Aus", + "options.game.player_damage.label": "Gruppenschaden", + "options.game.player_damage.choice.0": "An", + "options.game.player_damage.choice.1": "Aus", + "options.game.enemy_hp.label": "Gegner-LP", + "options.game.enemy_hp.choice.0": "An", + "options.game.enemy_hp.choice.1": "Aus", + "options.game.names.label": "Namen", + "options.game.names.choice.0": "An", + "options.game.names.choice.1": "Aus", + "options.discord.rich_presence.label": "Discord aktivieren", + "options.discord.rich_presence.help": "\"Discord Rich Presence\"\nZeigt auf Discord,\nwas du spielst.", + "options.discord.rich_presence.choice.0": "An", + "options.discord.rich_presence.choice.1": "Aus", + "options.game.language.label": "Sprache", + "options.game.language.help": "\"Sprache\"\nSprache des Spiels.\nFragen: Auswahl beim\nStart anzeigen.", + "options.game.language.choice.0": "Fragen", + "options.game.language.choice.1": "English", + "options.game.language.choice.2": "Français", + "options.game.language.choice.3": "Deutsch", + "options.game.language.choice.4": "Italiano", + "options.game.language.choice.5": "Español", + "options.page.display": "Anzeige", + "options.video.fullscreen.label": "Fenstermodus", + "options.video.fullscreen.help": "\"Fenstermodus\"\nIm Fenster oder im\nVollbild spielen.", + "options.video.fullscreen.choice.0": "Fenster", + "options.video.fullscreen.choice.1": "Vollbild", + "options.video.width.label": "Auflösung", + "options.video.width.help": "\"Auflösung\"\nGröße des Fensters.\nVollbild und Desktop\nnutzen den Monitor.", + "options.video.present_mode.label": "V-Sync", + "options.video.present_mode.help": "\"V-Sync\"\nAn: kein Tearing.\nSchnell: weniger\nLatenz. Aus: Tearing.", + "options.video.present_mode.choice.0": "An", + "options.video.present_mode.choice.1": "Schnell", + "options.video.present_mode.choice.2": "Aus", + "options.video.max_fps.label": "Bildratenlimit", + "options.video.max_fps.help": "\"Bildratenlimit\"\nDie meisten Bilder\npro Sekunde.", + "options.video.aspect.label": "Seitenverhältnis", + "options.video.aspect.help": "\"Seitenverhältnis\"\nBreit: füllt das\nFenster. 4:3: das Bild\nder PS2.", + "options.video.aspect.choice.0": "Breit", + "options.video.aspect.choice.1": "4:3", + "options.video.ui_scale.label": "Menügröße", + "options.video.ui_scale.help": "\"Menügröße\"\nGröße von HUD und\nMenüs, nach dem\nVerlassen der Optionen.", + "options.video.interpolation.label": "Weiche Bewegung", + "options.video.interpolation.help": "\"Weiche Bewegung\"\nZeichnet Bilder\nzwischen den Schritten.", + "options.video.interpolation.choice.0": "An", + "options.video.interpolation.choice.1": "Aus", + "options.video.show_fps.label": "FPS-Anzeige", + "options.video.show_fps.help": "\"FPS-Anzeige\"\nZeigt die Bildrate\nin der Ecke.", + "options.video.show_fps.choice.0": "An", + "options.video.show_fps.choice.1": "Aus", + "options.video.soft_focus.label": "Weichzeichner", + "options.video.soft_focus.choice.0": "An", + "options.video.soft_focus.choice.1": "Aus", + "options.page.audio": "Audio", + "options.audio.master_volume.label": "Lautstärke", + "options.audio.master_volume.help": "\"Lautstärke\"\nWie laut das Spiel ist.", + "options.audio.sound.label": "Ton", + "options.audio.sound.choice.0": "Stereo", + "options.audio.sound.choice.1": "Mono", + "options.page.controls": "Steuerung", + "options.input.vibration.label": "Vibration", + "options.input.vibration.choice.0": "An", + "options.input.vibration.choice.1": "Aus", + "options.input.mouse_sensitivity.label": "Mausempfindlichkeit", + "options.input.mouse_sensitivity.help": "\"Mausempfindlichkeit\"\nWie schnell die Maus\ndie Kamera dreht.", + "options.input.mouse_invert_y.label": "Maus Y umkehren", + "options.input.mouse_invert_y.help": "\"Maus Y umkehren\"\nMaus nach oben:\nBlick nach unten.", + "options.input.mouse_invert_y.choice.0": "An", + "options.input.mouse_invert_y.choice.1": "Aus", + "options.input.stick_sensitivity.label": "Stick-Empfindlichk.", + "options.input.stick_sensitivity.help": "\"Stick-Empfindlichk.\"\nWie weit der Stick\nkippen muss.", + "options.input.stick_invert_x.label": "Stick X umkehren", + "options.input.stick_invert_x.help": "\"Stick X umkehren\"\nKehrt Kamera links\nund rechts um.", + "options.input.stick_invert_x.choice.0": "An", + "options.input.stick_invert_x.choice.1": "Aus", + "options.input.stick_invert_y.label": "Stick Y umkehren", + "options.input.stick_invert_y.help": "\"Stick Y umkehren\"\nKehrt Kamera oben\nund unten um.", + "options.input.stick_invert_y.choice.0": "An", + "options.input.stick_invert_y.choice.1": "Aus", + "options.input.gyro.label": "Gyro", + "options.input.gyro.help": "\"Gyro\"\nWenn das Neigen des\nPads die Kamera dreht.", + "options.input.gyro.choice.0": "Aus", + "options.input.gyro.choice.1": "Immer", + "options.input.gyro.choice.2": "Ego-Sicht", + "options.input.gyro.choice.3": "Beim Halten", + "options.input.gyro_sensitivity.label": "Gyro-Empfindlichk.", + "options.input.gyro_sensitivity.help": "\"Gyro-Empfindlichk.\"\nWie schnell Neigen\ndie Kamera dreht.", + "options.input.gyro_invert_x.label": "Gyro X umkehren", + "options.input.gyro_invert_x.help": "\"Gyro X umkehren\"\nKehrt Gyro links\nund rechts um.", + "options.input.gyro_invert_x.choice.0": "An", + "options.input.gyro_invert_x.choice.1": "Aus", + "options.input.gyro_invert_y.label": "Gyro Y umkehren", + "options.input.gyro_invert_y.help": "\"Gyro Y umkehren\"\nKehrt Gyro oben\nund unten um.", + "options.input.gyro_invert_y.choice.0": "An", + "options.input.gyro_invert_y.choice.1": "Aus", + "options.video.width.value.Desktop": "Desktop", + "options.video.max_fps.value.Unlimited": "Unbegrenzt", + "options.game.qte_always_win.label": "QTEs immer gewinnen", + "options.game.qte_always_win.help": "\"QTEs immer gewinnen\"\nTastenabfragen enden\nimmer perfekt.", + "options.game.qte_always_win.choice.0": "An", + "options.game.qte_always_win.choice.1": "Aus", + "options.video.fps_detail.label": "FPS-Info", + "options.video.fps_detail.help": "\"FPS-Info\"\nWas der Zähler zeigt:\nnur FPS, mit Ticks\noder alles.", + "options.video.fps_detail.choice.0": "FPS", + "options.video.fps_detail.choice.1": "FPS+Ticks", + "options.video.fps_detail.choice.2": "Alles", + "options.input.mouse_zoom.label": "Mausrad-Zoom", + "options.input.mouse_zoom.help": "\"Mausrad-Zoom\"\nScrollen, um näher\noder weiter weg von der\nFigur zu gehen.", + "options.input.mouse_zoom.choice.0": "An", + "options.input.mouse_zoom.choice.1": "Aus", + "options.input.bindings.zoom_reset.label": "Zoom zurücksetzen", + "options.input.bindings.zoom_reset.help": "\"Zoom zurücksetzen\"\nStellt normalen\nKameraabstand her." +} diff --git a/port/lang/es_es.json b/port/lang/es_es.json new file mode 100644 index 000000000..e97fd78b1 --- /dev/null +++ b/port/lang/es_es.json @@ -0,0 +1,142 @@ +{ + "options.shortcuts": "{-759} Predeterm.\n{-761} Deshacer\n{-762} Cerrar", + "options.help.save_failed": "No se pudo guardar.\nLos cambios se aplican.\nCierra para reintentar\nescribir config.json.", + "options.help.display_kept": "La pantalla no pudo\ncambiar y mantiene\nsu modo. Prueba con\notro modo o tamaño.", + "options.help.tabs": "\"Opciones\"\n{-766} {-765} o izq./\nder.: cambiar página.", + "options.help.exit": "{-760} Cerrar\n{-759} Valores de la página\n{-761} Deshacer todo\n{-762} Cerrar (toda fila)", + "options.help.display_now": "La pantalla no pudo\ncambiar. Ahora es\n%1", + "options.display.fullscreen_now": "pantalla completa.", + "options.display.window_now": "una ventana\nde %1 x %2.", + "options.page.game": "Juego", + "options.game.save_cursor_position.label": "Guardar cursor", + "options.game.save_cursor_position.choice.0": "Sí", + "options.game.save_cursor_position.choice.1": "No", + "options.game.message_speed.label": "Velocidad mensajes", + "options.game.message_speed.choice.0": "Normal", + "options.game.message_speed.choice.1": "Rápido", + "options.game.clock.label": "Reloj", + "options.game.clock.choice.0": "Sí", + "options.game.clock.choice.1": "No", + "options.game.time_speed.label": "Velocidad del tiempo", + "options.game.time_speed.choice.0": "Normal", + "options.game.time_speed.choice.1": "Rápido", + "options.game.map.label": "Mapa de mazmorra", + "options.game.map.choice.0": "1", + "options.game.map.choice.1": "2", + "options.game.map.choice.2": "3", + "options.game.map.choice.3": "No", + "options.game.enemy_damage.label": "Daño a enemigos", + "options.game.enemy_damage.choice.0": "Sí", + "options.game.enemy_damage.choice.1": "No", + "options.game.player_damage.label": "Daño al grupo", + "options.game.player_damage.choice.0": "Sí", + "options.game.player_damage.choice.1": "No", + "options.game.enemy_hp.label": "PV enemigos", + "options.game.enemy_hp.choice.0": "Sí", + "options.game.enemy_hp.choice.1": "No", + "options.game.names.label": "Nombres", + "options.game.names.choice.0": "Sí", + "options.game.names.choice.1": "No", + "options.discord.rich_presence.label": "Activar Discord", + "options.discord.rich_presence.help": "\"Discord Rich Presence\"\nMuestra en Discord a\nqué estás jugando.", + "options.discord.rich_presence.choice.0": "Sí", + "options.discord.rich_presence.choice.1": "No", + "options.game.language.label": "Idioma", + "options.game.language.help": "\"Idioma\"\nEl idioma del juego.\nPreguntar: muestra la\nelección al iniciar.", + "options.game.language.choice.0": "Preguntar", + "options.game.language.choice.1": "English", + "options.game.language.choice.2": "Français", + "options.game.language.choice.3": "Deutsch", + "options.game.language.choice.4": "Italiano", + "options.game.language.choice.5": "Español", + "options.page.display": "Pantalla", + "options.video.fullscreen.label": "Modo de ventana", + "options.video.fullscreen.help": "\"Modo de ventana\"\nJuega en ventana o\nen pantalla completa.", + "options.video.fullscreen.choice.0": "Ventana", + "options.video.fullscreen.choice.1": "Completa", + "options.video.width.label": "Resolución", + "options.video.width.help": "\"Resolución\"\nTamaño de la ventana.\nCompleta y Escritorio\nusan todo el monitor.", + "options.video.present_mode.label": "V-Sync", + "options.video.present_mode.help": "\"V-Sync\"\nSí: sin cortes.\nRápido: menos retraso.\nNo: puede haber cortes.", + "options.video.present_mode.choice.0": "Sí", + "options.video.present_mode.choice.1": "Rápido", + "options.video.present_mode.choice.2": "No", + "options.video.max_fps.label": "Límite de FPS", + "options.video.max_fps.help": "\"Límite de FPS\"\nLos máximos cuadros\npor segundo.", + "options.video.aspect.label": "Relación aspecto", + "options.video.aspect.help": "\"Relación aspecto\"\nAncho: llena la\nventana. 4:3: la imagen\nde PS2.", + "options.video.aspect.choice.0": "Ancho", + "options.video.aspect.choice.1": "4:3", + "options.video.ui_scale.label": "Tamaño interfaz", + "options.video.ui_scale.help": "\"Tamaño interfaz\"\nTamaño del HUD y los\nmenús, al cerrar las\nopciones.", + "options.video.interpolation.label": "Movimiento suave", + "options.video.interpolation.help": "\"Movimiento suave\"\nDibuja cuadros entre\nlos pasos del juego.", + "options.video.interpolation.choice.0": "Sí", + "options.video.interpolation.choice.1": "No", + "options.video.show_fps.label": "Contador de FPS", + "options.video.show_fps.help": "\"Contador de FPS\"\nMuestra los cuadros\npor segundo arriba.", + "options.video.show_fps.choice.0": "Sí", + "options.video.show_fps.choice.1": "No", + "options.video.soft_focus.label": "Enfoque suave", + "options.video.soft_focus.choice.0": "Sí", + "options.video.soft_focus.choice.1": "No", + "options.page.audio": "Audio", + "options.audio.master_volume.label": "Volumen", + "options.audio.master_volume.help": "\"Volumen\"\nQué tan alto suena.", + "options.audio.sound.label": "Sonido", + "options.audio.sound.choice.0": "Estéreo", + "options.audio.sound.choice.1": "Mono", + "options.page.controls": "Controles", + "options.input.vibration.label": "Vibración", + "options.input.vibration.choice.0": "Sí", + "options.input.vibration.choice.1": "No", + "options.input.mouse_sensitivity.label": "Sensibilidad ratón", + "options.input.mouse_sensitivity.help": "\"Sensibilidad ratón\"\nQué tan rápido gira\nla cámara el ratón.", + "options.input.mouse_invert_y.label": "Invertir ratón Y", + "options.input.mouse_invert_y.help": "\"Invertir ratón Y\"\nRatón hacia arriba:\nmira hacia abajo.", + "options.input.mouse_invert_y.choice.0": "Sí", + "options.input.mouse_invert_y.choice.1": "No", + "options.input.stick_sensitivity.label": "Sensibilidad stick", + "options.input.stick_sensitivity.help": "\"Sensibilidad stick\"\nCuánto hay que\ninclinar el stick.", + "options.input.stick_invert_x.label": "Invertir stick X", + "options.input.stick_invert_x.help": "\"Invertir stick X\"\nInvierte la cámara a\nizquierda y derecha.", + "options.input.stick_invert_x.choice.0": "Sí", + "options.input.stick_invert_x.choice.1": "No", + "options.input.stick_invert_y.label": "Invertir stick Y", + "options.input.stick_invert_y.help": "\"Invertir stick Y\"\nInvierte la cámara\narriba y abajo.", + "options.input.stick_invert_y.choice.0": "Sí", + "options.input.stick_invert_y.choice.1": "No", + "options.input.gyro.label": "Giroscopio", + "options.input.gyro.help": "\"Giroscopio\"\nCuando inclinar el\nmando gira la cámara.", + "options.input.gyro.choice.0": "No", + "options.input.gyro.choice.1": "Siempre", + "options.input.gyro.choice.2": "Primera persona", + "options.input.gyro.choice.3": "Al mantener", + "options.input.gyro_sensitivity.label": "Sensib. giroscopio", + "options.input.gyro_sensitivity.help": "\"Sensib. giroscopio\"\nQué tan rápido gira\nal inclinar.", + "options.input.gyro_invert_x.label": "Invertir giroscopio X", + "options.input.gyro_invert_x.help": "\"Invertir giroscopio X\"\nInvierte el giroscopio\na izquierda y derecha.", + "options.input.gyro_invert_x.choice.0": "Sí", + "options.input.gyro_invert_x.choice.1": "No", + "options.input.gyro_invert_y.label": "Invertir giroscopio Y", + "options.input.gyro_invert_y.help": "\"Invertir giroscopio Y\"\nInvierte el giroscopio\narriba y abajo.", + "options.input.gyro_invert_y.choice.0": "Sí", + "options.input.gyro_invert_y.choice.1": "No", + "options.video.width.value.Desktop": "Escritorio", + "options.video.max_fps.value.Unlimited": "Sin límite", + "options.game.qte_always_win.label": "QTE siempre ganados", + "options.game.qte_always_win.help": "\"QTE siempre ganados\"\nLos botones a tiempo\nterminan en perfecto.", + "options.game.qte_always_win.choice.0": "Sí", + "options.game.qte_always_win.choice.1": "No", + "options.video.fps_detail.label": "Info de FPS", + "options.video.fps_detail.help": "\"Info de FPS\"\nQué muestra el\ncontador: solo FPS,\ncon ticks, o todo.", + "options.video.fps_detail.choice.0": "FPS", + "options.video.fps_detail.choice.1": "FPS+Ticks", + "options.video.fps_detail.choice.2": "Todo", + "options.input.mouse_zoom.label": "Zoom con rueda", + "options.input.mouse_zoom.help": "\"Zoom con rueda\"\nDesplázate para\nacercarte o alejarte\ndel personaje.", + "options.input.mouse_zoom.choice.0": "Sí", + "options.input.mouse_zoom.choice.1": "No", + "options.input.bindings.zoom_reset.label": "Restablecer zoom", + "options.input.bindings.zoom_reset.help": "\"Restablecer zoom\"\nRestaura la distancia\nnormal de la cámara." +} diff --git a/port/lang/fr_fr.json b/port/lang/fr_fr.json new file mode 100644 index 000000000..ac3ec544d --- /dev/null +++ b/port/lang/fr_fr.json @@ -0,0 +1,142 @@ +{ + "options.shortcuts": "{-759} Défauts\n{-761} Annuler\n{-762} Fermer", + "options.help.save_failed": "Sauvegarde impossible.\nChangements actifs.\nFermez pour réessayer\nconfig.json.", + "options.help.display_kept": "L'écran n'a pas pu\nchanger et garde son\nancien mode. Essayez\nun autre mode.", + "options.help.tabs": "\"Options\"\n{-766} {-765} ou gauche/\ndroite: changer page.", + "options.help.exit": "{-760} Fermer\n{-759} Défauts de la page\n{-761} Tout annuler\n{-762} Fermer (toute ligne)", + "options.help.display_now": "L'écran n'a pas pu\nchanger. Il est\ndésormais %1", + "options.display.fullscreen_now": "plein écran.", + "options.display.window_now": "une fenêtre\nde %1 x %2.", + "options.page.game": "Jeu", + "options.game.save_cursor_position.label": "Mémoriser curseur", + "options.game.save_cursor_position.choice.0": "Activé", + "options.game.save_cursor_position.choice.1": "Désactivé", + "options.game.message_speed.label": "Vitesse messages", + "options.game.message_speed.choice.0": "Normal", + "options.game.message_speed.choice.1": "Rapide", + "options.game.clock.label": "Horloge", + "options.game.clock.choice.0": "Activé", + "options.game.clock.choice.1": "Désactivé", + "options.game.time_speed.label": "Vitesse du temps", + "options.game.time_speed.choice.0": "Normal", + "options.game.time_speed.choice.1": "Rapide", + "options.game.map.label": "Carte du donjon", + "options.game.map.choice.0": "1", + "options.game.map.choice.1": "2", + "options.game.map.choice.2": "3", + "options.game.map.choice.3": "Désactivé", + "options.game.enemy_damage.label": "Dégâts ennemis", + "options.game.enemy_damage.choice.0": "Activé", + "options.game.enemy_damage.choice.1": "Désactivé", + "options.game.player_damage.label": "Dégâts équipe", + "options.game.player_damage.choice.0": "Activé", + "options.game.player_damage.choice.1": "Désactivé", + "options.game.enemy_hp.label": "PV ennemis", + "options.game.enemy_hp.choice.0": "Activé", + "options.game.enemy_hp.choice.1": "Désactivé", + "options.game.names.label": "Noms", + "options.game.names.choice.0": "Activé", + "options.game.names.choice.1": "Désactivé", + "options.discord.rich_presence.label": "Activer Discord", + "options.discord.rich_presence.help": "\"Discord Rich Presence\"\nMontre à quoi vous\njouez sur Discord.", + "options.discord.rich_presence.choice.0": "Activé", + "options.discord.rich_presence.choice.1": "Désactivé", + "options.game.language.label": "Langue", + "options.game.language.help": "\"Langue\"\nLangue du jeu.\nDemander: écran de\nchoix au démarrage.", + "options.game.language.choice.0": "Demander", + "options.game.language.choice.1": "English", + "options.game.language.choice.2": "Français", + "options.game.language.choice.3": "Deutsch", + "options.game.language.choice.4": "Italiano", + "options.game.language.choice.5": "Español", + "options.page.display": "Affichage", + "options.video.fullscreen.label": "Mode d'affichage", + "options.video.fullscreen.help": "\"Mode d'affichage\"\nJouer en fenêtre ou\nen plein écran.", + "options.video.fullscreen.choice.0": "Fenêtré", + "options.video.fullscreen.choice.1": "Plein écran", + "options.video.width.label": "Résolution", + "options.video.width.help": "\"Résolution\"\nTaille de la fenêtre.\nPlein écran et Bureau\noccupent tout l'écran.", + "options.video.present_mode.label": "V-Sync", + "options.video.present_mode.help": "\"V-Sync\"\nActivé: sans déchirure\nRapide: moins de délai\nDésactivé: déchirure", + "options.video.present_mode.choice.0": "Activé", + "options.video.present_mode.choice.1": "Rapide", + "options.video.present_mode.choice.2": "Désactivé", + "options.video.max_fps.label": "Limite d'images", + "options.video.max_fps.help": "\"Limite d'images\"\nNombre maximal\nd'images par seconde.", + "options.video.aspect.label": "Format d'image", + "options.video.aspect.help": "\"Format d'image\"\nLarge: remplit la\nfenêtre. 4:3: l'image\nde la PS2.", + "options.video.aspect.choice.0": "Large", + "options.video.aspect.choice.1": "4:3", + "options.video.ui_scale.label": "Taille interface", + "options.video.ui_scale.help": "\"Taille interface\"\nTaille du HUD et des\nmenus, appliquée à la\nfermeture des options.", + "options.video.interpolation.label": "Fluidité", + "options.video.interpolation.help": "\"Fluidité\"\nAffiche des images\nentre les étapes.", + "options.video.interpolation.choice.0": "Activé", + "options.video.interpolation.choice.1": "Désactivé", + "options.video.show_fps.label": "Compteur d'IPS", + "options.video.show_fps.help": "\"Compteur d'IPS\"\nAffiche la cadence\nd'images dans le coin.", + "options.video.show_fps.choice.0": "Activé", + "options.video.show_fps.choice.1": "Désactivé", + "options.video.soft_focus.label": "Flou doux", + "options.video.soft_focus.choice.0": "Activé", + "options.video.soft_focus.choice.1": "Désactivé", + "options.page.audio": "Audio", + "options.audio.master_volume.label": "Volume", + "options.audio.master_volume.help": "\"Volume\"\nLe volume du jeu.", + "options.audio.sound.label": "Son", + "options.audio.sound.choice.0": "Stéréo", + "options.audio.sound.choice.1": "Mono", + "options.page.controls": "Commandes", + "options.input.vibration.label": "Vibration", + "options.input.vibration.choice.0": "Activé", + "options.input.vibration.choice.1": "Désactivé", + "options.input.mouse_sensitivity.label": "Sensibilité souris", + "options.input.mouse_sensitivity.help": "\"Sensibilité souris\"\nVitesse de la souris\npour la caméra.", + "options.input.mouse_invert_y.label": "Inverser souris Y", + "options.input.mouse_invert_y.help": "\"Inverser souris Y\"\nSouris vers le haut:\nregard vers le bas.", + "options.input.mouse_invert_y.choice.0": "Activé", + "options.input.mouse_invert_y.choice.1": "Désactivé", + "options.input.stick_sensitivity.label": "Sensibilité stick", + "options.input.stick_sensitivity.help": "\"Sensibilité stick\"\nInclinaison du stick\nnécessaire.", + "options.input.stick_invert_x.label": "Inverser stick X", + "options.input.stick_invert_x.help": "\"Inverser stick X\"\nInverse la caméra\ngauche et droite.", + "options.input.stick_invert_x.choice.0": "Activé", + "options.input.stick_invert_x.choice.1": "Désactivé", + "options.input.stick_invert_y.label": "Inverser stick Y", + "options.input.stick_invert_y.help": "\"Inverser stick Y\"\nInverse la caméra\nhaut et bas.", + "options.input.stick_invert_y.choice.0": "Activé", + "options.input.stick_invert_y.choice.1": "Désactivé", + "options.input.gyro.label": "Gyroscope", + "options.input.gyro.help": "\"Gyroscope\"\nL'inclinaison de la\nmanette oriente la\ncaméra.", + "options.input.gyro.choice.0": "Désactivé", + "options.input.gyro.choice.1": "Toujours", + "options.input.gyro.choice.2": "Vue subjective", + "options.input.gyro.choice.3": "Si maintenu", + "options.input.gyro_sensitivity.label": "Sensibilité gyro", + "options.input.gyro_sensitivity.help": "\"Sensibilité gyro\"\nVitesse du gyroscope\npour la caméra.", + "options.input.gyro_invert_x.label": "Inverser gyro X", + "options.input.gyro_invert_x.help": "\"Inverser gyro X\"\nInverse la gauche et\nla droite du gyro.", + "options.input.gyro_invert_x.choice.0": "Activé", + "options.input.gyro_invert_x.choice.1": "Désactivé", + "options.input.gyro_invert_y.label": "Inverser gyro Y", + "options.input.gyro_invert_y.help": "\"Inverser gyro Y\"\nInverse le haut et\nle bas du gyro.", + "options.input.gyro_invert_y.choice.0": "Activé", + "options.input.gyro_invert_y.choice.1": "Désactivé", + "options.video.width.value.Desktop": "Bureau", + "options.video.max_fps.value.Unlimited": "Illimitée", + "options.game.qte_always_win.label": "QTE toujours gagnés", + "options.game.qte_always_win.help": "\"QTE toujours gagnés\"\nLes invites de touches\nfinissent en parfait.", + "options.game.qte_always_win.choice.0": "Activé", + "options.game.qte_always_win.choice.1": "Désactivé", + "options.video.fps_detail.label": "Infos IPS", + "options.video.fps_detail.help": "\"Infos IPS\"\nCe que montre le\ncompteur: IPS seuls,\navec ticks, ou tout.", + "options.video.fps_detail.choice.0": "IPS", + "options.video.fps_detail.choice.1": "IPS+Ticks", + "options.video.fps_detail.choice.2": "Tout", + "options.input.mouse_zoom.label": "Zoom molette", + "options.input.mouse_zoom.help": "\"Zoom molette\"\nFaites défiler pour\nvous rapprocher ou\nvous éloigner du héros.", + "options.input.mouse_zoom.choice.0": "Activé", + "options.input.mouse_zoom.choice.1": "Désactivé", + "options.input.bindings.zoom_reset.label": "Réinit. zoom", + "options.input.bindings.zoom_reset.help": "\"Réinit. zoom\"\nRétablit la distance\nnormale de la caméra." +} diff --git a/port/lang/it_it.json b/port/lang/it_it.json new file mode 100644 index 000000000..3e85eb9c0 --- /dev/null +++ b/port/lang/it_it.json @@ -0,0 +1,142 @@ +{ + "options.shortcuts": "{-759} Predefiniti\n{-761} Annulla\n{-762} Chiudi", + "options.help.save_failed": "Salvataggio fallito.\nLe modifiche valgono.\nChiudi per riprovare\nscrivere config.json.", + "options.help.display_kept": "Lo schermo non ha\npotuto cambiare e ha\nmantenuto la modalità.\nProva un'altra.", + "options.help.tabs": "\"Opzioni\"\n{-766} {-765} o sinistra/\ndestra: cambia pagina.", + "options.help.exit": "{-760} Chiudi\n{-759} Predefiniti pagina\n{-761} Annulla tutto\n{-762} Chiudi da ogni riga", + "options.help.display_now": "Lo schermo non ha\npotuto cambiare.\nOra è %1", + "options.display.fullscreen_now": "schermo intero.", + "options.display.window_now": "una finestra\ndi %1 x %2.", + "options.page.game": "Gioco", + "options.game.save_cursor_position.label": "Salva cursore", + "options.game.save_cursor_position.choice.0": "Sì", + "options.game.save_cursor_position.choice.1": "No", + "options.game.message_speed.label": "Velocità messaggi", + "options.game.message_speed.choice.0": "Normale", + "options.game.message_speed.choice.1": "Veloce", + "options.game.clock.label": "Orologio", + "options.game.clock.choice.0": "Sì", + "options.game.clock.choice.1": "No", + "options.game.time_speed.label": "Velocità del tempo", + "options.game.time_speed.choice.0": "Normale", + "options.game.time_speed.choice.1": "Veloce", + "options.game.map.label": "Mappa dungeon", + "options.game.map.choice.0": "1", + "options.game.map.choice.1": "2", + "options.game.map.choice.2": "3", + "options.game.map.choice.3": "No", + "options.game.enemy_damage.label": "Danni nemici", + "options.game.enemy_damage.choice.0": "Sì", + "options.game.enemy_damage.choice.1": "No", + "options.game.player_damage.label": "Danni squadra", + "options.game.player_damage.choice.0": "Sì", + "options.game.player_damage.choice.1": "No", + "options.game.enemy_hp.label": "PS nemici", + "options.game.enemy_hp.choice.0": "Sì", + "options.game.enemy_hp.choice.1": "No", + "options.game.names.label": "Nomi", + "options.game.names.choice.0": "Sì", + "options.game.names.choice.1": "No", + "options.discord.rich_presence.label": "Attiva Discord", + "options.discord.rich_presence.help": "\"Discord Rich Presence\"\nMostra su Discord a\ncosa stai giocando.", + "options.discord.rich_presence.choice.0": "Sì", + "options.discord.rich_presence.choice.1": "No", + "options.game.language.label": "Lingua", + "options.game.language.help": "\"Lingua\"\nLa lingua del gioco.\nChiedi: mostra la\nscelta all'avvio.", + "options.game.language.choice.0": "Chiedi", + "options.game.language.choice.1": "English", + "options.game.language.choice.2": "Français", + "options.game.language.choice.3": "Deutsch", + "options.game.language.choice.4": "Italiano", + "options.game.language.choice.5": "Español", + "options.page.display": "Schermo", + "options.video.fullscreen.label": "Modo finestra", + "options.video.fullscreen.help": "\"Modo finestra\"\nGioca in una finestra\no a schermo intero.", + "options.video.fullscreen.choice.0": "Finestra", + "options.video.fullscreen.choice.1": "Schermo intero", + "options.video.width.label": "Risoluzione", + "options.video.width.help": "\"Risoluzione\"\nDimensione finestra.\nIntero e Desktop usano\ntutto il monitor.", + "options.video.present_mode.label": "V-Sync", + "options.video.present_mode.help": "\"V-Sync\"\nSì: niente tearing.\nVeloce: meno ritardo.\nNo: può avere tearing.", + "options.video.present_mode.choice.0": "Sì", + "options.video.present_mode.choice.1": "Veloce", + "options.video.present_mode.choice.2": "No", + "options.video.max_fps.label": "Limite fotogrammi", + "options.video.max_fps.help": "\"Limite fotogrammi\"\nIl massimo di\nfotogrammi al secondo.", + "options.video.aspect.label": "Proporzioni", + "options.video.aspect.help": "\"Proporzioni\"\nAmpio: riempie la\nfinestra. 4:3: come\nsu PS2.", + "options.video.aspect.choice.0": "Ampio", + "options.video.aspect.choice.1": "4:3", + "options.video.ui_scale.label": "Dimensione menu", + "options.video.ui_scale.help": "\"Dimensione menu\"\nDimensione di HUD e\nmenu, dopo la chiusura\ndelle opzioni.", + "options.video.interpolation.label": "Moto fluido", + "options.video.interpolation.help": "\"Moto fluido\"\nDisegna fotogrammi\ntra i passi del gioco.", + "options.video.interpolation.choice.0": "Sì", + "options.video.interpolation.choice.1": "No", + "options.video.show_fps.label": "Contatore FPS", + "options.video.show_fps.help": "\"Contatore FPS\"\nMostra i fotogrammi\nal secondo nell'angolo.", + "options.video.show_fps.choice.0": "Sì", + "options.video.show_fps.choice.1": "No", + "options.video.soft_focus.label": "Fuoco morbido", + "options.video.soft_focus.choice.0": "Sì", + "options.video.soft_focus.choice.1": "No", + "options.page.audio": "Audio", + "options.audio.master_volume.label": "Volume", + "options.audio.master_volume.help": "\"Volume\"\nQuanto forte è il\ngioco.", + "options.audio.sound.label": "Suono", + "options.audio.sound.choice.0": "Stereo", + "options.audio.sound.choice.1": "Mono", + "options.page.controls": "Comandi", + "options.input.vibration.label": "Vibrazione", + "options.input.vibration.choice.0": "Sì", + "options.input.vibration.choice.1": "No", + "options.input.mouse_sensitivity.label": "Sensibilità mouse", + "options.input.mouse_sensitivity.help": "\"Sensibilità mouse\"\nVelocità con cui il\nmouse gira la camera.", + "options.input.mouse_invert_y.label": "Inverti mouse Y", + "options.input.mouse_invert_y.help": "\"Inverti mouse Y\"\nMouse in alto:\nsguardo in basso.", + "options.input.mouse_invert_y.choice.0": "Sì", + "options.input.mouse_invert_y.choice.1": "No", + "options.input.stick_sensitivity.label": "Sensibilità stick", + "options.input.stick_sensitivity.help": "\"Sensibilità stick\"\nQuanto va inclinato\nlo stick.", + "options.input.stick_invert_x.label": "Inverti stick X", + "options.input.stick_invert_x.help": "\"Inverti stick X\"\nInverte la camera a\nsinistra e a destra.", + "options.input.stick_invert_x.choice.0": "Sì", + "options.input.stick_invert_x.choice.1": "No", + "options.input.stick_invert_y.label": "Inverti stick Y", + "options.input.stick_invert_y.help": "\"Inverti stick Y\"\nInverte la camera in\nalto e in basso.", + "options.input.stick_invert_y.choice.0": "Sì", + "options.input.stick_invert_y.choice.1": "No", + "options.input.gyro.label": "Giroscopio", + "options.input.gyro.help": "\"Giroscopio\"\nQuando inclinare il\npad gira la camera.", + "options.input.gyro.choice.0": "No", + "options.input.gyro.choice.1": "Sempre", + "options.input.gyro.choice.2": "Prima persona", + "options.input.gyro.choice.3": "Se premuto", + "options.input.gyro_sensitivity.label": "Sensibilità giroscopio", + "options.input.gyro_sensitivity.help": "\"Sensib. giroscopio\"\nVelocità con cui\nl'inclinazione gira.", + "options.input.gyro_invert_x.label": "Inverti giroscopio X", + "options.input.gyro_invert_x.help": "\"Inverti giroscopio X\"\nInverte il giroscopio\na sinistra e a destra.", + "options.input.gyro_invert_x.choice.0": "Sì", + "options.input.gyro_invert_x.choice.1": "No", + "options.input.gyro_invert_y.label": "Inverti giroscopio Y", + "options.input.gyro_invert_y.help": "\"Inverti giroscopio Y\"\nInverte il giroscopio\nin alto e in basso.", + "options.input.gyro_invert_y.choice.0": "Sì", + "options.input.gyro_invert_y.choice.1": "No", + "options.video.width.value.Desktop": "Desktop", + "options.video.max_fps.value.Unlimited": "Illimitato", + "options.game.qte_always_win.label": "QTE sempre vinti", + "options.game.qte_always_win.help": "\"QTE sempre vinti\"\nI comandi a tempo\nfiniscono sempre in\nperfetto.", + "options.game.qte_always_win.choice.0": "Sì", + "options.game.qte_always_win.choice.1": "No", + "options.video.fps_detail.label": "Info FPS", + "options.video.fps_detail.help": "\"Info FPS\"\nCosa mostra il\ncontatore: solo FPS,\ncon tick, o tutto.", + "options.video.fps_detail.choice.0": "FPS", + "options.video.fps_detail.choice.1": "FPS+Tick", + "options.video.fps_detail.choice.2": "Tutto", + "options.input.mouse_zoom.label": "Zoom rotella", + "options.input.mouse_zoom.help": "\"Zoom rotella\"\nScorri per avvicinarti\no allontanarti dal\npersonaggio.", + "options.input.mouse_zoom.choice.0": "Sì", + "options.input.mouse_zoom.choice.1": "No", + "options.input.bindings.zoom_reset.label": "Azzera zoom", + "options.input.bindings.zoom_reset.help": "\"Azzera zoom\"\nRipristina la distanza\nnormale della camera." +} diff --git a/port/src/clsmes.cpp b/port/src/clsmes.cpp index c659efe9e..68630584d 100644 --- a/port/src/clsmes.cpp +++ b/port/src/clsmes.cpp @@ -7,18 +7,212 @@ #include #include "draw2d_port.hpp" +#include "gametext.hpp" #include "gameutil.hpp" +#include "localize.hpp" #include "mglib.hpp" #include "mglib_port.hpp" +#include "platform/ttffont.hpp" #include "rect.hpp" #include "snd.hpp" #include "texture.hpp" extern float RandTbl[64]; extern float RandTbl2[64]; +extern MES_FUCHI FuchiTbl_E[]; namespace { +// ---- Message text from a TrueType font ---- +// With font.ttf in a lang folder (the save's or the executable's), a character the font has is drawn +// from it at the screen's own resolution, in the cell the game laid the bitmap character out in, +// instead of from the 14x20 bitmaps of gaiji.img. Icons and characters the font lacks draw as before. + +bool TtfReady() { + static bool tried = false; + if (!tried) { + tried = true; + std::filesystem::path file = LocalizeFindFile("font.ttf"); + if (!file.empty() && ttffont::Load(file)) { + std::fprintf(stderr, "font: message text from %s\n", file.string().c_str()); + } + } + return ttffont::Loaded(); +} + +// What DrawGaijiFont tints a character with: the colour table's entry, the flashing cursor row's, +// and double for a lit row. +void GlyphTint(int clut, int dark, int wide, int &r, int &g, int &b, int &alpha) { + alpha = 0x80; + switch (FontColorTbl[clut]) { + case FONT_COLOR_WHITE: r = g = b = 0x5F; break; + case FONT_COLOR_BROWN: r = 0x22; g = 0x20; b = 0x18; break; + case FONT_COLOR_YELLOW: r = 0x5E; g = 0x5E; b = 0x20; break; + case FONT_COLOR_CYAN: r = 0x20; g = 0x5E; b = 0x5E; break; + case FONT_COLOR_GREEN: r = 0x20; g = 0x5E; b = 0x20; break; + case FONT_COLOR_BROWN_OPAQUE: r = 0x22; g = 0x20; b = 0x18; break; + case FONT_COLOR_GOLD: r = 0x73; g = 0x67; b = 0x33; break; + case FONT_COLOR_GREY: r = g = b = 0x47; break; + case FONT_COLOR_MAGENTA: r = 0x5F; g = 0x1F; b = 0x5F; break; + default: r = 0x28; g = 0x28; b = 0x20; break; + } + if (clut == 0xFF) { + if (dark != 0) { + r = 0x7F; g = 0x22; b = 0x22; + } else { + r = 0x40; g = 0x11; b = 0x11; + } + } + if (wide == 1) { + r *= 2; + g *= 2; + b *= 2; + } +} + +void TtfQuad(std::vector &out, const ttffont::Glyph &glyph, float x, float y, float width, + float height, int r, int g, int b, int alpha) { + auto clamp = [](int v) { return static_cast(v < 0 ? 0 : v > 255 ? 255 : v); }; + const u_char red = clamp(r), green = clamp(g), blue = clamp(b), a = clamp(alpha); + out.push_back(draw2d::Vertex(x, y, 0.0f, glyph.u0, glyph.v0, red, green, blue, a)); + out.push_back(draw2d::Vertex(x + width, y, 0.0f, glyph.u1, glyph.v0, red, green, blue, a)); + out.push_back(draw2d::Vertex(x + width, y + height, 0.0f, glyph.u1, glyph.v1, red, green, blue, a)); + out.push_back(draw2d::Vertex(x, y + height, 0.0f, glyph.u0, glyph.v1, red, green, blue, a)); +} + +// The em the font is drawn at: its line fits 82% of the cell's height and its widest glyph the cell's +// width, so a glyph never leaves the cell the game sized the window for. +float TtfEm(const ClsMes &mes, const ttffont::Metrics &metrics) { + return std::min(mes.char_height * 0.82f, mes.char_width * 0.95f / metrics.advance); +} + +// Where each laid-out character of a window starts, in the window's own coordinates. A word (a run of +// characters the font draws) keeps the place the game gave it: its letters go one after another by +// their own widths and the word is centred on the cells the game gave it, so rows, indents, centring and +// the icons between words stay where the game put them. A glyph is never given more than its cell, so +// a word is never wider than the game's. +void TtfLayoutRows(ClsMes &mes, std::vector &xs) { + const int count = mes.win_line_num; + xs.assign(static_cast(count), 0.0f); + for (int i = 0; i < count; i++) { + xs[static_cast(i)] = static_cast(mes.win_line[i].x); + } + if (mes.char_height <= 0 || mes.char_width <= 0) { + return; + } + const gfx::LogicalMapping mapping = gfx::GetUiMapping(gfx::CurrentRenderTarget()); + const ttffont::Metrics metrics = ttffont::GetMetrics(); + const float em = TtfEm(mes, metrics); + const int em_px = static_cast(em * mapping.scale_x + 0.5f); + const float gap = em * 0.1f; + + auto glyph_of = [&](int i, ttffont::Glyph &glyph) { + const int code = mes.win_line[i].code; + const char32_t ch = code >= -0x2DF && code < -0x251 ? GameTextChar(static_cast(code)) : 0; + return ch != 0 && ttffont::GetGlyph(ch, em_px, glyph); + }; + auto cell_of = [&](int i) { + const bool next = i + 1 < count && mes.win_line[i + 1].y == mes.win_line[i].y; + return next ? static_cast(mes.win_line[i + 1].x - mes.win_line[i].x) : static_cast(mes.char_width); + }; + + int i = 0; + while (i < count) { + ttffont::Glyph glyph; + if (!glyph_of(i, glyph)) { + i++; + continue; + } + int last = i; + float natural = 0.0f; + std::vector pen; + for (; last < count && mes.win_line[last].y == mes.win_line[i].y && glyph_of(last, glyph); last++) { + pen.push_back(natural); + natural += std::min(glyph.width / mapping.scale_x + gap, cell_of(last)); + } + const float span = static_cast(mes.win_line[last - 1].x - mes.win_line[i].x) + cell_of(last - 1); + const float start = static_cast(mes.win_line[i].x) + (span - (natural - gap)) / 2; + for (int k = i; k < last; k++) { + xs[static_cast(k)] = start + pen[static_cast(k - i)]; + } + i = last; + } +} + +// Queues the font's glyph for win_line[index]: an edge pass per outline and one fill. False where +// the font has none, and the bitmap character is drawn. +bool QueueTtfGlyph(ClsMes &mes, int index, int dark, int offset_x, int offset_y, const std::vector &xs, + std::vector &edges, std::vector &fills) { + const MES_WIN_LINE &line = mes.win_line[index]; + const char32_t ch = GameTextChar(line.code); + if (ch == 0 || mes.char_height <= 0 || mes.char_width <= 0 || static_cast(index) >= xs.size()) { + return false; + } + const gfx::LogicalMapping mapping = gfx::GetUiMapping(gfx::CurrentRenderTarget()); + const float rows = draw2d::RowScale(); + const ttffont::Metrics metrics = ttffont::GetMetrics(); + const float em = TtfEm(mes, metrics); + const int em_px = static_cast(em * mapping.scale_x + 0.5f); + ttffont::Glyph glyph; + if (!ttffont::GetGlyph(ch, em_px, glyph)) { + return false; + } + + // The window's origin for this row, as the bitmap characters take it. + const int row = line.y / mes.char_height; + float origin_x, cell_y; + if (row >= static_cast(std::size(mes.line_pos)) || mes.line_pos[row].x < 0 || mes.line_pos[row].y < 0) { + origin_x = static_cast(offset_x + mes.text_x); + cell_y = static_cast(offset_y + line.y + mes.text_y); + } else { + origin_x = static_cast(mes.line_pos[row].x); + cell_y = static_cast(line.y + mes.line_pos[row].y - row * mes.char_height); + } + + // The font's line is centred in the cell's height. The bitmap's pixels are screen pixels, so a quad + // is as many logical units as they come to. + const float width = glyph.width / mapping.scale_x; + const float height = glyph.height / mapping.scale_y * rows; + const float base = cell_y + (mes.char_height - em * (metrics.ascent + metrics.descent)) / 2 + em * metrics.ascent; + float x = origin_x + xs[static_cast(index)]; + float y = (base * rows) + glyph.y_offset / mapping.scale_y; + + // On whole pixels, so the edges of the strokes are not smeared across two. + auto snap = [](float value, float scale, float offset) { return (std::round(value * scale + offset) - offset) / scale; }; + x = snap(x, mapping.scale_x, mapping.offset_x); + y = snap(y, mapping.scale_y, mapping.offset_y); + + int wide = 0; + if (mes.cursor_row >= 0) { + wide = mes.cursor_lit == 1 ? (row == mes.cursor_row ? 1 : 0) : (row == mes.cursor_row ? 0 : 1); + } + int r, g, b, alpha; + GlyphTint(line.clut, dark, wide, r, g, b, alpha); + + // The bitmaps' outlines were soft; a crisp glyph's reads thick at the same offsets, so they are + // brought in to 60%. + const int cap = mes.edge_alpha; + auto edge = [&](float dx, float dy, int er, int eg, int eb, int ea) { + TtfQuad(edges, glyph, x + dx * 0.6f, y + dy * 0.6f * rows, width, height, er, eg, eb, std::min(cap, ea)); + }; + switch (mes.style) { + case MES_EDGE_WHITE: edge(1, 1, 0xFF, 0xFF, 0xFF, 0x40); break; + case MES_EDGE_BLACK: edge(1, 1, 0, 0, 0, 0x40); break; + case MES_EDGE_TABLE: + for (int i = 0; FuchiTbl_E[i].alpha > 0; i++) { + edge(static_cast(FuchiTbl_E[i].x), static_cast(FuchiTbl_E[i].y), FuchiTbl_E[i].r, + FuchiTbl_E[i].g, FuchiTbl_E[i].b, FuchiTbl_E[i].alpha); + } + break; + case MES_EDGE_DOUBLE: + edge(1, 1, 0x40, 0x40, 0x40, 0x80); + edge(2, 2, 0, 0, 0, 0x80); + break; + } + TtfQuad(fills, glyph, x, y, width, height, r, g, b, std::min(cap, alpha)); + return true; +} + enum { GAIJI_CODE, GAIJI_U, @@ -600,6 +794,13 @@ PC_OVERRIDE void ClsMes::DrawMesWin() { texture = draw2d::Get().find_texture("gaiji"); setbilinear(0); set2DSprite_Start(Vif1Packet, texture); + const bool ttf = TtfReady(); + std::vector ttf_edges; + std::vector ttf_fills; + std::vector ttf_xs; + if (ttf) { + TtfLayoutRows(*this, ttf_xs); + } for (index = this->text_from; index < this->text_no; index++) { if (MesAbsDrawOff != 0) { @@ -612,6 +813,10 @@ PC_OVERRIDE void ClsMes::DrawMesWin() { continue; } + if (ttf && code >= -0x2DF && QueueTtfGlyph(*this, index, dark, offset_x, offset_y, ttf_xs, ttf_edges, ttf_fills)) { + continue; + } + int gaiji_index = code + 0x300; int u = GaijiDataTbl[gaiji_index][GAIJI_U]; @@ -714,6 +919,15 @@ PC_OVERRIDE void ClsMes::DrawMesWin() { set2DSprite_End(Vif1Packet, texture); + if (!ttf_fills.empty()) { + const gfx::TextureBinding binding = ttffont::Binding(); + if (!ttf_edges.empty()) { + gfx::Draw2D(gfx::Primitive::Quads, ttf_edges, binding, draw2d::SpriteState()); + } + gfx::Draw2D(gfx::Primitive::Quads, ttf_fills, binding, draw2d::SpriteState()); + draw2d::RestoreTestZbuf(); + } + if (this->page_arrow != 0) { if ((this->text_no >= this->text_len && this->cursor_row < 0) || this->waiting != 0) { if ((this->blink >> 4) % 2 != 0) { diff --git a/port/src/dataread.cpp b/port/src/dataread.cpp index b1583eeab..d6736f8d9 100644 --- a/port/src/dataread.cpp +++ b/port/src/dataread.cpp @@ -11,6 +11,7 @@ #include #include "../../tools/dcdata/dcdata.hpp" +#include "localize.hpp" #include "platform/paths.hpp" namespace fs = std::filesystem; @@ -85,6 +86,28 @@ int ReadWhole(const fs::path &file, void *buffer) { return static_cast(size); } +// One file inside a pack, as ps2/src/dataread.cpp has it: offsets are bytes from the entry, and a +// first byte of zero ends the table. +struct PackEntry { + char name[64]; + int offset; + int size; + int next; +}; + +bool SameName(const char *name, std::string_view other) { + std::size_t length = std::strlen(name); + return length == other.size() && dcdata::FoldPath(std::string_view(name, length)) == dcdata::FoldPath(other); +} + +// Hands a file just read to the localization layer: a message file may be rewritten from the +// language's JSON in place (it fits the whole sectors ReadWhole zeroed), and a pack is noted so +// its message entries can be told from GetPackFile. +void Localized(std::string_view path, void *buffer, int *size) { + LocalizeLoaded(path, buffer); + LocalizeFile(path, buffer, dcdata::SectorsFor(static_cast(*size)) * dcdata::kSector, size); +} + } // namespace PC_OVERRIDE void InitCDFile() { @@ -142,6 +165,7 @@ PC_OVERRIDE int LoadFile2(char *path, void *buffer, int *out_size, int mode) { return 0; } int size = ReadWhole(*file, buffer); + Localized(path, buffer, &size); if (out_size) { *out_size = size; } @@ -189,6 +213,7 @@ PC_OVERRIDE int LoadFileBG(char *name, u_long128 *buffer, int *out_size) { return 0; } int size = ReadWhole(*file, buffer); + Localized(name, buffer, &size); std::snprintf(info->name, sizeof info->name, "%s", name); info->busy = true; info->id = 1; @@ -245,3 +270,28 @@ PC_OVERRIDE int WriteFile(char *path, void *buffer, int size) { stream.write(static_cast(buffer), size); return stream ? 1 : 0; } + +// A pack is looked up by the last component of a path, as retail does; a message file in it is +// given as the language's JSON has it. +PC_OVERRIDE u_int *GetPackFile(u_int *pack, char *name, int *out_size) { + if (!pack) { + return 0; + } + std::string_view base = name; + if (std::size_t slash = base.rfind('/'); slash != std::string_view::npos) { + base = base.substr(slash + 1); + } + for (PackEntry *entry = reinterpret_cast(pack); entry->name[0]; + entry = reinterpret_cast(reinterpret_cast(entry) + entry->next)) { + if (SameName(entry->name, base)) { + u_int *data = reinterpret_cast(reinterpret_cast(entry) + entry->offset); + int size = entry->size; + data = const_cast(LocalizePack(pack, base, data, &size)); + if (out_size) { + *out_size = size; + } + return data; + } + } + return 0; +} diff --git a/port/src/gameloop.cpp b/port/src/gameloop.cpp index 5bb425fa0..e4bf41ed7 100644 --- a/port/src/gameloop.cpp +++ b/port/src/gameloop.cpp @@ -28,6 +28,7 @@ #include "mglib_port.hpp" #include "nowload.hpp" #include "platform/clock.hpp" +#include "localize.hpp" #include "platform/config.hpp" #include "platform/input.hpp" #include "platform/overlay.hpp" @@ -784,8 +785,18 @@ int RunGame(int argc, char **argv) { MapNo = -1; mode = GAME_MODE_LANGUAGE; GamePad.KeyLock2(1); + if (const int language = ConfigGet().language; language >= LANG_ENGLISH_UK && language <= LANG_SPANISH) { + // game.language is set: start in it without asking, where the language screen would have. + LanguageCode = language; + MapNo = 801; + mode = GAME_MODE_RUSH_MOVIE; + } } if (g_jump.set) { + // A jump skips the language screen, so the language chosen in config.json is the one it starts in. + if (const int language = ConfigGet().language; language >= LANG_ENGLISH_UK && language <= LANG_SPANISH) { + LanguageCode = language; + } ApplyJump(); } @@ -794,6 +805,8 @@ int RunGame(int argc, char **argv) { bool data_loaded = false; bool skip_title = false; for (;;) { + // A language chosen on the Options screen takes hold here, where the next area is about to load. + LocalizeApplyPendingLanguage(); if (mode != GAME_MODE_UNUSED_12 && !data_loaded) { initialize_data(); data_loaded = true; @@ -857,6 +870,15 @@ int RunGame(int argc, char **argv) { result = ModeLoop(skip_title); GameApplyLoopResult(old_main_mode, result); + if (result == 0 && LocalizeLanguagePending() && !MenuOptionOpen() && + (mode == GAME_MODE_EDIT || mode == GAME_MODE_TITLE)) { + // The language was changed in this town or title and the Options screen has closed: load + // the same map again, now in the new language. A dungeon floor is not restarted for it; + // the language takes hold when the next area loads. + NextMapNo = MapNo; + StartEventNo = -1; + result = 1; + } InputSetLookOnLeftStick((mode == GAME_MODE_DUNGEON && gameTask == GAME_TASK_EYE_CAMERA) || (mode == GAME_MODE_EDIT && viewMode != 0 && EdInteriorFlag == 0)); diff --git a/port/src/gfx/renderer.cpp b/port/src/gfx/renderer.cpp index 69b128af2..655ddbe10 100644 --- a/port/src/gfx/renderer.cpp +++ b/port/src/gfx/renderer.cpp @@ -303,6 +303,8 @@ void CreateDevice() { features13.pNext = g.dynamic_color_write_mask ? &dynamic3 : dynamic3.pNext; features13.dynamicRendering = VK_TRUE; features13.synchronization2 = VK_TRUE; + // The fragment shaders' discard compiles to OpDemoteToHelperInvocation, which needs this on. + features13.shaderDemoteToHelperInvocation = VK_TRUE; VkPhysicalDeviceVulkan12Features features12 = {}; features12.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES; diff --git a/port/src/localize.cpp b/port/src/localize.cpp new file mode 100644 index 000000000..bb89de1af --- /dev/null +++ b/port/src/localize.cpp @@ -0,0 +1,899 @@ +// nlohmann/json before the game headers, whose one-letter macros it cannot stand. +#include "localize.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../tools/dcdata/dcdata.hpp" +#include "dataread.hpp" +#include "mainselect.hpp" +#include "menu_option.hpp" +#include "platform/config.hpp" +#include "platform/paths.hpp" + +namespace fs = std::filesystem; + +namespace { + +using Json = nlohmann::ordered_json; + +// The 76-byte entry of dataread.cpp's pack format, for walking a pack to its message files. +constexpr size_t kPackName = 64; +constexpr size_t kPackEntry = kPackName + 12; + +struct State { + std::vector directories; + bool directories_set = false; + int language = -1; + LocalizeTable table; + std::set reported; + std::unordered_map packs; + // Packs hand out pointers that callers keep, so what was built is never freed. + std::map>> built; +}; + +State &Get() { + static State state; + return state; +} + +// Each problem is told once, so a file loaded every frame does not fill the log. +void Report(const std::string &what) { + if (Get().reported.insert(what).second) { + std::fprintf(stderr, "localize: %s\n", what.c_str()); + } +} + +// The file each LanguageCode reads and the export writes, named as Minecraft's language files are +// (language_COUNTRY.json), and the folder its menu packs are in. +constexpr const char *kLanguageFiles[7] = {"ja_jp", "en_us", "en_gb", "fr_fr", "de_de", "it_it", "es_es"}; +constexpr const char *kLanguageDirs[7] = {"a_jpn", "a_usa", "a_eng", "a_fre", "a_ger", "a_ita", "a_spa"}; + +// The file suffix of a language code, or null for a code that is none. +const char *LanguageFile(int code) { + return code >= 0 && code < 7 ? kLanguageFiles[code] : nullptr; +} + +std::vector Directories() { + State &state = Get(); + if (state.directories_set) { + return state.directories; + } + std::vector directories; + fs::path exe = PathsExecutable(); + if (!exe.empty()) { + directories.push_back(exe.parent_path() / "lang"); + } + directories.push_back(PathsSaveRoot() / "lang"); + return directories; +} + +bool ReadText(const fs::path &file, std::string &text) { + std::ifstream stream(file, std::ios::binary); + if (!stream) { + return false; + } + text.assign(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + return true; +} + +// One language's files (.json, the save folder's over the executable's), without patches. +LocalizeTable LoadLanguage(int code) { + LocalizeTable table; + const char *name = LanguageFile(code); + if (name == nullptr) { + return table; + } + for (const fs::path &directory : Directories()) { + fs::path file = directory / (std::string(name) + ".json"); + std::string text; + std::error_code error; + if (!fs::is_regular_file(file, error) || !ReadText(file, text)) { + continue; + } + LocalizeTable read; + std::string why; + if (LocalizeParse(text, read, why)) { + LocalizeMerge(table, read); + } else { + Report(PathsDisplay(file) + ": " + why + "; using retail text"); + } + } + return table; +} + +// The table for the current LanguageCode, read when the code first or newly is asked for. +const LocalizeTable &Table() { + State &state = Get(); + if (state.language == LanguageCode) { + return state.table; + } + state.language = LanguageCode; + state.table = LoadLanguage(LanguageCode); + if (LanguageFile(LanguageCode) == nullptr) { + return state.table; + } + // Patches: every .json in /patches, by name, whatever the language. + for (const fs::path &directory : Directories()) { + std::error_code error; + std::vector names; + for (fs::directory_iterator it(directory / "patches", error), end; !error && it != end; it.increment(error)) { + if (it->path().extension() == ".json") { + names.push_back(it->path()); + } + } + std::sort(names.begin(), names.end()); + for (const fs::path &file : names) { + std::string text; + if (!ReadText(file, text)) { + continue; + } + LocalizeTable read; + std::string why; + if (LocalizeParsePatches(text, read, why)) { + LocalizeMerge(state.table, read); + } else { + Report(PathsDisplay(file) + ": " + why + "; patches in it ignored"); + } + } + } + return state.table; +} + +// DC_LOCALIZE_TRACE=1 names every message file the game loads, as the JSON would key it, with +// whether the table has an entry for it: how to see what a play-through touches. +void Trace(const std::string &key, bool has_entry) { + static const bool on = std::getenv("DC_LOCALIZE_TRACE") != nullptr; + if (on && Get().reported.insert("trace " + key).second) { + std::fprintf(stderr, "localize-trace: %s %s\n", key.c_str(), has_entry ? "json" : "retail"); + } +} + +bool IsPackName(std::string_view path) { + auto ends = [&](std::string_view suffix) { + return path.size() >= suffix.size() && + std::equal(suffix.begin(), suffix.end(), path.end() - suffix.size(), [](char a, char b) { + return a == dcdata::FoldChar(b); + }); + }; + return ends(".pak") || ends(".pac"); +} + +bool IsMessageName(std::string_view path) { + return path.size() >= 4 && std::equal(path.end() - 4, path.end(), ".mes", [](char a, char b) { + return dcdata::FoldChar(a) == b; + }); +} + +std::string FileKey(std::string_view path) { + if (size_t colon = path.find(':'); colon != std::string_view::npos) { + path = path.substr(colon + 1); + } + return dcdata::FoldPath(path); +} + +// A retail message file as the game reads it: s16 words, whose bytes the loader has swapped for +// the host's order already. +struct Message { + int id; + std::vector codes; +}; + +bool ReadRetail(const s16 *file, size_t words, std::vector &out) { + if (words < 2 || file[0] < 0) { + return false; + } + const size_t count = static_cast(file[0]); + if (2 + count * 2 > words) { + return false; + } + const size_t base = 1 + count; + for (size_t i = 0; i < count; i++) { + const int id = file[2 + i * 2]; + const long start = static_cast(base) + file[3 + i * 2]; + if (start < 0 || static_cast(start) >= words) { + continue; + } + Message message{id, {}}; + size_t at = static_cast(start); + while (at < words && file[at] != MES_CODE_END) { + message.codes.push_back(file[at++]); + } + if (at >= words) { + continue; + } + message.codes.push_back(MES_CODE_END); + out.push_back(std::move(message)); + } + return true; +} + +} // namespace + +std::string LocalizeTextHash(std::string_view text) { + std::uint64_t hash = 0xCBF29CE484222325ULL; + for (unsigned char c : text) { + hash ^= c; + hash *= 0x100000001B3ULL; + } + char digits[17]; + std::snprintf(digits, sizeof digits, "%016llx", static_cast(hash)); + return digits; +} + +std::string LocalizeMessageKey(std::string_view path) { + std::string key = FileKey(path); + // The folder a language's menu packs are in, a_fre/ and the like, is not part of the key. + for (const char *dir : kLanguageDirs) { + const std::string folder = std::string(dir) + "/"; + if (key.compare(0, folder.size(), folder) == 0) { + key.erase(0, folder.size()); + } else if (size_t at = key.find("/" + folder); at != std::string::npos) { + key.erase(at + 1, folder.size()); + } + } + if (key.size() >= 4 && key.compare(key.size() - 4, 4, ".mes") == 0) { + key.erase(key.size() - 4); + // The language is the last number of the name: d01_3, steve07_1_4. + if (key.size() >= 2 && key[key.size() - 2] == '_' && key.back() >= '0' && key.back() <= '6') { + key.erase(key.size() - 2); + } + } + std::replace(key.begin(), key.end(), '/', '.'); + return key; +} + +namespace { + +int ParseId(const std::string &key, bool &ok) { + char *end = nullptr; + long id = std::strtol(key.c_str(), &end, 10); + ok = !key.empty() && *end == '\0' && id >= INT16_MIN && id <= INT16_MAX; + return static_cast(id); +} + +bool ParsePatches(const Json &patches, LocalizeTable &read, std::string &error) { + if (!patches.is_array()) { + error = "\"patches\" is not an array"; + return false; + } + int number = 0; + for (const Json &item : patches) { + number++; + const std::string where = "patch " + std::to_string(number); + if (!item.is_object() || !item.contains("file") || !item["file"].is_string() || !item.contains("id")) { + error = where + " needs a \"file\" and an \"id\""; + return false; + } + LocalizePatch patch; + bool ok = false; + if (item["id"].is_number_integer()) { + patch.id = ParseId(std::to_string(item["id"].get()), ok); + } + if (!ok) { + error = where + ": \"id\" is not a message id (an s16)"; + return false; + } + if (item.contains("original")) { + if (!item["original"].is_string()) { + error = where + ": \"original\" is not a string"; + return false; + } + patch.original = item["original"].get(); + } + const bool has_text = item.contains("text"); + const bool has_replace = item.contains("replace"); + if (has_text == has_replace) { + error = where + " needs exactly one of \"text\" and \"replace\""; + return false; + } + if (has_text) { + if (!item["text"].is_string()) { + error = where + ": \"text\" is not a string"; + return false; + } + patch.text = item["text"].get(); + } else { + if (!item["replace"].is_array() || item["replace"].empty()) { + error = where + ": \"replace\" is not a list of [find, with] pairs"; + return false; + } + for (const Json &pair : item["replace"]) { + if (!pair.is_array() || pair.size() != 2 || !pair[0].is_string() || !pair[1].is_string() || + pair[0].get().empty()) { + error = where + ": \"replace\" is not a list of [find, with] pairs"; + return false; + } + patch.replace.emplace_back(pair[0].get(), pair[1].get()); + } + } + // A patch names its file by the path the game loads or by its message key. + const std::string file = item["file"].get(); + read.patches[IsMessageName(file) || file.find_first_of("/\\") != std::string::npos ? LocalizeMessageKey(file) + : file] + .push_back(std::move(patch)); + } + return true; +} + +} // namespace + +bool LocalizeParse(std::string_view json, LocalizeTable &table, std::string &error) { + LocalizeTable read; + try { + Json root = Json::parse(json); + if (!root.is_object()) { + error = "the top level is not an object"; + return false; + } + for (const auto &[key, text] : root.items()) { + if (!text.is_string()) { + error = "\"" + key + "\" is not a string"; + return false; + } + // "dun.script.d01.d01.12" is message 12 of a message file; any other key is a string of the port's. + const size_t dot = key.rfind('.'); + const std::string last = dot == std::string::npos ? key : key.substr(dot + 1); + const bool number = !last.empty() && last.find_first_not_of("-0123456789") == std::string::npos && + last != "-"; + if (!number) { + read.strings[key] = text.get(); + continue; + } + bool ok = false; + const int id = ParseId(last, ok); + if (!ok || dot == 0 || dot == std::string::npos) { + error = "\"" + key + "\" is not a message key (a file key, a dot and an s16 id)"; + return false; + } + read.files[key.substr(0, dot)][id] = text.get(); + } + } catch (const Json::exception &failure) { + error = failure.what(); + return false; + } + table = std::move(read); + return true; +} + +bool LocalizeParsePatches(std::string_view json, LocalizeTable &table, std::string &error) { + LocalizeTable read; + try { + Json root = Json::parse(json); + auto patches = root.is_object() ? root.find("patches") : root.end(); + if (!root.is_object() || patches == root.end()) { + error = "no \"patches\" list"; + return false; + } + if (!ParsePatches(*patches, read, error)) { + return false; + } + } catch (const Json::exception &failure) { + error = failure.what(); + return false; + } + table = std::move(read); + return true; +} + +void LocalizeMerge(LocalizeTable &base, const LocalizeTable &over) { + for (const auto &[file, messages] : over.files) { + for (const auto &[id, text] : messages) { + base.files[file][id] = text; + } + } + for (const auto &[file, patches] : over.patches) { + auto &target = base.patches[file]; + target.insert(target.end(), patches.begin(), patches.end()); + } + for (const auto &[key, text] : over.strings) { + base.strings[key] = text; + } +} + +bool LocalizeReadMessages(const s16 *file, size_t words, std::map &out) { + std::vector messages; + if (!ReadRetail(file, words, messages)) { + return false; + } + for (const Message &message : messages) { + out.emplace(message.id, GameTextDecode(message.codes.data())); + } + return true; +} + +bool LocalizeMessages(const s16 *retail, size_t words, const std::map &texts, + std::vector &out, LocalizeStats &stats) { + std::vector messages; + if (texts.empty() || !ReadRetail(retail, words, messages)) { + return false; + } + // Retail's own texts go in as codes, so none is changed by being read back as text. + std::map first; + for (const Message &message : messages) { + first.emplace(message.id, &message); + } + GameTextFile file; + for (const auto &[id, message] : first) { + std::string text = GameTextDecode(message->codes.data()); + if (file.Set(id, text) < 0) { + return false; + } + } + LocalizeStats made; + for (const auto &[id, text] : texts) { + const int missing = file.Set(id, text); + if (missing < 0) { + made.rejected++; + } else { + made.replaced++; + made.missing += missing; + } + } + stats = made; + if (made.replaced == 0) { + return false; + } + // GameTextFile does not say how long the file is; the last message by offset ends it. + const s16 *data = file.Data(); + const size_t base = 1 + static_cast(data[0]); + size_t end = 2 + static_cast(data[0]) * 2; + for (int i = 0; i < data[0]; i++) { + size_t at = base + static_cast(data[3 + i * 2]); + while (data[at] != MES_CODE_END) { + at++; + } + end = std::max(end, at + 1); + } + out.assign(data, data + end); + return true; +} + +namespace { + +// What the JSON says for one message file: the patches applied to the disc's messages, then the +// "files" texts, which win. A patch that does not apply is told once and the message stays as it was. +std::map Wanted(const LocalizeTable &table, const std::string &key, const s16 *retail, + size_t words) { + std::map texts; + if (auto found = table.files.find(key); found != table.files.end()) { + texts = found->second; + } + auto patches = table.patches.find(key); + if (patches == table.patches.end()) { + return texts; + } + std::map current; + if (!LocalizeReadMessages(retail, words, current)) { + return texts; + } + for (const LocalizePatch &patch : patches->second) { + const std::string name = key + " message " + std::to_string(patch.id); + if (table.files.count(key) != 0 && table.files.at(key).count(patch.id) != 0) { + continue; + } + auto message = current.find(patch.id); + if (message == current.end()) { + Report(name + ": not in the disc's file; patch skipped"); + continue; + } + if (!patch.original.empty() && patch.original != LocalizeTextHash(message->second)) { + Report(name + ": the disc's text is not what the patch was written for; patch skipped"); + continue; + } + std::string text; + if (patch.text) { + text = *patch.text; + } else { + text = message->second; + int applied = 0; + for (const auto &[find, with] : patch.replace) { + size_t at = text.find(find); + if (at == std::string::npos) { + Report(name + ": \"" + find + "\" is not in the disc's text; that replacement skipped"); + continue; + } + applied++; + for (; at != std::string::npos; at = text.find(find, at + with.size())) { + text.replace(at, find.size(), with); + } + } + if (applied == 0) { + continue; + } + } + message->second = text; + texts[patch.id] = text; + } + return texts; +} + +} // namespace + +void LocalizeLoaded(std::string_view path, const void *buffer) { + if (IsPackName(path)) { + Get().packs[buffer] = FileKey(path); + } else { + Get().packs.erase(buffer); + } +} + +bool LocalizeFile(std::string_view path, void *buffer, size_t capacity, int *size) { + if (!IsMessageName(path)) { + return false; + } + const LocalizeTable &table = Table(); + const std::string key = LocalizeMessageKey(path); + const bool known = table.files.count(key) != 0 || table.patches.count(key) != 0; + Trace(key, known); + if (!known || size == nullptr || *size < 4) { + return false; + } + std::vector out; + LocalizeStats stats; + const size_t words = static_cast(*size) / 2; + const auto texts = Wanted(table, key, static_cast(buffer), words); + if (!LocalizeMessages(static_cast(buffer), words, texts, out, stats)) { + if (stats.rejected > 0) { + Report(key + ": " + std::to_string(stats.rejected) + " texts did not fit; retail text kept"); + } + return false; + } + if (out.size() * 2 > capacity) { + Report(key + ": the translated file is " + std::to_string(out.size() * 2) + " bytes, past the " + + std::to_string(capacity) + " the game reads it into; retail text kept"); + return false; + } + if (stats.rejected > 0) { + Report(key + ": " + std::to_string(stats.rejected) + " texts did not fit; retail text kept for them"); + } + if (stats.missing > 0) { + Report(key + ": " + std::to_string(stats.missing) + " characters the game font lacks became '?'"); + } + std::memcpy(buffer, out.data(), out.size() * 2); + *size = static_cast(out.size() * 2); + return true; +} + +const u_int *LocalizePack(const u_int *pack, std::string_view name, const u_int *data, int *size) { + State &state = Get(); + if (data == nullptr || size == nullptr || !IsMessageName(name)) { + return data; + } + auto from = state.packs.find(pack); + if (from == state.packs.end()) { + return data; + } + const LocalizeTable &table = Table(); + const std::string key = LocalizeMessageKey(from->second + "/" + std::string(name)); + const bool known = table.files.count(key) != 0 || table.patches.count(key) != 0; + Trace(key, known); + if (!known || *size < 4) { + return data; + } + // The same pack read again hands out the same text again, as long as the retail bytes and the + // language are the same. + const size_t bytes = static_cast(*size); + std::string tag = key + "#" + std::to_string(LanguageCode) + "#" + + std::to_string(std::hash{}( + std::string_view(reinterpret_cast(data), bytes))); + if (auto cached = state.built.find(tag); cached != state.built.end()) { + *size = static_cast(cached->second->size() * 2); + return reinterpret_cast(cached->second->data()); + } + std::vector out; + LocalizeStats stats; + const auto texts = Wanted(table, key, reinterpret_cast(data), bytes / 2); + if (!LocalizeMessages(reinterpret_cast(data), bytes / 2, texts, out, stats)) { + if (stats.rejected > 0) { + Report(key + ": " + std::to_string(stats.rejected) + " texts did not fit; retail text kept"); + } + return data; + } + if (stats.rejected > 0) { + Report(key + ": " + std::to_string(stats.rejected) + " texts did not fit; retail text kept for them"); + } + if (stats.missing > 0) { + Report(key + ": " + std::to_string(stats.missing) + " characters the game font lacks became '?'"); + } + // The game reads whole quadwords off a message file's end, as it does off the disc's. + out.resize((out.size() + 7) & ~size_t{7}, MES_CODE_END); + auto kept = std::make_unique>(std::move(out)); + auto *result = reinterpret_cast(kept->data()); + *size = static_cast(kept->size() * 2); + state.built[tag] = std::move(kept); + return result; +} + +std::string LocalizeText(std::string_view english_key, std::string_view english) { + const LocalizeTable &table = Table(); + const std::string key(english_key); + if (auto found = table.strings.find(key); found != table.strings.end()) { + return found->second; + } + // A key that ends in a number is read as a message id (options.game.map.choice.0); it is the same entry. + if (size_t dot = key.rfind('.'); dot != std::string::npos) { + bool ok = false; + const int id = ParseId(key.substr(dot + 1), ok); + if (auto file = ok ? table.files.find(key.substr(0, dot)) : table.files.end(); file != table.files.end()) { + if (auto text = file->second.find(id); text != file->second.end()) { + return text->second; + } + } + } + return std::string(english); +} + +std::string LocalizeFormat(std::string text, std::initializer_list arguments) { + int number = 1; + for (const std::string &argument : arguments) { + const std::string placeholder = "%" + std::to_string(number++); + for (size_t at = text.find(placeholder); at != std::string::npos; at = text.find(placeholder, at + argument.size())) { + text.replace(at, placeholder.size(), argument); + } + } + return text; +} + +std::filesystem::path LocalizeFindFile(std::string_view name) { + std::vector directories = Directories(); + for (auto directory = directories.rbegin(); directory != directories.rend(); ++directory) { + fs::path file = *directory / std::string(name); + std::error_code error; + if (fs::is_regular_file(file, error)) { + return file; + } + } + return {}; +} + +namespace { + +// 0: none waiting. +int g_pending_language = 0; + +} // namespace + +void LocalizeConfigChanged(const Config &before, const Config &after) { + if (after.language == before.language) { + return; + } + // "ask" and the language the game is already in leave nothing to switch to. + g_pending_language = after.language >= 2 && after.language <= 6 && after.language != LanguageCode ? after.language : 0; +} + +bool LocalizeLanguagePending() { + return g_pending_language != 0; +} + +void LocalizeApplyPendingLanguage() { + if (g_pending_language == 0) { + return; + } + LanguageCode = g_pending_language; + g_pending_language = 0; + if (SystemMes != nullptr) { + LoadSystemMessage(); + } +} + +void LocalizeSetDirectories(std::vector directories) { + State &state = Get(); + state.directories = std::move(directories); + state.directories_set = true; + state.language = -1; +} + +void LocalizeReset() { + State &state = Get(); + state.language = -1; + state.table = {}; + state.reported.clear(); + state.packs.clear(); +} + +namespace { + +// Which language a message file is for, from where the game keeps it: "_N.mes", "img_N" or an +// "a_xxx" directory. -1 where its path says none. +int FileLanguage(const std::string &key) { + static const std::pair dirs[] = { + {"a_jpn/", 0}, + {"a_usa/", 1}, + {"a_eng/", 2}, + {"a_fre/", 3}, + {"a_ger/", 4}, + {"a_ita/", 5}, + {"a_spa/", 6} + }; + for (const auto &[dir, code] : dirs) { + if (key.find(dir) != std::string::npos) { + return code; + } + } + std::string_view name = key; + if (size_t slash = name.rfind('/'); slash != std::string_view::npos) { + name = name.substr(slash + 1); + } + if (name.size() >= 6 && name[name.size() - 6] == '_' && name[name.size() - 5] >= '0' && + name[name.size() - 5] <= '6' && name.substr(name.size() - 4) == ".mes") { + return name[name.size() - 5] - '0'; + } + return -1; +} + +struct Exported { + std::string key; + std::map messages; + int language; + // The language came from what the text is, not from the path. + bool derived = false; +}; + +// Japanese text is not text but codes from 0 up, which GameTextDecode writes as "{N}"; every other +// language's codes are negative. +bool IsJapanese(const std::map &messages) { + for (const auto &[id, text] : messages) { + for (size_t at = text.find('{'); at != std::string::npos; at = text.find('{', at + 1)) { + if (at + 1 < text.size() && text[at + 1] >= '0' && text[at + 1] <= '9') { + return true; + } + } + } + return false; +} + +} // namespace + +int LocalizeExport(const fs::path &out, bool verify) { + const fs::path &root = PathsDataRoot(); + std::error_code error; + if (!fs::is_directory(root, error)) { + std::fprintf(stderr, "export: %s is not a directory\n", PathsDisplay(root).c_str()); + return -1; + } + std::vector found; + int mismatched = 0; + auto add = [&](const std::string &key, const s16 *words, size_t count) { + Exported item; + item.key = key; + item.language = FileLanguage(key); + if (!LocalizeReadMessages(words, count, item.messages) || item.messages.empty()) { + return; + } + // A file that names no language but is Japanese (the menu packs left in commenu/) is for that one. + if (item.language == -1 && IsJapanese(item.messages)) { + item.language = 0; + item.derived = true; + } + if (verify) { + std::map again; + LocalizeStats stats; + std::vector rebuilt; + std::map same = item.messages; + if (LocalizeMessages(words, count, same, rebuilt, stats)) { + std::map back; + if (!LocalizeReadMessages(rebuilt.data(), rebuilt.size(), back) || back != item.messages || + stats.missing != 0 || stats.rejected != 0) { + mismatched++; + std::fprintf(stderr, "export: %s does not read back as it was (missing %d, rejected %d)\n", + key.c_str(), stats.missing, stats.rejected); + } + } else { + mismatched++; + std::fprintf(stderr, "export: %s could not be rebuilt\n", key.c_str()); + } + } + found.push_back(std::move(item)); + }; + + auto options = fs::directory_options::follow_directory_symlink | fs::directory_options::skip_permission_denied; + for (fs::recursive_directory_iterator it(root, options, error), end; !error && it != end; it.increment(error)) { + std::error_code entry_error; + if (!it->is_regular_file(entry_error)) { + continue; + } + const std::string key = dcdata::FoldPath(it->path().lexically_relative(root).generic_string()); + const bool mes = IsMessageName(key); + if (!mes && !IsPackName(key)) { + continue; + } + std::string bytes; + if (!ReadText(it->path(), bytes)) { + continue; + } + if (mes) { + std::vector words(bytes.size() / 2); + std::memcpy(words.data(), bytes.data(), words.size() * 2); + add(key, words.data(), words.size()); + continue; + } + for (size_t at = 0; at + kPackEntry <= bytes.size();) { + const char *entry = bytes.data() + at; + if (entry[0] == 0) { + break; + } + int offset, size, next; + std::memcpy(&offset, entry + kPackName, 4); + std::memcpy(&size, entry + kPackName + 4, 4); + std::memcpy(&next, entry + kPackName + 8, 4); + std::string name(entry, std::find(entry, entry + kPackName, '\0') - entry); + if (offset < 0 || size < 0 || next <= 0 || at + static_cast(offset) + size > bytes.size()) { + break; + } + if (IsMessageName(name)) { + std::vector words(static_cast(size) / 2); + std::memcpy(words.data(), bytes.data() + at + offset, words.size() * 2); + add(key + "/" + dcdata::FoldPath(name), words.data(), words.size()); + } + at += static_cast(next); + } + } + + // One flat file per language, as Minecraft's are (".": text), with the files that name + // no language in every one. Where a file's path names its language it wins over one that does not. + // Two files of one language that come to the same key would be one file to the loader, so that is told. + std::error_code made; + fs::create_directories(out / "hashes", made); + int collisions = 0; + for (int code = 0; code < 7; code++) { + std::map chosen; + std::map> keyed; + auto rank = [](const Exported &item) { return item.language == -1 ? 0 : item.derived ? 1 + : 2; }; + for (const Exported &item : found) { + if (item.language != code && item.language != -1) { + continue; + } + const std::string key = LocalizeMessageKey(item.key); + auto at = chosen.find(key); + if (at == chosen.end() || rank(item) > rank(*at->second)) { + chosen[key] = &item; + } else if (rank(item) == rank(*at->second) && at->second->key != item.key) { + collisions++; + std::fprintf(stderr, "export: %s and %s are both \"%s\"\n", at->second->key.c_str(), + item.key.c_str(), key.c_str()); + } + } + for (const auto &[key, item] : chosen) { + keyed[key] = item->messages; + } + Json texts = Json::object(); + Json hashes = Json::object(); + for (const auto &[key, messages] : keyed) { + for (const auto &[id, text] : messages) { + texts[key + "." + std::to_string(id)] = text; + hashes[key + "." + std::to_string(id)] = LocalizeTextHash(text); + } + } + // The port's own strings (the Options screen's), as this language shows them now: the shipped + // translation if there is one, else the English the code has. + const LocalizeTable language = LoadLanguage(code); + for (const auto &[key, english] : OptionStrings()) { + auto text = language.strings.find(key); + texts[key] = text != language.strings.end() ? text->second : english; + } + std::ofstream(out / (std::string(kLanguageFiles[code]) + ".json"), std::ios::binary | std::ios::trunc) + << texts.dump(4, ' ', false, Json::error_handler_t::replace) << '\n'; + std::ofstream(out / "hashes" / (std::string(kLanguageFiles[code]) + ".json"), std::ios::binary | std::ios::trunc) + << hashes.dump(4, ' ', false, Json::error_handler_t::replace) << '\n'; + } + if (collisions != 0) { + std::fprintf(stderr, "export: %d message files share a key with another\n", collisions); + } + if (verify) { + std::fprintf(stderr, "export: %d of %zu message files do not read back as they were\n", mismatched, + found.size()); + } + return static_cast(found.size()); +} diff --git a/port/src/localize.hpp b/port/src/localize.hpp new file mode 100644 index 000000000..2301e100d --- /dev/null +++ b/port/src/localize.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gametext.hpp" + +// Game text from JSON the port ships, laid over the retail message files (docs/LOCALIZATION.md). Each +// language is one flat file, as Minecraft's language files are, named for it (en_gb.json, fr_fr.json...) and +// read from /lang/, then /lang/ over it: +// +// {"dun.script.d01.d01.12": "There are three chests here.", ...} +// +// A key is the message file's key (LocalizeMessageKey), a dot, and the message id. The keys are the same in +// every language, so the files can be set side by side. A message the JSON does not have, and a file it +// does not have, stay as retail has them. + +// One edit to a message the disc has (docs/LOCALIZATION.md, "Patches"): either text that takes its +// place whole, or pairs of a piece of the disc's text and what stands for it. The edit applies only +// where the disc's message still has what it expects: every "find" occurs in it, and "original", +// when given, is the LocalizeTextHash of the whole message. +struct LocalizePatch { + int id = 0; + std::string original; + std::optional text; + std::vector> replace; +}; + +struct LocalizeTable { + // Message file key > message id > text. + std::map> files; + // Patches by message file key, in the order they were read; a message a "files" entry has is not patched. + std::map> patches; + // The port's own text, the Options screen's: key (a dotted name that does not end in a message id) > text. + std::map strings; +}; + +// The key of a message file, the same in every language: the path the game loads (or "/" +// for a file in a pack) with the language taken out, the ".mes" taken off and the slashes made dots. +// dun/script/d01/d01_3.mes is "dun.script.d01.d01", and commenu/a_fre/dungeon/dunmenu5.pak/allmenu.mes is +// "commenu.dungeon.dunmenu5.pak.allmenu". +std::string LocalizeMessageKey(std::string_view path); + +// A 64-bit FNV-1a of a message's text as 16 hex digits: what "original" in a patch is checked against, +// and what the export lists in hashes/.json for the person writing one. +std::string LocalizeTextHash(std::string_view text); + +// What a replaced file cost: texts that went in, characters the font lacks (shown as '?'), and +// texts left as retail because they did not fit. +struct LocalizeStats { + int replaced = 0; + int missing = 0; + int rejected = 0; +}; + +// Reads one language's file: a flat object of ".": text for the disc's messages, and +// "": text for the port's own (a key whose last part is not a number). Gives back false with the +// reason in error for text that is not JSON, a number that is not an s16 id, or a value that is not a +// string; table is untouched then. +bool LocalizeParse(std::string_view json, LocalizeTable &table, std::string &error); + +// Reads a patch file, {"patches": [...]}, in the same way. +bool LocalizeParsePatches(std::string_view json, LocalizeTable &table, std::string &error); + +// Table entries of over replace those of base. +void LocalizeMerge(LocalizeTable &base, const LocalizeTable &over); + +// The messages of a retail message file of words s16, as GameTextDecode reads them; the first of +// an id the file repeats, as the game's lookup finds it. False for a file that is not one. +bool LocalizeReadMessages(const s16 *file, size_t words, std::map &out); + +// retail with texts laid over it, as a message file in out. False, leaving out as it was, where +// retail is not a message file or nothing changed. +bool LocalizeMessages(const s16 *retail, size_t words, const std::map &texts, + std::vector &out, LocalizeStats &stats); + +// The text of a string the port draws itself (the Options screen's labels, help and choices): the current +// language's text for key if its JSON has one, else english. A "%1", "%2"... in a text stands for what +// LocalizeFormat is given. +std::string LocalizeText(std::string_view key, std::string_view english); +std::string LocalizeFormat(std::string text, std::initializer_list arguments); + +// Host hooks, called from the data loaders. LocalizeLoaded notes where a pack came from (only +// .pak and .pac are kept). LocalizeFile rewrites a message file just read into buffer, which holds +// capacity bytes, and sets *size; a file that would not fit stays as retail. LocalizePack gives +// back the data of pack entry name as the JSON has it (a buffer that lives on), or data itself. +void LocalizeLoaded(std::string_view path, const void *buffer); +bool LocalizeFile(std::string_view path, void *buffer, size_t capacity, int *size); +const u_int *LocalizePack(const u_int *pack, std::string_view name, const u_int *data, int *size); + +// Writes every message file under the data root as one flat file per language into out (en_gb.json, +// fr_fr.json...; the files that name no language go in every one) and each message's hash into +// out/hashes/ the same way. Gives back how many message files it read, or -1 where the data cannot be +// read; verify counts, on stderr, texts that do not encode back to the retail codes. +int LocalizeExport(const std::filesystem::path &out, bool verify); + +// The file called name in the language folders (the save folder's before the executable's), or an +// empty path where neither has one. +std::filesystem::path LocalizeFindFile(std::string_view name); + +struct Config; + +// A ConfigChangeHook: when game.language is set to a language other than the current one, the switch +// is held until the game can make it whole. A language is not one setting but everything an area loads +// (its message files, menu packs and images), so changing it under a loaded area leaves half of it in +// each; the game applies it between areas, and reloads the town or title it is in once the Options +// screen has closed. +void LocalizeConfigChanged(const Config &before, const Config &after); + +// Whether a language is waiting to be switched to, and the switch: LanguageCode changes and the system +// messages (names, places) are read again. The game loop calls it where a new area is about to load. +bool LocalizeLanguagePending(); +void LocalizeApplyPendingLanguage(); + +// For tests: where the language files are, and forgetting what was read. +void LocalizeSetDirectories(std::vector directories); +void LocalizeReset(); diff --git a/port/src/main.cpp b/port/src/main.cpp index 687cd5420..ed72b77d7 100644 --- a/port/src/main.cpp +++ b/port/src/main.cpp @@ -16,6 +16,7 @@ #include "gamemode.hpp" #include "gfx/gfx.hpp" #include "langset.hpp" +#include "localize.hpp" #include "menu_option.hpp" #include "menu_save.hpp" #include "mglib.hpp" @@ -56,6 +57,7 @@ struct Options { bool fast_load = false; bool show_fps = false; bool screenshot_fps = false; + const char *export_text = nullptr; }; Options g_options; @@ -80,6 +82,7 @@ Options g_options; " --screenshot-fps with --screenshot and --show-fps: the image as a window shows it, the\n" " FPS counter over it\n" " --display-per-tick N headless: also render N interpolated display frames per tick\n" + " --export-text DIR write the game's message files to DIR as language JSON (docs/LOCALIZATION.md)\n" " --show-fps draw the FPS counter on presented frames when headless too\n" "test hooks:\n" " --jump MODE[:MAP] start in edit:, dungeon:<0-6>, title, rush, opening or menu,\n" @@ -126,6 +129,8 @@ Options ParseOptions(int argc, const char **argv) { options.jump = value(); } else if (arg == "--fast-load") { options.fast_load = true; + } else if (arg == "--export-text") { + options.export_text = value(); } else if (arg == "--show-fps") { options.show_fps = true; } else if (arg == "--screenshot-fps") { @@ -334,6 +339,21 @@ int Run(int argc, const char **argv) { const char *fast_load = std::getenv("DC_FAST_LOAD"); GameSetFastLoad(options.fast_load || (fast_load != nullptr && *fast_load != '\0' && *fast_load != '0')); RequireData(); + if (options.export_text == nullptr) { + // Once per save folder: the extracted data's text as JSON for translators (docs/LOCALIZATION.md). + std::filesystem::path seed = PathsSaveRoot() / "lang-export"; + std::error_code error; + if (!std::filesystem::exists(seed / "en_gb.json", error)) { + std::fprintf(stderr, "exporting the game text to %s\n", PathsDisplay(seed).c_str()); + if (LocalizeExport(seed, false) < 0) { + std::fprintf(stderr, "the game text could not be exported\n"); + } + } + } + if (options.export_text != nullptr) { + // The game's message files as language JSON for translators; no window, no game. + return LocalizeExport(options.export_text, true) >= 0 ? kExitOk : kExitUsage; + } LoadInputScript(options.input); ConfigLoad(); @@ -366,6 +386,7 @@ int Run(int argc, const char **argv) { ConfigAddChangeHook(ApplyConfigChange); ConfigAddChangeHook(DisplayChanged); ConfigAddChangeHook(GameOptionsChanged); + ConfigAddChangeHook(LocalizeConfigChanged); int status = RunGame(argc, const_cast(argv)); if (status == kExitOk && options.screenshot != nullptr) { @@ -373,6 +394,7 @@ int Run(int argc, const char **argv) { } ReportPresentStats(); + ConfigRemoveChangeHook(LocalizeConfigChanged); ConfigRemoveChangeHook(GameOptionsChanged); ConfigRemoveChangeHook(DisplayChanged); ConfigRemoveChangeHook(ApplyConfigChange); diff --git a/port/src/menu_option.cpp b/port/src/menu_option.cpp index a65cab849..448a8daa0 100644 --- a/port/src/menu_option.cpp +++ b/port/src/menu_option.cpp @@ -1,9 +1,12 @@ #include "menu_option.hpp" +#include "localize.hpp" + #include #include #include #include +#include #include #include #include @@ -19,6 +22,7 @@ #include "memcard.hpp" #include "memorycardaccess.hpp" #include "menu_draw.hpp" +#include "mainselect.hpp" #include "menuetc.hpp" #include "platform/display.hpp" #include "platform/input.hpp" @@ -146,6 +150,27 @@ std::string ChoiceName(const char *names, int choice) { return std::string(rest.substr(0, rest.find('|'))); } +// The Options screen's own text is looked up by key, in the language the game is in (localize.hpp); the +// English here is what it shows where the language has none. A setting's strings are options..label, .help and .choice., and a page's is options.page.. +std::string ChoiceText(const Row &row, int choice) { + return LocalizeText("options." + std::string(row.key) + ".choice." + std::to_string(choice), + ChoiceName(row.names, choice)); +} + +// A value a row works out itself ("Desktop", "Unlimited") by its English: options..value.. +std::string ValueText(const Row &row, const std::string &english) { + return LocalizeText("options." + std::string(row.key) + ".value." + english, english); +} + +std::string PageKey(const char *name) { + std::string key = "options.page."; + for (const char *c = name; *c != '\0'; ++c) { + key += static_cast(*c >= 'A' && *c <= 'Z' ? *c - 'A' + 'a' : *c); + } + return key; +} + int Nearest(std::span choices, double value) { int best = 0; for (int i = 1; i < static_cast(choices.size()); ++i) { @@ -229,6 +254,19 @@ int MapCount(const Config &) { return 4; } +// Ask, then the five languages of the language screen (LanguageCode 2 to 6). +int LanguageChoice(const Config &config) { + return config.language >= 2 && config.language <= 6 ? config.language - 1 : 0; +} + +void SetLanguage(Config &config, int choice) { + config.language = choice == 0 ? 0 : choice + 1; +} + +int LanguageCount(const Config &) { + return 6; +} + int ResolutionCount(const Config &) { return static_cast(g_sizes.size()); } @@ -519,6 +557,9 @@ const Row kGameRows[] = { "\"Discord Rich Presence\"\nShows what you are\nplaying on Discord."), OnOffRow<&Config::qte_always_win>("game.qte_always_win", "Always Win QTEs", "\"Always Win QTEs\"\nButton prompts always\nend in a perfect."), + Row{"game.language", "Language", + "\"Language\"\nThe language of the game;\nAsk shows the language\nscreen at start-up.", -1, + LanguageCount, LanguageChoice, SetLanguage, nullptr, "Ask|English|Francais|Deutsch|Italiano|Espanol"}, }; const Row kDisplayRows[] = { @@ -595,6 +636,18 @@ const Tab kTabs[] = { }; constexpr int kTabCount = static_cast(std::size(kTabs)); + +// The screen's fixed texts in English, with the keys their translations have. +constexpr const char *kShortcutsText = PAD_GLYPH_SQUARE " Reset tab\n" PAD_GLYPH_TRIANGLE " Undo changes\n" PAD_GLYPH_CIRCLE + " Close"; +constexpr const char *kSaveHelpText = "Could not save.\nChanges apply now.\nClose to retry writing\nconfig.json."; +constexpr const char *kDisplayHelpText = "The display could not\nchange, and kept the\nmode it had. Try\nanother mode or size."; +constexpr const char *kTabHelpText = "\"Options\"\n" PAD_GLYPH_L1 " " PAD_GLYPH_R1 " or left and right\nturn the page."; +constexpr const char *kExitHelpText = PAD_GLYPH_CROSS " Close\n" PAD_GLYPH_SQUARE " This page's defaults\n" PAD_GLYPH_TRIANGLE + " Undo every change\n" PAD_GLYPH_CIRCLE " Close from any row"; +constexpr const char *kDisplayNowText = "The display could not\nchange as asked. It is\nnow %1"; +constexpr const char *kFullscreenNowText = "fullscreen."; +constexpr const char *kWindowNowText = "a %1 x %2\nwindow."; constexpr int kTabHelp = 998; constexpr int kExitHelp = 999; constexpr int kSaveHelp = 997; @@ -669,20 +722,19 @@ struct Texts { right.Set(">"); l1.Set(PAD_GLYPH_L1); r1.Set(PAD_GLYPH_R1); - shortcuts.Set(PAD_GLYPH_SQUARE " Reset tab\n" PAD_GLYPH_TRIANGLE " Undo changes\n" PAD_GLYPH_CIRCLE " Close"); - help.Set(kSaveHelp, "Could not save.\nChanges apply now.\nClose to retry writing\nconfig.json."); - help.Set(kDisplayHelp, "The display could not\nchange, and kept the\nmode it had. Try\nanother mode or size."); - help.Set(kTabHelp, "\"Options\"\n" PAD_GLYPH_L1 " " PAD_GLYPH_R1 " or left and right\nturn the page."); - help.Set(kExitHelp, PAD_GLYPH_CROSS " Close\n" PAD_GLYPH_SQUARE " This page's defaults\n" PAD_GLYPH_TRIANGLE - " Undo every change\n" PAD_GLYPH_CIRCLE " Close from any row"); + shortcuts.Set(LocalizeText("options.shortcuts", kShortcutsText)); + help.Set(kSaveHelp, LocalizeText("options.help.save_failed", kSaveHelpText)); + help.Set(kDisplayHelp, LocalizeText("options.help.display_kept", kDisplayHelpText)); + help.Set(kTabHelp, LocalizeText("options.help.tabs", kTabHelpText)); + help.Set(kExitHelp, LocalizeText("options.help.exit", kExitHelpText)); int index = 0; for (int t = 0; t < kTabCount; ++t) { - tabs[t].Set(kTabs[t].name); + tabs[t].Set(LocalizeText(PageKey(kTabs[t].name), kTabs[t].name)); for (const Row &row : kTabs[t].rows) { - labels.emplace_back().Set(row.label); + labels.emplace_back().Set(LocalizeText("options." + std::string(row.key) + ".label", row.label)); values.emplace_back(); if (row.help != nullptr) { - help.Set(kRowHelp + index, row.help); + help.Set(kRowHelp + index, LocalizeText("options." + std::string(row.key) + ".help", row.help)); } ++index; } @@ -690,9 +742,22 @@ struct Texts { } }; +std::unique_ptr g_texts; +int g_texts_language = -1; + Texts &GetTexts() { - static Texts texts; - return texts; + if (!g_texts) { + g_texts = std::make_unique(); + g_texts_language = LanguageCode; + } + return *g_texts; +} + +// The screen's text is laid out for a language; opening the screen in another lays it out again. +void RefreshTextsForLanguage() { + if (g_texts && g_texts_language != LanguageCode) { + g_texts.reset(); + } } int RowCount(int tab) { @@ -828,10 +893,13 @@ void ShowHelp() { message = kDisplayHelp; } else if (DisplayGetWarning() == DisplayWarning::Changed) { WindowMode mode = DisplayShownMode(); - std::string now = mode.fullscreen ? "fullscreen." : std::format("a {} x {}\nwindow.", mode.width, mode.height); + std::string now = mode.fullscreen ? LocalizeText("options.display.fullscreen_now", kFullscreenNowText) + : LocalizeFormat(LocalizeText("options.display.window_now", kWindowNowText), + {std::to_string(mode.width), std::to_string(mode.height)}); if (now != g_screen.display_now) { g_screen.display_now = now; - GetTexts().help.Set(kDisplayNowHelp, "The display could not\nchange as asked. It is\nnow " + now); + GetTexts().help.Set(kDisplayNowHelp, + LocalizeFormat(LocalizeText("options.help.display_now", kDisplayNowText), {now})); buffer = GetTexts().help.Data(); mes.mes_made = -1; } @@ -1270,6 +1338,7 @@ PC_OVERRIDE int InitMenuOption(int mode, int block_no, u_long128 *buffer) { return 0; } + RefreshTextsForLanguage(); g_screen.open = true; g_screen.mode = mode; g_screen.block_no = block_no; @@ -1475,7 +1544,7 @@ PC_OVERRIDE void DrawMenuOption() { int choice = row.get(config); texts.labels[index + r].Draw(kLabelX, y, alpha); GameText &value = texts.values[index + r]; - std::string text = row.text != nullptr ? row.text(config) : ChoiceName(row.names, choice); + std::string text = row.text != nullptr ? ValueText(row, row.text(config)) : ChoiceText(row, choice); if (ConfigAppliesOnRestart(row.key)) { text += " *"; } @@ -1498,3 +1567,44 @@ PC_OVERRIDE void DrawMenuOption() { PC_OVERRIDE int OptionMenuFadeOutStart() { return g_screen.step == OPTION_STEP_FADE_OUT; } + +std::vector> OptionStrings() { + std::vector> out; + // Rows may share a key (the key-binding rows share one help); the same text twice is listed once, and two + // texts under one key are both kept for the tests to find. + auto add = [&](std::string key, std::string english) { + for (const auto &[have_key, have_english] : out) { + if (have_key == key && have_english == english) { + return; + } + } + out.emplace_back(std::move(key), std::move(english)); + }; + add("options.shortcuts", kShortcutsText); + add("options.help.save_failed", kSaveHelpText); + add("options.help.display_kept", kDisplayHelpText); + add("options.help.tabs", kTabHelpText); + add("options.help.exit", kExitHelpText); + add("options.help.display_now", kDisplayNowText); + add("options.display.fullscreen_now", kFullscreenNowText); + add("options.display.window_now", kWindowNowText); + for (const Tab &tab : kTabs) { + add(PageKey(tab.name), tab.name); + for (const Row &row : tab.rows) { + const std::string prefix = "options." + std::string(row.key); + add(prefix + ".label", row.label); + if (row.help != nullptr) { + add(prefix + ".help", row.help); + } + if (row.names != nullptr) { + const int count = row.count(ConfigGet()); + for (int choice = 0; choice < count; ++choice) { + add(prefix + ".choice." + std::to_string(choice), ChoiceName(row.names, choice)); + } + } + } + } + add("options.video.width.value.Desktop", "Desktop"); + add("options.video.max_fps.value.Unlimited", "Unlimited"); + return out; +} diff --git a/port/src/menu_option.hpp b/port/src/menu_option.hpp index 05be778fb..c59f8e0a5 100644 --- a/port/src/menu_option.hpp +++ b/port/src/menu_option.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "platform/config.hpp" @@ -12,6 +14,11 @@ class CSaveData; // The host keeps the interface at 100% meanwhile: video.ui_scale applies from when it closes. bool MenuOptionOpen(); +// Every string the Options screen draws that is its own, not the game's: key and English, as +// localize.hpp's LocalizeText takes them (options..label, .help, .choice., options.page....). +// The export lists them in every language's file; port/lang/ ships their translations. +std::vector> OptionStrings(); + // Puts the game's options from config.json into the save, where the game reads them (its // configuration words, the dungeon map's status and the menu cursors' reset flag), and sets the // sound's stereo mode. A loaded save brings its own copy, so this runs before every mode starts diff --git a/port/src/platform/config.cpp b/port/src/platform/config.cpp index 385999917..14374cdf3 100644 --- a/port/src/platform/config.cpp +++ b/port/src/platform/config.cpp @@ -150,6 +150,23 @@ bool ReadGyro(const Json &value, ConfigGyro &out) { return false; } +constexpr const char *kLanguageNames[] = {"ask", "", "english", "francais", "deutsch", "italiano", "espanol"}; + +// "ask" or a language's name, as config.json spells them, becoming LanguageCode 0 or 2 to 6. +bool ReadLanguage(const Json &value, int &out) { + if (!value.is_string()) { + return false; + } + std::string language = Lower(value.get()); + for (std::size_t i = 0; i < std::size(kLanguageNames); ++i) { + if (kLanguageNames[i][0] != '\0' && language == kLanguageNames[i]) { + out = static_cast(i); + return true; + } + } + return false; +} + bool ReadList(const Json &value, std::vector &out) { std::vector items; if (value.is_string()) { @@ -322,6 +339,9 @@ bool Apply(Config &config, std::string_view name, const Json &value) { if (name == "game.debug_mode") { return ReadBool(value, config.debug_mode); } + if (name == "game.language") { + return ReadLanguage(value, config.language); + } if (name == "video.present_mode") { return ReadPresentMode(value, config.present_mode); } @@ -474,6 +494,7 @@ std::string ConfigSerialize(const Config &config) { root["game"]["tick_rate"] = config.tick_rate; root["game"]["debug_mode"] = config.debug_mode; root["game"]["qte_always_win"] = config.qte_always_win; + root["game"]["language"] = kLanguageNames[config.language >= 2 && config.language <= 6 ? config.language : 0]; const ConfigGameOptions &options = config.options; root["game"]["save_cursor_position"] = options.save_cursor_position; root["game"]["message_speed"] = options.fast_messages ? "fast" : "normal"; diff --git a/port/src/platform/config.hpp b/port/src/platform/config.hpp index 2911471b8..dd9d0c95f 100644 --- a/port/src/platform/config.hpp +++ b/port/src/platform/config.hpp @@ -91,6 +91,9 @@ struct Config { std::vector mouse_release_keys = {"Escape"}; ConfigGameOptions options; bool discord_rich_presence = true; + // game.language: 0 asks at start-up, as retail does; 2 to 6 is the language to start in + // (LanguageCode: English, Francais, Deutsch, Italiano, Espanol) and skips the language screen. + int language = 0; bool operator==(const Config &) const = default; }; diff --git a/port/src/platform/ttffont.cpp b/port/src/platform/ttffont.cpp new file mode 100644 index 000000000..832c4a075 --- /dev/null +++ b/port/src/platform/ttffont.cpp @@ -0,0 +1,433 @@ +#include "platform/ttffont.hpp" + +#include +#include +#include +#include +#include + +#define STB_TRUETYPE_IMPLEMENTATION +#define STBTT_STATIC +#include "stb_truetype.h" + +#include "platform/paths.hpp" + +namespace ttffont { + +namespace { + +constexpr int kAtlasSize = 2048; +// Space round each bitmap, so linear sampling at a quad's edge reads transparent texels. +constexpr int kPad = 1; + +struct Font { + std::vector data; + stbtt_fontinfo info = {}; + bool loaded = false; +}; + +struct Atlas { + gfx::TextureHandle texture = gfx::kNullTexture; + int x = 0; + int y = 0; + int row_height = 0; + std::map, Glyph> glyphs; + std::map, bool> missing; +}; + +Font g_font; +Atlas g_atlas; + +void ResetAtlas() { + if (g_atlas.texture != gfx::kNullTexture) { + gfx::DestroyTexture(g_atlas.texture); + } + g_atlas = {}; +} + +bool EnsureAtlas() { + if (g_atlas.texture == gfx::kNullTexture) { + gfx::TextureDesc desc; + desc.width = kAtlasSize; + desc.height = kAtlasSize; + desc.format = gfx::TextureFormat::Rgba8; + g_atlas.texture = gfx::CreateTexture(desc); + } + return g_atlas.texture != gfx::kNullTexture; +} + +// ---- Glyph bitmaps, and letters the font lacks built from ones it has ---- + +// Coverage, with its top-left corner (x0, y0) in pixels from the pen on the baseline (y down). +struct Bitmap { + int x0 = 0; + int y0 = 0; + int w = 0; + int h = 0; + std::vector alpha; +}; + +// The glyph index of ch when the font draws something for it. A blank placeholder (a font that maps +// a letter it does not have to an empty outline) counts as missing. +int DrawnGlyph(char32_t ch) { + const int index = stbtt_FindGlyphIndex(&g_font.info, static_cast(ch)); + return index != 0 && !stbtt_IsGlyphEmpty(&g_font.info, index) ? index : 0; +} + +Bitmap Raster(int index, float scale_x, float scale_y) { + Bitmap bitmap; + int x1, y1; + stbtt_GetGlyphBitmapBox(&g_font.info, index, scale_x, scale_y, &bitmap.x0, &bitmap.y0, &x1, &y1); + bitmap.w = x1 - bitmap.x0; + bitmap.h = y1 - bitmap.y0; + if (bitmap.w > 0 && bitmap.h > 0) { + bitmap.alpha.assign(static_cast(bitmap.w) * bitmap.h, 0); + stbtt_MakeGlyphBitmap(&g_font.info, bitmap.alpha.data(), bitmap.w, bitmap.h, bitmap.w, scale_x, scale_y, index); + } + return bitmap; +} + +struct Part { + const Bitmap *bitmap; + int dx; + int dy; +}; + +// The parts laid over each other, each moved by its own offset; the strongest coverage wins. +Bitmap Combine(const std::vector &parts) { + int x0 = INT32_MAX, y0 = INT32_MAX, x1 = INT32_MIN, y1 = INT32_MIN; + for (const Part &part : parts) { + if (part.bitmap->w <= 0 || part.bitmap->h <= 0) { + continue; + } + x0 = std::min(x0, part.bitmap->x0 + part.dx); + y0 = std::min(y0, part.bitmap->y0 + part.dy); + x1 = std::max(x1, part.bitmap->x0 + part.dx + part.bitmap->w); + y1 = std::max(y1, part.bitmap->y0 + part.dy + part.bitmap->h); + } + Bitmap out; + if (x0 >= x1 || y0 >= y1) { + return out; + } + out.x0 = x0; + out.y0 = y0; + out.w = x1 - x0; + out.h = y1 - y0; + out.alpha.assign(static_cast(out.w) * out.h, 0); + for (const Part &part : parts) { + for (int row = 0; row < part.bitmap->h; row++) { + for (int col = 0; col < part.bitmap->w; col++) { + unsigned char &to = out.alpha[static_cast(part.bitmap->y0 + part.dy + row - y0) * out.w + + (part.bitmap->x0 + part.dx + col - x0)]; + to = std::max(to, part.bitmap->alpha[static_cast(row) * part.bitmap->w + col]); + } + } + } + return out; +} + +enum class Mark { + Grave, + Acute, + Circumflex, + Diaeresis, + Tilde, + Cedilla +}; + +// A character the PAL font draws as a letter with a mark on it, or under it. +struct Accented { + char32_t ch; + char32_t base; + Mark mark; +}; + +constexpr Accented kAccented[] = { + {U'À', U'A', Mark::Grave }, + {U'Á', U'A', Mark::Acute }, + {U'Â', U'A', Mark::Circumflex}, + {U'Ä', U'A', Mark::Diaeresis }, + {U'È', U'E', Mark::Grave }, + {U'É', U'E', Mark::Acute }, + {U'Ê', U'E', Mark::Circumflex}, + {U'Ë', U'E', Mark::Diaeresis }, + {U'Ì', U'I', Mark::Grave }, + {U'Í', U'I', Mark::Acute }, + {U'Î', U'I', Mark::Circumflex}, + {U'Ï', U'I', Mark::Diaeresis }, + {U'Ñ', U'N', Mark::Tilde }, + {U'Ò', U'O', Mark::Grave }, + {U'Ó', U'O', Mark::Acute }, + {U'Ô', U'O', Mark::Circumflex}, + {U'Ö', U'O', Mark::Diaeresis }, + {U'Ù', U'U', Mark::Grave }, + {U'Ú', U'U', Mark::Acute }, + {U'Û', U'U', Mark::Circumflex}, + {U'Ü', U'U', Mark::Diaeresis }, + {U'Ç', U'C', Mark::Cedilla }, + {U'à', U'a', Mark::Grave }, + {U'á', U'a', Mark::Acute }, + {U'â', U'a', Mark::Circumflex}, + {U'ä', U'a', Mark::Diaeresis }, + {U'è', U'e', Mark::Grave }, + {U'é', U'e', Mark::Acute }, + {U'ê', U'e', Mark::Circumflex}, + {U'ë', U'e', Mark::Diaeresis }, + {U'ì', U'i', Mark::Grave }, + {U'í', U'i', Mark::Acute }, + {U'î', U'i', Mark::Circumflex}, + {U'ï', U'i', Mark::Diaeresis }, + {U'ñ', U'n', Mark::Tilde }, + {U'ò', U'o', Mark::Grave }, + {U'ó', U'o', Mark::Acute }, + {U'ô', U'o', Mark::Circumflex}, + {U'ö', U'o', Mark::Diaeresis }, + {U'ù', U'u', Mark::Grave }, + {U'ú', U'u', Mark::Acute }, + {U'û', U'u', Mark::Circumflex}, + {U'ü', U'u', Mark::Diaeresis }, + {U'ç', U'c', Mark::Cedilla }, +}; + +// The plain character whose glyph stands for a mark, for the fonts that have no accent characters of +// their own: a tick for the acute, a pair of them for the diaeresis, a comma for the cedilla. +char32_t MarkGlyph(Mark mark) { + switch (mark) { + case Mark::Grave: + return U'`'; + case Mark::Acute: + return U'\''; + case Mark::Circumflex: + return U'^'; + case Mark::Diaeresis: + return U'"'; + case Mark::Tilde: + return U'~'; + case Mark::Cedilla: + return U','; + } + return 0; +} + +// Builds ch from the font's own glyphs when the font draws nothing for it; false for a character this +// does not know how to build (the caller then draws the game's bitmap). scale is pixels per font unit. +bool Compose(char32_t ch, float scale, int em_px, Bitmap &out) { + const int gap = std::max(1, em_px / 25); + + for (const Accented &accented : kAccented) { + if (accented.ch != ch) { + continue; + } + const int base_index = DrawnGlyph(accented.base); + const int mark_index = DrawnGlyph(MarkGlyph(accented.mark)); + if (base_index == 0 || mark_index == 0) { + return false; + } + const bool upper = accented.base >= U'A' && accented.base <= U'Z'; + const float k = accented.mark == Mark::Cedilla ? 0.9f : upper ? 0.7f + : 0.85f; + const Bitmap base = Raster(base_index, scale, scale); + const Bitmap mark = Raster(mark_index, scale * k, scale * k); + if (base.w <= 0 || mark.w <= 0) { + return false; + } + const int dx = (base.x0 + base.w / 2) - (mark.x0 + mark.w / 2); + int dy; + if (accented.mark == Mark::Cedilla) { + // The comma's top just inside the letter's bottom, its tail below the baseline. + dy = (base.y0 + base.h - 2) - mark.y0; + } else { + // The mark's bottom a little above the letter's top (a lower-case i's dot included). + dy = base.y0 - gap - (mark.y0 + mark.h); + } + out = Combine({ + {&base, 0, 0 }, + {&mark, dx, dy} + }); + return out.w > 0; + } + + if (ch == U'¡' || ch == U'¿') { + const int index = DrawnGlyph(ch == U'¡' ? U'!' : U'?'); + if (index == 0) { + return false; + } + out = Raster(index, scale, scale); + // Turned over inside its own box, so it keeps the height and the depth the original had. + for (int row = 0; row < out.h / 2; row++) { + std::swap_ranges(out.alpha.begin() + static_cast(row) * out.w, + out.alpha.begin() + static_cast(row + 1) * out.w, + out.alpha.begin() + static_cast(out.h - 1 - row) * out.w); + } + return out.w > 0; + } + + if (ch == U'œ' || ch == U'Œ') { + const int first = DrawnGlyph(ch == U'œ' ? U'o' : U'O'); + const int second = DrawnGlyph(ch == U'œ' ? U'e' : U'E'); + if (first == 0 || second == 0) { + return false; + } + // Two letters, each squeezed to 65% of its width, the second tucked against the first. + const Bitmap left = Raster(first, scale * 0.65f, scale); + const Bitmap right = Raster(second, scale * 0.65f, scale); + out = Combine({ + {&left, 0, 0}, + {&right, left.x0 + left.w - right.x0 - 1, 0} + }); + return out.w > 0; + } + return false; +} + +} // namespace + +bool Load(const std::filesystem::path &file) { + std::ifstream stream(file, std::ios::binary); + if (!stream) { + std::fprintf(stderr, "font: cannot read %s\n", PathsDisplay(file).c_str()); + return false; + } + Font font; + font.data.assign(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + const unsigned char *bytes = font.data.data(); + int offset = stbtt_GetFontOffsetForIndex(bytes, 0); + if (offset < 0 || !stbtt_InitFont(&font.info, bytes, offset)) { + std::fprintf(stderr, "font: %s is not a TrueType font\n", PathsDisplay(file).c_str()); + return false; + } + font.loaded = true; + // The font's data moves with the vector it is in, so the info is built on the final place. + g_font = std::move(font); + stbtt_InitFont(&g_font.info, g_font.data.data(), stbtt_GetFontOffsetForIndex(g_font.data.data(), 0)); + ResetAtlas(); + return true; +} + +bool Loaded() { + return g_font.loaded; +} + +bool GetGlyph(char32_t ch, int em_px, Glyph &out) { + if (!g_font.loaded || em_px < 4 || em_px > 512) { + return false; + } + const auto key = std::make_pair(ch, em_px); + if (auto found = g_atlas.glyphs.find(key); found != g_atlas.glyphs.end()) { + out = found->second; + return true; + } + if (g_atlas.missing.count(key) != 0) { + return false; + } + if (!EnsureAtlas()) { + g_atlas.missing[key] = true; + return false; + } + + // An em of em_px pixels: stb scales by the font's units per em. + const float scale = stbtt_ScaleForMappingEmToPixels(&g_font.info, static_cast(em_px)); + Bitmap bitmap; + if (const int index = DrawnGlyph(ch); index != 0) { + bitmap = Raster(index, scale, scale); + } else if (!Compose(ch, scale, em_px, bitmap)) { + g_atlas.missing[key] = true; + return false; + } + const int width = bitmap.w; + const int height = bitmap.h; + + if (width > 0 && height > 0) { + if (g_atlas.x + width + 2 * kPad > kAtlasSize) { + g_atlas.x = 0; + g_atlas.y += g_atlas.row_height; + g_atlas.row_height = 0; + } + if (g_atlas.y + height + 2 * kPad > kAtlasSize) { + // Full: start again. Glyphs queued this frame keep their old coordinates, which now + // show whatever lands there; one frame of it, and the next fills the atlas afresh. + std::fprintf(stderr, "font: atlas full, starting again\n"); + gfx::TextureHandle keep = g_atlas.texture; + g_atlas = {}; + g_atlas.texture = keep; + std::vector blank(static_cast(kAtlasSize) * kAtlasSize * 4, 0); + gfx::UpdateTexture(keep, 0, 0, 0, kAtlasSize, kAtlasSize, blank.data()); + } + } + + Glyph glyph = {}; + glyph.x_offset = static_cast(bitmap.x0); + glyph.y_offset = static_cast(bitmap.y0); + glyph.width = static_cast(width); + glyph.height = static_cast(height); + + if (width > 0 && height > 0) { + // White with the coverage as alpha, so the draw's vertex colour tints it. + const int w = width + 2 * kPad; + const int h = height + 2 * kPad; + std::vector pixels(static_cast(w) * h * 4, 0); + for (int row = 0; row < height; row++) { + for (int col = 0; col < width; col++) { + unsigned char *texel = &pixels[(static_cast(row + kPad) * w + col + kPad) * 4]; + texel[0] = texel[1] = texel[2] = 255; + texel[3] = bitmap.alpha[static_cast(row) * width + col]; + } + } + gfx::UpdateTexture(g_atlas.texture, 0, static_cast(g_atlas.x), static_cast(g_atlas.y), + static_cast(w), static_cast(h), pixels.data()); + glyph.u0 = static_cast(g_atlas.x + kPad); + glyph.v0 = static_cast(g_atlas.y + kPad); + glyph.u1 = static_cast(g_atlas.x + kPad + width); + glyph.v1 = static_cast(g_atlas.y + kPad + height); + g_atlas.x += w; + g_atlas.row_height = std::max(g_atlas.row_height, h); + } + + g_atlas.glyphs[key] = glyph; + out = glyph; + return true; +} + +bool RenderGlyph(char32_t ch, int em_px, std::vector &alpha, int &w, int &h, int &x0, int &y0) { + if (!g_font.loaded || em_px < 4 || em_px > 512) { + return false; + } + const float scale = stbtt_ScaleForMappingEmToPixels(&g_font.info, static_cast(em_px)); + Bitmap bitmap; + if (const int index = DrawnGlyph(ch); index != 0) { + bitmap = Raster(index, scale, scale); + } else if (!Compose(ch, scale, em_px, bitmap)) { + return false; + } + alpha = std::move(bitmap.alpha); + w = bitmap.w; + h = bitmap.h; + x0 = bitmap.x0; + y0 = bitmap.y0; + return true; +} + +gfx::TextureBinding Binding() { + gfx::TextureBinding binding; + binding.texture = g_atlas.texture; + binding.filter = gfx::Filter::Linear; + return binding; +} + +Metrics GetMetrics() { + Metrics metrics = {0.8f, 0.2f, 0.6f}; + if (!g_font.loaded) { + return metrics; + } + int ascent, descent, gap; + stbtt_GetFontVMetrics(&g_font.info, &ascent, &descent, &gap); + const float em = 1.0f / stbtt_ScaleForMappingEmToPixels(&g_font.info, 1.0f); + metrics.ascent = static_cast(ascent) / em; + metrics.descent = static_cast(-descent) / em; + int advance, bearing; + stbtt_GetCodepointHMetrics(&g_font.info, '0', &advance, &bearing); + metrics.advance = static_cast(advance) / em; + return metrics; +} + +} // namespace ttffont diff --git a/port/src/platform/ttffont.hpp b/port/src/platform/ttffont.hpp new file mode 100644 index 000000000..99c86fc3c --- /dev/null +++ b/port/src/platform/ttffont.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "gfx/gfx.hpp" + +// A TrueType font for the game's message text, drawn at the screen's own resolution instead of +// from the 14x20 bitmaps of gaiji.img. Host side: plain types only. +namespace ttffont { + +// Reads the font file; false (with a message on stderr) for one that is missing or not TrueType. +// Loading again replaces the font. +bool Load(const std::filesystem::path &file); + +bool Loaded(); + +// One glyph in the atlas. u and v are texel coordinates of the bitmap in the atlas; the bitmap is +// width x height pixels, its top-left x_offset pixels right of the pen and y_offset pixels below +// the baseline (negative above it). +struct Glyph { + float u0, v0, u1, v1; + float x_offset, y_offset; + float width, height; +}; + +// The glyph of ch at an em of em_px pixels, rasterized into the atlas when first asked for. False +// where the font has no glyph for ch, so the caller draws what it drew before, or the atlas is full. +bool GetGlyph(char32_t ch, int em_px, Glyph &out); + +// The glyph of ch at an em of em_px pixels as plain coverage (alpha, w x h, 0 to 255) with its top-left +// corner x0, y0 pixels from the pen on the baseline, y down; what GetGlyph puts in the atlas, and what +// the tests look at. False where GetGlyph would say so. +bool RenderGlyph(char32_t ch, int em_px, std::vector &alpha, int &w, int &h, int &x0, int &y0); + +// The atlas, to draw GetGlyph's coordinates from. +gfx::TextureBinding Binding(); + +// The font's own proportions, as fractions of an em. +struct Metrics { + float ascent; + float descent; // positive, below the baseline + float advance; // of the digit 0 +}; + +Metrics GetMetrics(); + +} // namespace ttffont diff --git a/port/src/tests/gfx_fixture.hpp b/port/src/tests/gfx_fixture.hpp index cf140e706..d8637ab2d 100644 --- a/port/src/tests/gfx_fixture.hpp +++ b/port/src/tests/gfx_fixture.hpp @@ -39,7 +39,7 @@ struct GfxFixture { GfxFixture(int width, int height, float render_scale, const GfxOptions &options) { // Synchronization validation too, unless the environment already chose layer features. - dc::test::SetEnv("VK_LAYER_ENABLES", "VK_VALIDATION_FEATURE_ENABLE_SYNCHRONIZATION_VALIDATION_EXT", 0); + dc::test::SetEnv("VK_LAYER_VALIDATE_SYNC", "1", 0); WindowConfig window{width, height, true}; window.vulkan = !options.offscreen; WindowInit(window); diff --git a/port/src/tests/localize_test.cpp b/port/src/tests/localize_test.cpp new file mode 100644 index 000000000..94dbee400 --- /dev/null +++ b/port/src/tests/localize_test.cpp @@ -0,0 +1,684 @@ +// nlohmann/json before the game headers, whose one-letter macros it cannot stand. +#include +#include + +#include +#include +#include +#include +#include + +#include "clsmes.hpp" +#include "data_fixture.hpp" +#include "gametext.hpp" +#include "localize.hpp" +#include "mainselect.hpp" + +namespace { + +namespace fs = std::filesystem; + +// A retail-shaped message file of the given texts, as the loaders hand it over. +std::vector File(const std::map &texts) { + GameTextFile file; + for (const auto &[id, text] : texts) { + EXPECT_EQ(file.Set(id, text), 0); + } + const s16 *data = file.Data(); + // The last message by offset ends the file. + size_t end = 2 + static_cast(data[0]) * 2; + for (int i = 0; i < data[0]; i++) { + size_t at = 1 + static_cast(data[0]) + static_cast(data[3 + i * 2]); + while (data[at] != MES_CODE_END) { + at++; + } + end = std::max(end, at + 1); + } + return std::vector(data, data + end); +} + +std::string Text(std::vector &file, int id) { + ClsMes mes; + mes.SetBuff(file.data()); + const s16 *top = mes.GetTextLineDataTop(id); + return top == nullptr ? "" : GameTextDecode(top); +} + +void Write(const fs::path &path, const std::string &text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; +} + +struct LangDir { + fs::path dir = datafix::TempDir("localize"); + + LangDir() { + LocalizeReset(); + LocalizeSetDirectories({dir}); + LanguageCode = 3; + } + + ~LangDir() { + LocalizeReset(); + LocalizeSetDirectories({}); + std::error_code error; + fs::remove_all(dir, error); + } +}; + +// A buffer as LoadFile2 leaves one: the file, then zeros to whole sectors. +struct Loaded { + alignas(64) unsigned char bytes[4096] = {}; + int size = 0; + + explicit Loaded(const std::vector &file) { + size = static_cast(file.size() * 2); + std::memcpy(bytes, file.data(), static_cast(size)); + } +}; + +} // namespace + +TEST(Localize, ParsesTheFormat) { + LocalizeTable table; + std::string error; + ASSERT_TRUE(LocalizeParse(R"({"dun.script.d01.d01.0": "a", "dun.script.d01.d01.-5": "b{{", "meswin.system14.12": "c"})", + table, error)) + << error; + ASSERT_EQ(table.files.count("dun.script.d01.d01"), 1U); + EXPECT_EQ(table.files["dun.script.d01.d01"].at(0), "a"); + EXPECT_EQ(table.files["dun.script.d01.d01"].at(-5), "b{{"); + EXPECT_EQ(table.files["meswin.system14"].at(12), "c"); +} + +TEST(Localize, MessageKeysAreTheSameInEveryLanguage) { + EXPECT_EQ(LocalizeMessageKey("dun/script/d01/d01_3.mes"), "dun.script.d01.d01"); + EXPECT_EQ(LocalizeMessageKey("dun/script/d01/d01_6.mes"), "dun.script.d01.d01"); + EXPECT_EQ(LocalizeMessageKey("cdrom0:\\DUN\\SCRIPT\\D01\\D01_3.MES"), "dun.script.d01.d01"); + EXPECT_EQ(LocalizeMessageKey("dun/message/ww_mes/steve07_1_4.mes"), "dun.message.ww_mes.steve07_1"); + EXPECT_EQ(LocalizeMessageKey("commenu/a_fre/dungeon/dunmenu5.pak/allmenu.mes"), "commenu.dungeon.dunmenu5.pak.allmenu"); + EXPECT_EQ(LocalizeMessageKey("commenu/a_eng/dungeon/dunmenu5.pak/allmenu.mes"), "commenu.dungeon.dunmenu5.pak.allmenu"); + EXPECT_EQ(LocalizeMessageKey("meswin/system14_2.mes"), "meswin.system14"); + // A file that names no language keeps its name. + EXPECT_EQ(LocalizeMessageKey("noda_w/dun00_eng.mes"), "noda_w.dun00_eng"); + EXPECT_EQ(LocalizeMessageKey("commenu/emenu.pak/allmenu.mes"), "commenu.emenu.pak.allmenu"); +} + +TEST(Localize, RejectsBadJson) { + LocalizeTable table; + std::string error; + EXPECT_FALSE(LocalizeParse("{", table, error)); + EXPECT_FALSE(LocalizeParse("[]", table, error)); + EXPECT_FALSE(LocalizeParse(R"({"a.b.40000": "t"})", table, error)); + EXPECT_NE(error.find("not a message key"), std::string::npos); + EXPECT_FALSE(LocalizeParse(R"({".1": "t"})", table, error)); + EXPECT_FALSE(LocalizeParse(R"({"a.b.1": 5})", table, error)); + EXPECT_FALSE(LocalizeParse(R"({"options.x.label": 5})", table, error)); + EXPECT_TRUE(table.files.empty()); + EXPECT_TRUE(table.strings.empty()); +} + +// A key that does not end in a message id is one of the port's own strings. +TEST(Localize, PortStringsAreKeysThatDoNotEndInAnId) { + LocalizeTable table; + std::string error; + ASSERT_TRUE(LocalizeParse(R"({"options.game.map.label": "Carte", "options.game.map.choice.3": "Non", + "dun.script.d01.d01.1": "msg"})", + table, error)) + << error; + EXPECT_EQ(table.strings.at("options.game.map.label"), "Carte"); + EXPECT_EQ(table.strings.count("options.game.map.choice.3"), 0U); + EXPECT_EQ(table.files.at("options.game.map.choice").at(3), "Non"); + EXPECT_EQ(table.files.at("dun.script.d01.d01").at(1), "msg"); +} + +TEST(Localize, TextComesFromTheLanguageJsonOrTheEnglishGiven) { + LangDir lang; + Write(lang.dir / "fr_fr.json", R"({"options.game.map.label": "Carte du donjon", "options.game.map.choice.3": "Non"})"); + EXPECT_EQ(LocalizeText("options.game.map.label", "Dungeon Map"), "Carte du donjon"); + EXPECT_EQ(LocalizeText("options.game.map.choice.3", "Off"), "Non"); + EXPECT_EQ(LocalizeText("options.game.map.choice.2", "3"), "3") << "a key the file lacks gives the English"; + LanguageCode = 4; + EXPECT_EQ(LocalizeText("options.game.map.label", "Dungeon Map"), "Dungeon Map") << "no file for German"; +} + +TEST(Localize, FormatFillsThePlaceholders) { + EXPECT_EQ(LocalizeFormat("a %1 x %2\nwindow.", {"1280", "720"}), "a 1280 x 720\nwindow."); + EXPECT_EQ(LocalizeFormat("%2 then %1, %1 again", {"x", "y"}), "y then x, x again"); + EXPECT_EQ(LocalizeFormat("100%", {"x"}), "100%"); +} + +TEST(Localize, ReplacesById) { + std::vector retail = File({ + {1, "one" }, + {2, "two" }, + {3, "three"} + }); + std::vector out; + LocalizeStats stats; + ASSERT_TRUE(LocalizeMessages(retail.data(), retail.size(), { + {2, "deux {-752}" }, + {9, "neuf\nlignes"} + }, + out, stats)); + EXPECT_EQ(stats.replaced, 2); + EXPECT_EQ(stats.missing, 0); + EXPECT_EQ(stats.rejected, 0); + EXPECT_EQ(Text(out, 1), "one"); + EXPECT_EQ(Text(out, 2), "deux {-752}"); + EXPECT_EQ(Text(out, 3), "three"); + EXPECT_EQ(Text(out, 9), "neuf\nlignes"); + EXPECT_EQ(Text(out, 4), ""); +} + +TEST(Localize, CountsWhatTheFontLacks) { + std::vector retail = File({ + {1, "one"} + }); + std::vector out; + LocalizeStats stats; + ASSERT_TRUE(LocalizeMessages(retail.data(), retail.size(), { + {1, "a~b;"} + }, + out, stats)); + EXPECT_EQ(stats.missing, 2); + EXPECT_EQ(Text(out, 1), "a?b?"); +} + +TEST(Localize, KeepsRetailWhereATextDoesNotFit) { + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + std::vector out; + LocalizeStats stats; + // The first is past what a message file's s16 offsets reach, the last past its s16 ids. + ASSERT_TRUE(LocalizeMessages(retail.data(), retail.size(), + { + {1, std::string(0x8000, 'A')}, + {2, "deux"}, + {40000, "no"} + }, + out, stats)); + EXPECT_EQ(stats.rejected, 2); + EXPECT_EQ(stats.replaced, 1); + EXPECT_EQ(Text(out, 1), "one"); + EXPECT_EQ(Text(out, 2), "deux"); + + EXPECT_FALSE(LocalizeMessages(retail.data(), retail.size(), { + {1, std::string(0x8000, 'A')} + }, + out, stats)); + EXPECT_EQ(stats.rejected, 1); +} + +TEST(Localize, RetailRoundTrips) { + std::vector retail = File({ + {1, "Norune Village" }, + {2, "{-1021}x\n{-1024}"}, + {3, "Tani\xC3\xA8re" } + }); + std::map read; + ASSERT_TRUE(LocalizeReadMessages(retail.data(), retail.size(), read)); + EXPECT_EQ(read, (std::map{ + {1, "Norune Village" }, + {2, "{-1021}x\n{-1024}"}, + {3, "Tani\xC3\xA8re" } + })); + + std::vector out; + LocalizeStats stats; + ASSERT_TRUE(LocalizeMessages(retail.data(), retail.size(), read, out, stats)); + EXPECT_EQ(out, retail); + + s16 junk[2] = {-1, 0}; + EXPECT_FALSE(LocalizeReadMessages(junk, 2, read)); +} + +// A message that never ends (as the last id of the PAL allmenu.mes does) is left out, not fatal. +TEST(Localize, SkipsAMessageWithoutAnEnd) { + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + retail.pop_back(); + std::map read; + ASSERT_TRUE(LocalizeReadMessages(retail.data(), retail.size(), read)); + EXPECT_EQ(read.size(), 1U); +} + +TEST(Localize, LoadedFileComesFromTheLanguageJson) { + LangDir lang; + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + Write(lang.dir / "fr_fr.json", R"({"dun.script.d01.d01.2": "deux"})"); + + Loaded loaded(retail); + int size = loaded.size; + EXPECT_TRUE(LocalizeFile("cdrom0:\\DUN\\SCRIPT\\D01\\D01_3.MES", loaded.bytes, 2048, &size)); + std::vector got(reinterpret_cast(loaded.bytes), reinterpret_cast(loaded.bytes) + size / 2); + EXPECT_EQ(Text(got, 1), "one"); + EXPECT_EQ(Text(got, 2), "deux"); + + // Another file, and a name that is no message file, stay as they were. + Loaded other(retail); + size = other.size; + EXPECT_FALSE(LocalizeFile("dun/script/d02/d02_3.mes", other.bytes, 2048, &size)); + EXPECT_FALSE(LocalizeFile("dun/script/d01/d01_3.bin", other.bytes, 2048, &size)); + EXPECT_EQ(size, other.size); + + // A language with no file of its own has nothing to say. + LanguageCode = 4; + EXPECT_FALSE(LocalizeFile("dun/script/d01/d01_4.mes", other.bytes, 2048, &size)); + EXPECT_EQ(size, other.size); +} + +TEST(Localize, TheSaveFoldersFileLaysOverTheExecutables) { + LangDir lang; + fs::path second = lang.dir / "second"; + LocalizeSetDirectories({lang.dir, second}); + std::vector retail = File({ + {1, "one" }, + {2, "two" }, + {3, "three"} + }); + Write(lang.dir / "fr_fr.json", R"({"a.1": "exe", "a.2": "exe"})"); + Write(second / "fr_fr.json", R"({"a.2": "save"})"); + + Loaded loaded(retail); + int size = loaded.size; + ASSERT_TRUE(LocalizeFile("a_3.mes", loaded.bytes, 2048, &size)); + std::vector got(reinterpret_cast(loaded.bytes), reinterpret_cast(loaded.bytes) + size / 2); + EXPECT_EQ(Text(got, 1), "exe"); + EXPECT_EQ(Text(got, 2), "save"); + EXPECT_EQ(Text(got, 3), "three"); +} + +TEST(Localize, BadJsonFallsBackToRetail) { + LangDir lang; + std::vector retail = File({ + {1, "one"} + }); + Write(lang.dir / "fr_fr.json", "{ not json"); + + Loaded loaded(retail); + int size = loaded.size; + testing::internal::CaptureStderr(); + EXPECT_FALSE(LocalizeFile("a.mes", loaded.bytes, 2048, &size)); + EXPECT_FALSE(LocalizeFile("a.mes", loaded.bytes, 2048, &size)); + std::string log = testing::internal::GetCapturedStderr(); + EXPECT_NE(log.find("fr_fr.json"), std::string::npos); + // Told once, not on every load. + EXPECT_EQ(log.find("fr_fr.json", log.find("fr_fr.json") + 1), std::string::npos); + EXPECT_EQ(size, loaded.size); +} + +TEST(Localize, AFileThatWouldNotFitStaysRetail) { + LangDir lang; + std::vector retail = File({ + {1, "one"} + }); + Write(lang.dir / "fr_fr.json", R"({"a.1": "a text far longer than the file it replaces"})"); + + Loaded loaded(retail); + int size = loaded.size; + testing::internal::CaptureStderr(); + EXPECT_FALSE(LocalizeFile("a.mes", loaded.bytes, static_cast(size) + 4, &size)); + EXPECT_NE(testing::internal::GetCapturedStderr().find("retail text kept"), std::string::npos); + EXPECT_EQ(size, loaded.size); + EXPECT_TRUE(LocalizeFile("a.mes", loaded.bytes, 2048, &size)); + EXPECT_GT(size, loaded.size); +} + +TEST(Localize, PackEntryComesFromTheJson) { + LangDir lang; + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + Write(lang.dir / "fr_fr.json", R"({"commenu.emenu.pak.allmenu.1": "un"})"); + + alignas(64) static unsigned char pack[4096]; + std::memcpy(pack, retail.data(), retail.size() * 2); + int size = static_cast(retail.size() * 2); + const auto *data = reinterpret_cast(pack); + + // A pack nobody said where it came from is left alone. + EXPECT_EQ(LocalizePack(reinterpret_cast(pack), "allmenu.mes", data, &size), data); + + LocalizeLoaded("cdrom0:/COMMENU/A_FRE/EMENU.PAK;1", pack); + LocalizeLoaded("commenu/a_fre/emenu.pak", pack); + int replaced = size; + const u_int *got = LocalizePack(reinterpret_cast(pack), "allmenu.mes", data, &replaced); + ASSERT_NE(got, data); + std::vector words(reinterpret_cast(got), reinterpret_cast(got) + replaced / 2); + EXPECT_EQ(Text(words, 1), "un"); + EXPECT_EQ(Text(words, 2), "two"); + + // The same entry read again is the same text, at the same place. + int again = size; + EXPECT_EQ(LocalizePack(reinterpret_cast(pack), "allmenu.mes", data, &again), got); + EXPECT_EQ(again, replaced); + + // An entry that is not a message file, and a buffer another file has taken, are not touched. + int other = size; + EXPECT_EQ(LocalizePack(reinterpret_cast(pack), "option.img", data, &other), data); + LocalizeLoaded("commenu/a_fre/other.img", pack); + EXPECT_EQ(LocalizePack(reinterpret_cast(pack), "allmenu.mes", data, &other), data); +} + +// ---- Patches ---- + +namespace { + +std::vector Patched(LangDir &lang, const std::vector &retail, const std::string &patches, + const std::string &files = "") { + Write(lang.dir / "patches" / "a.json", patches); + if (!files.empty()) { + Write(lang.dir / "fr_fr.json", files); + } + Loaded loaded(retail); + int size = loaded.size; + if (!LocalizeFile("a.mes", loaded.bytes, 2048, &size)) { + return {}; + } + return std::vector(reinterpret_cast(loaded.bytes), reinterpret_cast(loaded.bytes) + size / 2); +} + +} // namespace + +TEST(Localize, ParsesPatches) { + LocalizeTable table; + std::string error; + ASSERT_TRUE(LocalizeParsePatches(R"({"patches": [ + {"file": "A.MES", "id": 3, "replace": [["teh", "the"], ["recieve", "receive"]]}, + {"file": "a.mes", "id": 4, "original": "0123456789abcdef", "text": "new"}]})", + table, error)) + << error; + ASSERT_EQ(table.patches["a"].size(), 2U); + EXPECT_EQ(table.patches["a"][0].replace.size(), 2U); + EXPECT_EQ(table.patches["a"][1].original, "0123456789abcdef"); + EXPECT_EQ(*table.patches["a"][1].text, "new"); + + for (const char *bad : {R"({"patches": 1})", R"({"patches": [{"id": 1, "text": "x"}]})", + R"({"patches": [{"file": "a", "id": 1}]})", + R"({"patches": [{"file": "a", "id": 1, "text": "x", "replace": [["a", "b"]]}]})", + R"({"patches": [{"file": "a", "id": 1, "replace": [["", "b"]]}]})", + R"({"patches": [{"file": "a", "id": 40000, "text": "x"}]})"}) { + EXPECT_FALSE(LocalizeParsePatches(bad, table, error)) << bad; + } +} + +TEST(Localize, TextHashIsStable) { + EXPECT_EQ(LocalizeTextHash(""), "cbf29ce484222325"); + EXPECT_EQ(LocalizeTextHash("a"), "af63dc4c8601ec8c"); + EXPECT_NE(LocalizeTextHash("one"), LocalizeTextHash("One")); +} + +TEST(Localize, ReplacePatchEditsTheDiscsText) { + LangDir lang; + std::vector retail = File({ + {1, "one" }, + {2, "teh cat and teh dog"}, + {3, "three" } + }); + std::vector got = Patched(lang, retail, R"({"patches": [{"file": "a.mes", "id": 2, "replace": [["teh", "the"]]}]})"); + ASSERT_FALSE(got.empty()); + EXPECT_EQ(Text(got, 1), "one"); + EXPECT_EQ(Text(got, 2), "the cat and the dog"); + EXPECT_EQ(Text(got, 3), "three"); +} + +TEST(Localize, APatchThatDoesNotMatchIsSkippedAndTold) { + LangDir lang; + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + testing::internal::CaptureStderr(); + std::vector got = Patched(lang, retail, R"({"patches": [ + {"file": "a.mes", "id": 2, "replace": [["zzz", "y"]]}, + {"file": "a.mes", "id": 9, "text": "nine"}, + {"file": "a.mes", "id": 1, "original": "0000000000000000", "text": "uno"}]})"); + std::string log = testing::internal::GetCapturedStderr(); + EXPECT_TRUE(got.empty()); + EXPECT_NE(log.find("\"zzz\" is not in the disc's text"), std::string::npos); + EXPECT_NE(log.find("message 9: not in the disc's file"), std::string::npos); + EXPECT_NE(log.find("message 1: the disc's text is not what the patch was written for"), std::string::npos); +} + +TEST(Localize, TextPatchWithTheRightHashApplies) { + LangDir lang; + std::vector retail = File({ + {1, "one"}, + {2, "two"} + }); + std::vector got = Patched(lang, retail, + R"({"patches": [{"file": "a.mes", "id": 1, "original": ")" + LocalizeTextHash("one") + + R"(", "text": "uno"}]})"); + ASSERT_FALSE(got.empty()); + EXPECT_EQ(Text(got, 1), "uno"); + EXPECT_EQ(Text(got, 2), "two"); +} + +TEST(Localize, PatchesChainAndAFilesTextWins) { + LangDir lang; + std::vector retail = File({ + {1, "aa"}, + {2, "bb"} + }); + std::vector got = Patched(lang, retail, + R"({"patches": [{"file": "a.mes", "id": 1, "replace": [["a", "b"]]}, + {"file": "a.mes", "id": 1, "replace": [["bb", "c"]]}, + {"file": "a.mes", "id": 2, "replace": [["b", "x"]]}]})", + R"({"a.2": "mine"})"); + ASSERT_FALSE(got.empty()); + EXPECT_EQ(Text(got, 1), "c"); + EXPECT_EQ(Text(got, 2), "mine"); +} + +// ---- game.language ---- + +#include "platform/config.hpp" + +TEST(LocalizeLanguage, ConfigReadsAndWritesTheLanguage) { + EXPECT_EQ(ConfigParse("").language, 0); + EXPECT_EQ(ConfigParse(R"({"game": {"language": "francais"}})").language, 3); + EXPECT_EQ(ConfigParse(R"({"game": {"language": "Espanol"}})").language, 6); + EXPECT_EQ(ConfigParse(R"({"game": {"language": "english"}})").language, 2); + EXPECT_EQ(ConfigParse(R"({"game": {"language": "ask"}})").language, 0); + + testing::internal::CaptureStderr(); + EXPECT_EQ(ConfigParse(R"({"game": {"language": "klingon"}})").language, 0); + EXPECT_EQ(ConfigParse(R"({"game": {"language": 3}})").language, 0); + testing::internal::GetCapturedStderr(); + + Config config; + config.language = 4; + EXPECT_EQ(ConfigParse(ConfigSerialize(config)).language, 4); + EXPECT_FALSE(ConfigAppliesOnRestart("game.language")); +} + +// ---- the language files and the export ---- + +#include "platform/paths.hpp" + +namespace { + +void WriteMes(const fs::path &path, const std::vector &file) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary).write(reinterpret_cast(file.data()), static_cast(file.size() * 2)); +} + +} // namespace + +TEST(LocalizeLanguages, ExportWritesOneFlatFilePerLanguageWithTheSameKeys) { + fs::path root = datafix::TempDir("langexport"); + WriteMes(root / "data/dun/script/d01/d01_2.mes", File({ + {1, "Hello"}, + {2, "Bye" } + })); + WriteMes(root / "data/dun/script/d01/d01_3.mes", File({ + {1, "Bonjour" }, + {2, "Au revoir \xC3\xA0 la Cr\xC3\xA8me"} + })); + WriteMes(root / "data/commenu/a_ger/dunmenu.pak.mes", File({ + {5, "Gegenstand"} + })); + WriteMes(root / "data/noda_w/dun00_eng.mes", File({ + {9, "Only one"} + })); + PathsSetDataRoot(root / "data"); + + ASSERT_GE(LocalizeExport(root / "out", true), 4); + auto load = [&](const char *name) { + std::ifstream stream(root / "out" / name); + return nlohmann::ordered_json::parse(stream); + }; + const auto en = load("en_gb.json"); + const auto fr = load("fr_fr.json"); + const auto de = load("de_de.json"); + // Flat ".": text, the same keys for the same message in every language. + EXPECT_EQ(en["dun.script.d01.d01.1"], "Hello"); + EXPECT_EQ(fr["dun.script.d01.d01.1"], "Bonjour"); + EXPECT_EQ(fr["dun.script.d01.d01.2"], "Au revoir \xC3\xA0 la Cr\xC3\xA8me"); + EXPECT_EQ(de["commenu.dunmenu.pak.5"], "Gegenstand"); + EXPECT_FALSE(en.contains("commenu.dunmenu.pak.5")); + EXPECT_TRUE(en.contains("dun.script.d01.d01.2")); + // A file that names no language is in every language's file. + for (const auto *language : {&en, &fr, &de}) { + EXPECT_EQ((*language)["noda_w.dun00_eng.9"], "Only one"); + } + EXPECT_TRUE(fs::exists(root / "out" / "hashes" / "fr_fr.json")); + EXPECT_EQ(load("hashes/fr_fr.json")["dun.script.d01.d01.1"], LocalizeTextHash("Bonjour")); + + // The file is UTF-8 as it stands, with no \u escapes standing in for letters. + std::ifstream raw(root / "out" / "fr_fr.json", std::ios::binary); + std::string bytes((std::istreambuf_iterator(raw)), std::istreambuf_iterator()); + EXPECT_NE(bytes.find("Cr\xC3\xA8me"), std::string::npos); + EXPECT_EQ(bytes.find("\\u00"), std::string::npos); + raw.close(); + std::error_code removed; + fs::remove_all(root, removed); +} + +TEST(LocalizeLanguages, ExportSaysWhenTwoFilesShareAKey) { + fs::path root = datafix::TempDir("langcollide"); + WriteMes(root / "data/x/m_3.mes", File({ + {1, "one"} + })); + WriteMes(root / "data/x/a_fre/m.mes", File({ + {1, "uno"} + })); + PathsSetDataRoot(root / "data"); + + testing::internal::CaptureStderr(); + LocalizeExport(root / "out", false); + EXPECT_NE(testing::internal::GetCapturedStderr().find("share a key"), std::string::npos); + std::error_code removed; + fs::remove_all(root, removed); +} + +TEST(LocalizeLanguages, ChangingTheLanguageSettingWaitsForTheGameToSwitch) { + LanguageCode = 2; + Config before; + Config after; + after.language = 5; + LocalizeConfigChanged(before, after); + EXPECT_TRUE(LocalizeLanguagePending()); + EXPECT_EQ(LanguageCode, 2) << "nothing changes under a loaded area"; + LocalizeApplyPendingLanguage(); + EXPECT_EQ(LanguageCode, 5); + EXPECT_FALSE(LocalizeLanguagePending()); + + // Changing back before the game switched cancels it; "ask", the current language and a setting + // that did not change leave nothing waiting. + before = after; + after.language = 3; + LocalizeConfigChanged(before, after); + EXPECT_TRUE(LocalizeLanguagePending()); + before = after; + after.language = 5; + LocalizeConfigChanged(before, after); + EXPECT_FALSE(LocalizeLanguagePending()); + before = after; + after.language = 0; + LocalizeConfigChanged(before, after); + EXPECT_FALSE(LocalizeLanguagePending()); + LocalizeApplyPendingLanguage(); + EXPECT_EQ(LanguageCode, 5); +} + +// ---- the Options screen's own text ---- + +#include "menu_option.hpp" + +// port/lang/ ships the Options screen's strings in each language: the same keys as the code has, nothing +// else, and the same placeholders and pad glyph codes as the English. +TEST(LocalizeOptions, ShippedTranslationsHaveExactlyTheCodesKeys) { + const fs::path lang = fs::path(__FILE__).parent_path().parent_path().parent_path() / "lang"; + ASSERT_TRUE(fs::is_directory(lang)) << lang; + + std::map english; + for (const auto &[key, text] : OptionStrings()) { + EXPECT_TRUE(english.emplace(key, text).second) << "two strings called " << key; + } + EXPECT_GT(english.size(), 100U); + + auto tokens = [](const std::string &text) { + std::string out; + for (size_t at = 0; at < text.size(); at++) { + if ((text[at] == '%' && at + 1 < text.size() && isdigit(static_cast(text[at + 1]))) || + (text[at] == '{' && at + 1 < text.size() && (text[at + 1] == '-' || isdigit(static_cast(text[at + 1]))))) { + out += text.substr(at, text.find_first_of("}", at) == std::string::npos ? 2 : text.find_first_of("} ", at) - at + 1); + } + } + return out; + }; + + int files = 0; + for (const char *name : {"fr_fr", "de_de", "it_it", "es_es"}) { + std::ifstream stream(lang / (std::string(name) + ".json")); + ASSERT_TRUE(stream) << name; + const auto shipped = nlohmann::ordered_json::parse(stream); + files++; + for (const auto &[key, text] : english) { + ASSERT_TRUE(shipped.contains(key)) << name << " lacks " << key; + const std::string translated = shipped[key]; + EXPECT_FALSE(translated.empty()) << name << " " << key; + EXPECT_EQ(tokens(translated), tokens(text)) << name << " " << key << ": placeholders and pad glyphs differ"; + } + for (const auto &item : shipped.items()) { + EXPECT_TRUE(english.count(item.key()) != 0) << name << " has " << item.key() << " that the code does not"; + } + } + EXPECT_EQ(files, 4); +} + +TEST(LocalizeOptions, TheExportListsTheOptionStringsWithTheShippedText) { + fs::path root = datafix::TempDir("langopt"); + WriteMes(root / "data/dun/script/d01/d01_3.mes", File({ + {1, "Bonjour"} + })); + PathsSetDataRoot(root / "data"); + const fs::path lang = fs::path(__FILE__).parent_path().parent_path().parent_path() / "lang"; + LocalizeReset(); + LocalizeSetDirectories({lang}); + + ASSERT_GE(LocalizeExport(root / "out", false), 1); + auto load = [&](const char *name) { + std::ifstream stream(root / "out" / name); + return nlohmann::ordered_json::parse(stream); + }; + const auto en = load("en_gb.json"); + const auto fr = load("fr_fr.json"); + EXPECT_EQ(en["options.game.map.label"], "Dungeon Map"); + EXPECT_EQ(fr["options.game.map.label"], "Carte du donjon"); + EXPECT_EQ(fr["dun.script.d01.d01.1"], "Bonjour"); + LocalizeSetDirectories({}); + LocalizeReset(); + std::error_code removed; + fs::remove_all(root, removed); +} diff --git a/port/src/tests/ttffont_test.cpp b/port/src/tests/ttffont_test.cpp new file mode 100644 index 000000000..243db74e6 --- /dev/null +++ b/port/src/tests/ttffont_test.cpp @@ -0,0 +1,98 @@ +#include + +#include +#include +#include +#include + +#include "platform/ttffont.hpp" + +// The tests that need a font read one from DC_TEST_FONT (a TrueType file); without it they skip, as no +// font is shipped with the port. DC_TEST_FONT_DUMP=1 also prints each glyph as text. + +namespace { + +struct Rendered { + std::vector alpha; + int w = 0; + int h = 0; + int x0 = 0; + int y0 = 0; +}; + +bool LoadTestFont() { + const char *path = std::getenv("DC_TEST_FONT"); + return path != nullptr && *path != '\0' && ttffont::Load(path); +} + +Rendered Render(char32_t ch, int em_px = 40) { + Rendered out; + EXPECT_TRUE(ttffont::RenderGlyph(ch, em_px, out.alpha, out.w, out.h, out.x0, out.y0)) << static_cast(ch); + if (std::getenv("DC_TEST_FONT_DUMP") != nullptr) { + std::printf("U+%04X %dx%d at (%d,%d)\n", static_cast(ch), out.w, out.h, out.x0, out.y0); + for (int row = 0; row < out.h; row++) { + for (int col = 0; col < out.w; col++) { + const unsigned char a = out.alpha[static_cast(row) * out.w + col]; + std::putchar(a > 160 ? '#' : a > 64 ? '+' + : ' '); + } + std::putchar('\n'); + } + } + return out; +} + +} // namespace + +// An accented letter is its base with a mark above it, in the same place across the letters. +TEST(TtfFont, AccentedLettersAreBuiltFromTheFontsOwnGlyphs) { + if (!LoadTestFont()) { + GTEST_SKIP() << "DC_TEST_FONT is not set"; + } + const Rendered e = Render(U'e'); + for (char32_t accented : {U'é', U'è', U'ê', U'ë'}) { + const Rendered r = Render(accented); + EXPECT_GT(r.w, 0); + EXPECT_LT(r.y0, e.y0) << "the mark stands above the letter"; + EXPECT_EQ(r.y0 + r.h, e.y0 + e.h) << "the letter keeps its bottom"; + EXPECT_NEAR(r.x0 + r.w / 2.0, e.x0 + e.w / 2.0, 2.0) << "the mark is centred on the letter"; + } + const Rendered c = Render(U'c'); + const Rendered cedilla = Render(U'ç'); + EXPECT_GT(cedilla.y0 + cedilla.h, c.y0 + c.h) << "the cedilla hangs below the baseline"; + EXPECT_EQ(cedilla.y0, c.y0); + + const Rendered upper = Render(U'É'); + const Rendered plain_upper = Render(U'E'); + EXPECT_LT(upper.y0, plain_upper.y0); +} + +TEST(TtfFont, InvertedMarksAndLigaturesAreBuilt) { + if (!LoadTestFont()) { + GTEST_SKIP() << "DC_TEST_FONT is not set"; + } + const Rendered bang = Render(U'!'); + const Rendered inverted = Render(U'¡'); + EXPECT_EQ(inverted.w, bang.w); + EXPECT_EQ(inverted.h, bang.h); + // Turned over: what was at the top is now at the bottom. + EXPECT_EQ(inverted.alpha.front(), bang.alpha[bang.alpha.size() - static_cast(bang.w)]); + + const Rendered oe = Render(U'œ'); + const Rendered o = Render(U'o'); + EXPECT_GT(oe.w, o.w); + EXPECT_LT(oe.w, 2 * o.w); + Render(U'Œ'); + Render(U'Ñ'); + Render(U'ñ'); + Render(U'ä'); +} + +TEST(TtfFont, WhatItCannotBuildIsLeftToTheBitmap) { + if (!LoadTestFont()) { + GTEST_SKIP() << "DC_TEST_FONT is not set"; + } + Rendered out; + EXPECT_FALSE(ttffont::RenderGlyph(U'ß', 40, out.alpha, out.w, out.h, out.x0, out.y0)); + EXPECT_FALSE(ttffont::RenderGlyph(U'~' + 0x1000, 40, out.alpha, out.w, out.h, out.x0, out.y0)); +} diff --git a/scripts/port/compare_text.py b/scripts/port/compare_text.py new file mode 100644 index 000000000..ff3cbbfb5 --- /dev/null +++ b/scripts/port/compare_text.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Compares the game's text across languages, message by message. + +Reads the language files that `darkcloud --export-text` writes (by default /lang-export, see +docs/LOCALIZATION.md): en_gb.json, fr_fr.json, de_de.json, it_it.json, es_es.json. Each is a flat +object of ".": text, with the same keys in every language, so the +languages are matched key to key. Writes one CSV per language into (default: compare/ beside the +export), with British English as the reference: + + key, english, text, flag + +Flags: + missing English has the message, this language's file does not + extra this language has a message English does not + same the text is the English text (often an untranslated line; skip it for names and numbers) + empty this language's text is empty where English's is not + long the text is more than twice, or less than half, the English length (a window may not fit it) + +The export is the disc's own text: keep what this writes out of git, as the export is. + + python scripts/port/compare_text.py [] +""" + +import csv +import json +import re +import sys +from pathlib import Path + +REFERENCE = "en_gb" +LANGUAGES = ["fr_fr", "de_de", "it_it", "es_es"] + + +def load(path): + with open(path, encoding="utf-8") as stream: + return json.load(stream) + + +def informative(text): + """False for a text that is the same in every language: only codes, digits and punctuation.""" + stripped = re.sub(r"\{-?\d+\}", "", text) + return re.search(r"[A-Za-z]{3,}", stripped) is not None + + +def sort_key(key): + """Keys by message file, then message id as a number.""" + file, _, id_ = key.rpartition(".") + return (file, int(id_) if id_.lstrip("-").isdigit() else 0) + + +def main(argv): + if len(argv) < 2: + print(__doc__) + return 2 + export = Path(argv[1]) + out = Path(argv[2]) if len(argv) > 2 else export.parent / "compare" + out.mkdir(parents=True, exist_ok=True) + english = load(export / (REFERENCE + ".json")) + lines = ["Messages against English (%s.json), by language" % REFERENCE, ""] + for name in LANGUAGES: + other = load(export / (name + ".json")) + counts = {"missing": 0, "extra": 0, "same": 0, "empty": 0, "long": 0} + with open(out / (name + ".csv"), "w", encoding="utf-8", newline="") as stream: + writer = csv.writer(stream) + writer.writerow(["key", "english", "text", "flag"]) + for key in sorted(set(english) | set(other), key=sort_key): + en = english.get(key) + text = other.get(key) + flag = "" + if en is not None and text is None: + flag = "missing" + elif en is None: + flag = "extra" + elif en and not text: + flag = "empty" + elif text == en and informative(en): + flag = "same" + elif len(en) > 20 and not (0.5 <= len(text) / len(en) <= 2.0): + flag = "long" + if flag: + counts[flag] += 1 + writer.writerow([key, en or "", text or "", flag]) + lines.append("%s: %d messages, %s" % (name, len(other), ", ".join("%d %s" % (n, k) for k, n in counts.items()))) + (out / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + print("\n".join(lines)) + print("written to", out) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))