Compare commits

...

4 Commits

Author SHA1 Message Date
Pipe530 4c6be6cd1d Localize trophy notifications with embedded JSON catalogs 2026-10-16 16:19:43 +02:00
Felipe 584c2e8ca6 Play snd0.at9 during the startup splash screen (#1545)
launcher: play snd0.at9 on title selection
2026-10-16 15:59:03 +02:00
nmzik 717b50155a launcher: rename executable to kyty_launcher 2026-10-16 12:57:28 +02:00
shadowbeat070 57b161019d shader: honor front-face input format 2026-10-16 12:57:27 +02:00
50 changed files with 732 additions and 35 deletions
+5 -5
View File
@@ -407,12 +407,12 @@ jobs:
- name: Verify artifacts
shell: bash
run: |
file _Build/linux/install/launcher
file _Build/linux/install/kyty_launcher
file _Build/linux/install/kyty_emulator
test -x _Build/linux/install/kyty_updater
file _Build/linux/install/kyty_emulator | grep -q "ELF 64-bit LSB .*x86-64"
ldd _Build/linux/install/kyty_emulator > /dev/null
readelf -d _Build/linux/install/launcher | grep -q 'RPATH.*\$ORIGIN/lib'
readelf -d _Build/linux/install/kyty_launcher | grep -q 'RPATH.*\$ORIGIN/lib'
while IFS= read -r -d '' binary; do
while read -r dependency; do
test -e "_Build/linux/install/lib/$dependency"
@@ -421,8 +421,8 @@ jobs:
sed -n 's/.*Shared library: \[\(libQt6[^]]*\|libicu[^]]*\)\].*/\1/p'
)
done < <(
find _Build/linux/install/launcher _Build/linux/install/plugins \
-type f \( -name launcher -o -name '*.so' \) -print0
find _Build/linux/install/kyty_launcher _Build/linux/install/plugins \
-type f \( -name kyty_launcher -o -name '*.so' \) -print0
)
_Build/linux/install/kyty_emulator --help > /dev/null
@@ -497,7 +497,7 @@ jobs:
linux_dir="artifacts/KytyPS5-Linux-x86_64"
test -d "$linux_dir"
chmod a+x \
"$linux_dir/launcher" \
"$linux_dir/kyty_launcher" \
"$linux_dir/kyty_emulator" \
"$linux_dir/kyty_updater"
tar -C "$linux_dir" -czf \
+2 -2
View File
@@ -5,7 +5,7 @@
"name": "Debug launcher UI",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}\\_Build\\vscode-clang\\launcher\\launcher.exe",
"program": "${workspaceFolder}\\_Build\\vscode-clang\\launcher\\kyty_launcher.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}\\_Build\\vscode-clang\\launcher",
@@ -13,7 +13,7 @@
"externalConsole": false,
"linux": {
"type": "cppdbg",
"program": "${workspaceFolder}/_Build/vscode-clang/launcher/launcher",
"program": "${workspaceFolder}/_Build/vscode-clang/launcher/kyty_launcher",
"cwd": "${workspaceFolder}/_Build/vscode-clang/launcher",
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb"
+5 -5
View File
@@ -275,11 +275,11 @@ From the repository root, follow the launcher configure, build and install steps
[Building on Linux](#building-on-linux), omitting `-DCMAKE_PREFIX_PATH="$Qt6_DIR"`; CMake finds
Fedora's packaged Qt 6 automatically.
Run `./_Build/linux/install/launcher` on regular Fedora or inside the container. To launch a
Run `./_Build/linux/install/kyty_launcher` on regular Fedora or inside the container. To launch a
container build from the host, leave the container with `exit`, then run from the repository root:
```bash
distrobox enter kyty-build -- ./_Build/linux/install/launcher
distrobox enter kyty-build -- ./_Build/linux/install/kyty_launcher
```
Qt libraries and plugins are bundled. You can also launch directly on a Fedora Atomic host
@@ -345,7 +345,7 @@ Use `_Build/linux` instead of `_Build/windows` for a Linux build.
A ready-made Visual Studio Code setup is included in [`.vscode`](.vscode). It configures CMake
Tools to build the project with Ninja and `clang-cl` and provides launch profiles for both
`launcher.exe` and `kyty_emulator.exe`. It is Windows-only: VS Code settings cannot select a
`kyty_launcher.exe` and `kyty_emulator.exe`. It is Windows-only: VS Code settings cannot select a
compiler per platform, so on Linux configure from the command line as shown above.
Before using it:
@@ -365,11 +365,11 @@ Update your graphics driver before reporting rendering problems.
To use the graphical launcher:
```powershell
.\_Build\windows\install\launcher.exe
.\_Build\windows\install\kyty_launcher.exe
```
```bash
./_Build/linux/install/launcher
./_Build/linux/install/kyty_launcher
```
```bash
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "تم كسب الكأس!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofej získána!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofæ optjent!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trophäe erhalten!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Κερδίσατε ένα τρόπαιο!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trophy earned!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trophy earned!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "¡Ganaste un trofeo!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "¡Ganaste un trofeo!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Pokaali ansaittu!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trophée obtenu !"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trophée obtenu !"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trófea megszerezve!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofi diperoleh!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofeo ottenuto!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "トロフィーを獲得しました!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "트로피를 획득했습니다!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofee behaald!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofé opptjent!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Zdobyto trofeum!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Troféu conquistado!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Troféu conquistado!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofeu câștigat!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Трофей получен!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Trofé intjänad!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "ได้รับถ้วยรางวัลแล้ว!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Kupa kazanıldı!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "Đã nhận cúp!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "已获得奖杯!"
}
+3
View File
@@ -0,0 +1,3 @@
{
"trophy.toast.earned": "已獲得獎盃!"
}
+20
View File
@@ -0,0 +1,20 @@
# Notification localization
Trophy notification subtitles use the configured console language. Trophy names still come
from the game's metadata. The launcher trophy dialogs are unchanged.
Edit `assets/localization/<locale>.json`, using flat UTF-8 string values:
```json
{
"trophy.toast.earned": "Trophy earned!"
}
```
CMake converts the catalogs into C++ byte arrays in the build directory. The emulator parses
the embedded catalogs once and falls back to `en-US.json` for missing translations. No language
files or translation service are needed at runtime. Rebuild after editing a catalog.
For local testing, rebuild `kyty_emulator`, choose a game's Console Language in the launcher,
and unlock a trophy with notifications enabled. Installed system fonts provide non-Latin
glyphs; the selected language needs an appropriate font on the host.
+14
View File
@@ -12,9 +12,23 @@ endif()
add_library(common STATIC ${common_src} ${common_headers})
set(localization_dir "${PROJECT_SOURCE_DIR}/assets/localization")
file(GLOB localization_catalogs CONFIGURE_DEPENDS "${localization_dir}/*.json")
set(localization_data "${CMAKE_CURRENT_BINARY_DIR}/localizationData.h")
add_custom_command(
OUTPUT "${localization_data}"
COMMAND ${CMAKE_COMMAND} "-DINPUTS=${localization_catalogs}" "-DOUTPUT=${localization_data}"
-P "${KYTY_SOURCE_DIR}/embed_localization.cmake"
DEPENDS ${localization_catalogs} "${KYTY_SOURCE_DIR}/embed_localization.cmake"
COMMENT "Embed notification localization catalogs"
VERBATIM
)
target_sources(common PRIVATE "${localization_data}")
target_include_directories(common
PUBLIC ${KYTY_SOURCE_DIR}
PRIVATE
${CMAKE_CURRENT_BINARY_DIR}
${KYTY_THIRD_PARTY_DIR}/cpuinfo/include
)
+53
View File
@@ -0,0 +1,53 @@
#include "common/localization.h"
#include <array>
#include <cstddef>
#include <nlohmann/json.hpp>
#include <string>
#include "localizationData.h"
namespace Common::Localization {
namespace {
constexpr std::array<std::string_view, 30> Locales = {
"ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT",
"ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO",
"pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ",
"hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"};
size_t LanguageIndex(int console_language) {
return console_language >= 0 && console_language < static_cast<int>(Locales.size())
? static_cast<size_t>(console_language)
: 1;
}
} // namespace
std::string_view LocaleName(int console_language) {
return Locales[LanguageIndex(console_language)];
}
std::string_view Get(int console_language, std::string_view key) {
static const auto catalogs = [] {
std::array<nlohmann::json, Locales.size()> result;
for (size_t i = 0; i < Locales.size(); ++i) {
const auto bytes = EmbeddedCatalog(Locales[i]);
result[i] = nlohmann::json::parse(bytes.begin(), bytes.end(), nullptr, false);
}
return result;
}();
for (const auto index: std::array {LanguageIndex(console_language), size_t {1}}) {
const auto& catalog = catalogs[index];
const auto found = catalog.find(key);
if (found != catalog.end() && found->is_string()) {
const auto& text = found->get_ref<const std::string&>();
if (!text.empty()) {
return text;
}
}
}
return key;
}
} // namespace Common::Localization
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <string_view>
namespace Common::Localization {
// Console language indices follow the system configuration; invalid indices use English.
[[nodiscard]] std::string_view LocaleName(int console_language);
// Translations have static lifetime. Missing translations use English, then the supplied key.
[[nodiscard]] std::string_view Get(int console_language, std::string_view key);
} // namespace Common::Localization
+2 -8
View File
@@ -1,9 +1,9 @@
#include "common/trophies.h"
#include "common/file.h"
#include "common/localization.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <fmt/format.h>
#include <nlohmann/json.hpp>
@@ -105,13 +105,7 @@ Package ParsePackage(std::span<const std::byte> data, int console_language) {
!conf["defaultLanguage"].is_string()) {
return {};
}
static constexpr std::array<std::string_view, 30> locales = {
"ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT",
"ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO",
"pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ",
"hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"};
const auto locale =
locales[console_language >= 0 && console_language < locales.size() ? console_language : 1];
const auto locale = Localization::LocaleName(console_language);
auto meta = ReadJson(files, fmt::format("tropmeta_{}.json", locale));
if (meta.is_null()) {
meta = ReadJson(files, fmt::format("tropmeta_{}.json",
+62
View File
@@ -0,0 +1,62 @@
if(NOT DEFINED INPUTS OR NOT DEFINED OUTPUT)
message(FATAL_ERROR "embed_localization.cmake requires INPUTS and OUTPUT")
endif()
set(contents "// Generated from assets/localization; do not edit.\n#pragma once\n#include <span>\n#include <string_view>\n\nnamespace {\n")
set(lookup "std::span<const unsigned char> EmbeddedCatalog(std::string_view locale) {\n")
set(continuation "[89ab][0-9a-f]")
string(CONCAT utf8_character
"[0-7][0-9a-f]|(c[2-9a-f]|d[0-9a-f])${continuation}"
"|e0[ab][0-9a-f]${continuation}|e[1-9abcef]${continuation}${continuation}"
"|ed[89][0-9a-f]${continuation}|f0[9ab][0-9a-f]${continuation}${continuation}"
"|f[123]${continuation}${continuation}${continuation}|f48[0-9a-f]${continuation}${continuation}")
foreach(catalog IN LISTS INPUTS)
file(READ "${catalog}" bytes HEX)
# Validate UTF-8, excluding overlong sequences, surrogates, and values above U+10FFFF.
if(NOT bytes MATCHES "^(${utf8_character})*$")
message(FATAL_ERROR "Localization must be UTF-8: ${catalog}")
endif()
file(READ "${catalog}" json)
string(JSON type ERROR_VARIABLE error TYPE "${json}")
if(error OR NOT type STREQUAL "OBJECT")
message(FATAL_ERROR "Invalid localization object in ${catalog}: ${error}")
endif()
string(JSON count LENGTH "${json}")
if(count GREATER 0)
math(EXPR last "${count} - 1")
foreach(index RANGE ${last})
string(JSON key MEMBER "${json}" ${index})
string(JSON type TYPE "${json}" "${key}")
if(NOT type STREQUAL "STRING")
message(FATAL_ERROR "Localization value '${key}' must be a string: ${catalog}")
endif()
string(JSON text GET "${json}" "${key}")
if(key STREQUAL "" OR text STREQUAL "")
message(FATAL_ERROR "Localization keys and values must not be empty: ${catalog}")
endif()
endforeach()
endif()
get_filename_component(locale "${catalog}" NAME_WE)
if(locale STREQUAL "en-US")
string(JSON earned ERROR_VARIABLE error GET "${json}" "trophy.toast.earned")
if(error OR earned STREQUAL "")
message(FATAL_ERROR "English localization requires trophy.toast.earned: ${catalog}")
endif()
set(english_found TRUE)
endif()
string(MAKE_C_IDENTIFIER "${locale}" symbol)
string(REGEX REPLACE "(..)" "0x\\1," bytes "${bytes}")
string(APPEND contents "constexpr unsigned char catalog_${symbol}[] = {${bytes}};\n")
string(APPEND lookup "\tif (locale == \"${locale}\") return catalog_${symbol};\n")
endforeach()
if(NOT english_found)
message(FATAL_ERROR "Localization requires an en-US.json fallback")
endif()
string(APPEND contents "\n${lookup}\treturn {};\n}\n} // namespace\n")
get_filename_component(output_dir "${OUTPUT}" DIRECTORY)
file(MAKE_DIRECTORY "${output_dir}")
file(WRITE "${OUTPUT}.tmp" "${contents}")
execute_process(COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${OUTPUT}.tmp" "${OUTPUT}"
COMMAND_ERROR_IS_FATAL ANY)
file(REMOVE "${OUTPUT}.tmp")
+130 -4
View File
@@ -5,6 +5,7 @@
#include "common/assert.h"
#include "common/emulatorConfig.h"
#include "common/file.h"
#include "common/localization.h"
#include "common/logging/log.h"
#include "common/stringUtils.h"
#include "gpu_blit_shaders/gpu_imgui_hdr_spv.h"
@@ -30,6 +31,7 @@
#include <cstring>
#include <deque>
#include <fmt/format.h>
#include <initializer_list>
#include <limits>
#include <memory>
#include <mutex>
@@ -280,6 +282,61 @@ std::vector<std::string_view> WrapTrophyTitle(std::string_view text, float font_
}
}
// ImGui's built-in font covers Latin only. Merge installed fonts for the selected console language.
void AddSystemFontFallbacks(ImGuiIO& io) {
io.Fonts->AddFontDefault();
const auto add = [&](std::initializer_list<const char*> paths) {
for (const char* path: paths) {
std::error_code error;
if (std::filesystem::is_regular_file(path, error)) {
ImFontConfig config;
config.MergeMode = true;
config.Flags = ImFontFlags_NoLoadError;
if (io.Fonts->AddFontFromFileTTF(path, 0.0f, &config) != nullptr) {
return true;
}
}
}
return false;
};
bool found =
add({"C:/Windows/Fonts/segoeui.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/Library/Fonts/Arial Unicode.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf"});
const auto locale = Common::Localization::LocaleName(Config::GetConsoleLanguage());
const char* noto_cjk[] = {"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc"};
if (locale == "ja-JP") {
found = add({"C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/YuGothM.ttc",
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", noto_cjk[0], noto_cjk[1],
noto_cjk[2]});
} else if (locale == "ko-KR") {
found = add({"C:/Windows/Fonts/malgun.ttf", "/System/Library/Fonts/AppleSDGothicNeo.ttc",
noto_cjk[0], noto_cjk[1], noto_cjk[2]});
} else if (locale == "zh-Hans" || locale == "zh-Hant") {
found =
add({locale == "zh-Hans" ? "C:/Windows/Fonts/msyh.ttc" : "C:/Windows/Fonts/msjh.ttc",
"/System/Library/Fonts/PingFang.ttc", noto_cjk[0], noto_cjk[1], noto_cjk[2]});
} else if (locale == "th-TH") {
found = add({"C:/Windows/Fonts/leelawui.ttf", "C:/Windows/Fonts/tahoma.ttf",
"/System/Library/Fonts/Supplemental/Ayuthaya.ttf",
"/usr/share/fonts/truetype/tlwg/Garuda.ttf",
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/noto/NotoSansThai-Regular.ttf"});
} else if (locale == "ar-AE") {
found = add({"C:/Windows/Fonts/tahoma.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/noto/NotoSansArabic-Regular.ttf"});
}
if (!found) {
LOGF("[Trophy] no installed fallback font for %.*s\n", static_cast<int>(locale.size()),
locale.data());
}
}
void ClearInputEvents() {
std::scoped_lock lock(g_input_mutex);
g_input_events.clear();
@@ -441,6 +498,63 @@ std::u16string Utf8ToUtf16(std::string_view text) {
return result;
}
// The Arabic notification is a single RTL run. Use presentation forms because ImGui does not
// shape Arabic. This intentionally applies only to the catalog subtitle, never game-provided names.
std::string PrepareTrophySubtitle(int language) {
const auto text = Common::Localization::Get(language, "trophy.toast.earned");
if (Common::Localization::LocaleName(language) != "ar-AE") {
return std::string(text);
}
struct ArabicForm {
char16_t letter;
char16_t isolated;
bool joins_next;
};
static constexpr ArabicForm FORMS[] = {
{0x0621, 0xfe80, false}, {0x0622, 0xfe81, false}, {0x0623, 0xfe83, false},
{0x0624, 0xfe85, false}, {0x0625, 0xfe87, false}, {0x0626, 0xfe89, true},
{0x0627, 0xfe8d, false}, {0x0628, 0xfe8f, true}, {0x0629, 0xfe93, false},
{0x062a, 0xfe95, true}, {0x062b, 0xfe99, true}, {0x062c, 0xfe9d, true},
{0x062d, 0xfea1, true}, {0x062e, 0xfea5, true}, {0x062f, 0xfea9, false},
{0x0630, 0xfeab, false}, {0x0631, 0xfead, false}, {0x0632, 0xfeaf, false},
{0x0633, 0xfeb1, true}, {0x0634, 0xfeb5, true}, {0x0635, 0xfeb9, true},
{0x0636, 0xfebd, true}, {0x0637, 0xfec1, true}, {0x0638, 0xfec5, true},
{0x0639, 0xfec9, true}, {0x063a, 0xfecd, true}, {0x0641, 0xfed1, true},
{0x0642, 0xfed5, true}, {0x0643, 0xfed9, true}, {0x0644, 0xfedd, true},
{0x0645, 0xfee1, true}, {0x0646, 0xfee5, true}, {0x0647, 0xfee9, true},
{0x0648, 0xfeed, false}, {0x0649, 0xfeef, false}, {0x064a, 0xfef1, true},
};
const auto form = [](char16_t c) -> const ArabicForm* {
for (const auto& value: FORMS) {
if (value.letter == c) {
return &value;
}
}
return nullptr;
};
const auto logical = Utf8ToUtf16(text);
if (std::none_of(logical.begin(), logical.end(),
[](char16_t c) { return c >= 0x0600 && c <= 0x06ff; })) {
return std::string(text);
}
auto visual = logical;
for (size_t i = 0; i < logical.size(); ++i) {
const auto* current = form(logical[i]);
if (current == nullptr) {
continue;
}
const auto* previous = i > 0 ? form(logical[i - 1]) : nullptr;
const auto* next = i + 1 < logical.size() ? form(logical[i + 1]) : nullptr;
const bool joins_previous =
previous != nullptr && previous->joins_next && current->letter != 0x0621;
const bool joins_next = current->joins_next && next != nullptr && next->letter != 0x0621;
visual[i] = static_cast<char16_t>(current->isolated + (joins_next ? 2 : 0) +
(joins_previous ? 1 : 0));
}
std::reverse(visual.begin(), visual.end());
return Common::Utf16ToUtf8(visual);
}
std::string VisibleText(const Ime::HostSnapshot& snapshot, size_t max_units) {
const size_t cursor = std::min<size_t>(snapshot.cursor, snapshot.text.size());
const size_t payload_units = max_units > 4 ? max_units - 4 : 1;
@@ -794,6 +908,7 @@ struct SystemOverlay::Impl {
io.ConfigNavCursorVisibleAlways = true;
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
io.BackendPlatformName = "Kyty system overlay input";
AddSystemFontFallbacks(io);
ImGui::StyleColorsDark();
auto& style = ImGui::GetStyle();
style.WindowRounding = 10.0f;
@@ -1578,8 +1693,9 @@ struct SystemOverlay::Impl {
trophy_notification = std::move(g_trophy_notifications.front());
g_trophy_notifications.pop_front();
}
trophy_notification_born = now;
trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png);
trophy_notification_subtitle = PrepareTrophySubtitle(Config::GetConsoleLanguage());
trophy_notification_born = now;
trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png);
PlayTrophySound();
}
const float age = std::chrono::duration<float>(now - trophy_notification_born).count();
@@ -1663,8 +1779,17 @@ struct SystemOverlay::Impl {
line.data(), line.data() + line.size());
line_y += line_height;
}
draw->AddText(nullptr, 20.0f * screen_scale, {text_left, line_y + 8.0f * screen_scale},
fade(176, 180, 192, 255), "Trophy earned!");
const float subtitle_size = 20.0f * screen_scale;
const float subtitle_width =
ImGui::GetFont()
->CalcTextSizeA(subtitle_size, std::numeric_limits<float>::max(), 0.0f,
trophy_notification_subtitle.c_str())
.x;
const float available_width = std::max(1.0f, width - text_offset - text_padding);
const float fitted_size = std::max(
1.0f, subtitle_size * std::min(1.0f, available_width / std::max(1.0f, subtitle_width)));
draw->AddText(nullptr, fitted_size, {text_left, line_y + 8.0f * screen_scale},
fade(176, 180, 192, 255), trophy_notification_subtitle.c_str());
draw->PopClipRect();
}
@@ -1782,6 +1907,7 @@ struct SystemOverlay::Impl {
static constexpr std::size_t MAX_TOUCH_PARTICLES = 64;
static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128;
std::optional<TrophyNotification> trophy_notification;
std::string trophy_notification_subtitle;
Clock::time_point trophy_notification_born;
std::vector<std::unique_ptr<ImTextureData>> retired_trophy_images;
std::array<std::unique_ptr<DdsImage>, 2> splash_dds;
@@ -52,9 +52,12 @@ uint32_t EmitBuiltinU32(EmitterState& state, IR::StageInputKind kind, uint32_t c
const auto value = state.builder.AllocateId();
const auto bits = state.builder.AllocateId();
state.builder.AddFunction(spv::OpLoad, TypeBool(state), value, variable);
// PS5 initializes v_front_face with float +1.0/-1.0 bits.
state.builder.AddFunction(spv::OpSelect, TypeU32(state), bits, value,
ConstantU32(state, 0x3f800000u), ConstantU32(state, 0xbf800000u));
// SPI_BARYC_CNTL.FRONT_FACE_ALL_BITS selects uint masks or float +/-1.
const bool all_bits =
state.input_info.pixel != nullptr && state.input_info.pixel->ps_front_face_all_bits;
const auto front = ConstantU32(state, all_bits ? 0xffffffffu : 0x3f800000u);
const auto back = ConstantU32(state, all_bits ? 0u : 0xbf800000u);
state.builder.AddFunction(spv::OpSelect, TypeU32(state), bits, value, front, back);
return bits;
}
if (kind == IR::StageInputKind::VertexIndex || kind == IR::StageInputKind::InstanceIndex ||
+6 -3
View File
@@ -344,6 +344,8 @@ static void ShaderGetStaticInputInfoPS(
ps_info.ps_pos_z = (active_inputs & 0x00000400u) != 0;
ps_info.ps_pos_w = (active_inputs & 0x00000800u) != 0;
ps_info.ps_front_face = (active_inputs & 0x00001000u) != 0;
// SPI_BARYC_CNTL.FRONT_FACE_ALL_BITS selects integer masks instead of float +/-1.
ps_info.ps_front_face_all_bits = (sh.baryc_cntl & 0x01000000u) != 0;
ps_info.ps_ancillary = (active_inputs & 0x00002000u) != 0;
ps_info.ps_sample_shading = (active_inputs & 0x00000011u) != 0;
ps_info.ps_no_perspective = (sh.ps_input_ena & sh.ps_input_addr & 0x00000020u) != 0;
@@ -464,6 +466,7 @@ void BuildStageStaticKey(const ShaderPixelInputInfo& info, std::vector<uint32_t>
key.push_back(static_cast<uint32_t>(info.ps_pos_z));
key.push_back(static_cast<uint32_t>(info.ps_pos_w));
key.push_back(static_cast<uint32_t>(info.ps_front_face));
key.push_back(static_cast<uint32_t>(info.ps_front_face_all_bits));
key.push_back(static_cast<uint32_t>(info.ps_ancillary));
key.push_back(static_cast<uint32_t>(info.ps_no_perspective));
key.push_back(static_cast<uint32_t>(info.parameter_mode));
@@ -737,6 +740,7 @@ void ShaderDbgDumpInputInfo(const ShaderPixelInputInfo& info) {
"\t ps_pos_z = %s\n"
"\t ps_pos_w = %s\n"
"\t ps_front_face = %s\n"
"\t ps_front_face_all_bits = %s\n"
"\t ps_ancillary = %s\n"
"\t ps_sample_shading = %s\n"
"\t ps_no_perspective = %s\n"
@@ -745,11 +749,10 @@ void ShaderDbgDumpInputInfo(const ShaderPixelInputInfo& info) {
"\t ps_execute_on_noop = %s\n",
info.input_num, info.ps_system_input_base, info.custom_interpolation_mask,
info.ps_perspective_center_vgpr, info.ps_perspective_sample_vgpr,
info.ps_perspective_centroid_vgpr,
info.ps_pos_x ? "true" : "false",
info.ps_perspective_centroid_vgpr, info.ps_pos_x ? "true" : "false",
info.ps_pos_y ? "true" : "false", info.ps_pos_z ? "true" : "false",
info.ps_pos_w ? "true" : "false", info.ps_front_face ? "true" : "false",
info.ps_ancillary ? "true" : "false",
info.ps_front_face_all_bits ? "true" : "false", info.ps_ancillary ? "true" : "false",
info.ps_sample_shading ? "true" : "false", info.ps_no_perspective ? "true" : "false",
info.ps_pixel_kill_enable ? "true" : "false", info.ps_early_z ? "true" : "false",
info.ps_execute_on_noop ? "true" : "false");
+1
View File
@@ -196,6 +196,7 @@ struct ShaderPixelInputInfo {
bool ps_pos_z = false;
bool ps_pos_w = false;
bool ps_front_face = false;
bool ps_front_face_all_bits = false;
bool ps_ancillary = false;
bool ps_no_perspective = false;
ShaderPixelParameterMode parameter_mode = ShaderPixelParameterMode::FirstVertex;
+12 -2
View File
@@ -21,7 +21,8 @@ add_executable(launcher WIN32
)
target_link_libraries(launcher Qt6::Concurrent Qt6::Network Qt6::Widgets Vulkan::Headers SDL3::SDL3-static kyty_git_version common)
target_link_libraries(launcher Qt6::Concurrent Qt6::Network Qt6::Widgets Vulkan::Headers SDL3::SDL3-static
LibAtrac9 kyty_git_version common)
if(LINUX)
find_package(Qt6 COMPONENTS DBus REQUIRED)
target_link_libraries(launcher Qt6::DBus)
@@ -76,7 +77,7 @@ if(WIN32)
)
endif()
set(launcher_name "launcher")
set(launcher_name "kyty_launcher")
if(APPLE)
set(launcher_name "KytyPS5")
endif()
@@ -101,6 +102,15 @@ install(TARGETS launcher
BUNDLE DESTINATION .
RUNTIME DESTINATION .)
if(NOT APPLE)
# Older releases require the previous executable name when validating updates.
install(CODE "
file(INSTALL DESTINATION \"\${CMAKE_INSTALL_PREFIX}\"
TYPE PROGRAM RENAME \"launcher${CMAKE_EXECUTABLE_SUFFIX}\"
FILES \"\${CMAKE_INSTALL_PREFIX}/${launcher_name}${CMAKE_EXECUTABLE_SUFFIX}\")
")
endif()
if(APPLE)
install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS")
install(TARGETS kyty_updater DESTINATION "KytyPS5.app/Contents/MacOS")
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <QByteArray>
#include <QFutureWatcher>
#include <QObject>
#include <QPromise>
#include <QString>
#include <QThreadPool>
#include <vector>
struct SDL_AudioStream;
class TitleSound final: public QObject {
public:
explicit TitleSound(QObject* parent = nullptr);
~TitleSound() override;
void SetTitle(const QString& base);
private:
struct Sound {
std::vector<float> pcm;
int channels = 0;
int rate = 0;
};
static Sound Decode(const QByteArray& data, const QPromise<Sound>& promise);
void Stop();
QThreadPool m_decode_pool;
QFutureWatcher<Sound> m_decode;
QString m_base;
SDL_AudioStream* m_stream = nullptr;
bool m_audio_initialized = false;
};
+10 -1
View File
@@ -6,6 +6,7 @@
#include "configurationListWidget.h"
#include "controllerPreview.h"
#include "gameContent.h"
#include "titleSound.h"
#include "updateChecker.h"
#include <QApplication>
@@ -90,6 +91,7 @@ private:
MainDialog* m_main_dialog = nullptr;
UpdateChecker* m_update_checker = nullptr;
ControllerPreview m_controller_preview;
TitleSound m_title_sound;
QString m_interpreter;
QProcess m_process;
@@ -124,7 +126,13 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
connect(main_dialog, &MainDialog::Start, this, &MainDialogPrivate::FindInterpreter,
Qt::QueuedConnection);
connect(m_ui->widget, &ConfigurationListWidget::Select, this, &MainDialogPrivate::Update);
connect(m_ui->widget, &ConfigurationListWidget::Select, this, [this]() {
Update();
const auto* item = m_ui->widget->GetSelectedItem();
m_title_sound.SetTitle(m_process.state() == QProcess::NotRunning && item != nullptr
? item->GetInfo().basedir
: QString());
});
connect(m_ui->widget, &ConfigurationListWidget::PreviewControllerColor, this,
[this](const QString& color) {
if (m_process.state() == QProcess::NotRunning) {
@@ -530,6 +538,7 @@ void MainDialogPrivate::Run() {
m_running_item->SetRunning(true);
m_controller_preview.SetSuspended(true);
m_title_sound.SetTitle({});
auto info = m_ui->widget->CreateConfiguration(*m_running_item);
m_main_dialog->RunInterpreter(&m_process, *info);
+106
View File
@@ -0,0 +1,106 @@
#include "titleSound.h"
#include "gameContent.h"
#include "libs/ajm/atrac9_decoder.h"
#include <QDebug>
#include <QtConcurrentRun>
#include <SDL3/SDL.h>
#include <array>
TitleSound::Sound TitleSound::Decode(const QByteArray& data, const QPromise<Sound>& promise) {
using namespace Libs::Audio::Ajm;
AjmAt9Decoder decoder(0, 0, AjmSampleEncoding::Float,
AJM_INSTANCE_FLAG_DEC_AT9_PARSE_RIFF_HEADER);
AjmGaplessState gapless;
std::array<float, 16 * 1024> batch;
Sound sound;
size_t offset = 0;
while (offset < static_cast<size_t>(data.size()) && !gapless.IsEnd()) {
if (promise.isCanceled()) {
return {};
}
const auto result = decoder.Decode(data.constData() + offset, data.size() - offset,
batch.data(), sizeof(batch), true, &gapless);
if ((result.result != 0 && result.result != AJM_RESULT_NOT_ENOUGH_ROOM) ||
result.input_consumed == 0) {
return {};
}
const auto samples = result.output_written / sizeof(float);
constexpr size_t MaxSamples = (128u << 20u) / sizeof(float);
if (samples > MaxSamples - sound.pcm.size()) {
return {};
}
sound.pcm.insert(sound.pcm.end(), batch.begin(), batch.begin() + samples);
offset += result.input_consumed;
}
if (promise.isCanceled()) {
return {};
}
const auto format = decoder.GetFormat();
sound.channels = static_cast<int>(format.channel_num);
sound.rate = static_cast<int>(format.sampling_frequency);
return sound;
}
TitleSound::TitleSound(QObject* parent): QObject(parent) {
m_decode_pool.setMaxThreadCount(1);
connect(&m_decode, &QFutureWatcher<Sound>::finished, this, [this] {
if (m_decode.isCanceled() || m_decode.future().resultCount() == 0) {
return;
}
auto sound = m_decode.future().takeResult();
if (sound.pcm.empty()) {
qWarning() << "Cannot decode title sound:" << m_base;
return;
}
m_audio_initialized = SDL_InitSubSystem(SDL_INIT_AUDIO);
if (m_audio_initialized) {
const SDL_AudioSpec spec {SDL_AUDIO_F32, sound.channels, sound.rate};
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, nullptr,
nullptr);
}
if (m_stream == nullptr ||
!SDL_PutAudioStreamData(m_stream, sound.pcm.data(),
static_cast<int>(sound.pcm.size() * sizeof(float))) ||
!SDL_FlushAudioStream(m_stream) || !SDL_ResumeAudioStreamDevice(m_stream)) {
qWarning() << "Cannot play title sound:" << SDL_GetError();
Stop();
}
});
}
TitleSound::~TitleSound() {
m_decode.cancel();
Stop();
}
void TitleSound::SetTitle(const QString& base) {
if (base == m_base) {
return;
}
m_decode.cancel();
Stop();
m_base = base;
if (!base.isEmpty()) {
m_decode.setFuture(QtConcurrent::run(&m_decode_pool, [base](QPromise<Sound>& promise) {
const auto data =
GameContent::ReadFile(base, QStringLiteral("sce_sys/snd0.at9"), 32u << 20u);
if (!data.isEmpty() && !promise.isCanceled()) {
promise.addResult(Decode(data, promise));
}
}));
}
}
void TitleSound::Stop() {
if (m_stream != nullptr) {
SDL_DestroyAudioStream(m_stream);
m_stream = nullptr;
}
if (m_audio_initialized) {
SDL_QuitSubSystem(SDL_INIT_AUDIO);
m_audio_initialized = false;
}
}
+2 -2
View File
@@ -21,11 +21,11 @@ constexpr qint64 MAX_METADATA_SIZE = 2 * 1024 * 1024;
QString LauncherRelativePath() {
#if defined(_WIN32)
return QStringLiteral("launcher.exe");
return QStringLiteral("kyty_launcher.exe");
#elif defined(__APPLE__)
return QStringLiteral("KytyPS5.app/Contents/MacOS/KytyPS5");
#else
return QStringLiteral("launcher");
return QStringLiteral("kyty_launcher");
#endif
}
+95
View File
@@ -1383,6 +1383,7 @@ struct GraphicsCase {
std::vector<u32> vertices;
bool pixel_ancillary = false;
bool pixel_front_face = false;
bool pixel_front_face_all_bits = false;
u32 layers = 1;
vk::SampleCountFlagBits samples = vk::SampleCountFlagBits::e1;
bool pixel_position_w = false;
@@ -1834,6 +1835,7 @@ CompiledShader CompileFragmentCase(const GraphicsCase &test) {
: ShaderPixelParameterMode::FirstVertex;
pixel_info.ps_ancillary = test.pixel_ancillary;
pixel_info.ps_front_face = test.pixel_front_face;
pixel_info.ps_front_face_all_bits = test.pixel_front_face_all_bits;
pixel_info.ps_pos_w = test.pixel_position_w;
pixel_info.ps_depth_export_enable = test.pixel_depth_export;
pixel_info.ps_system_input_base = 2;
@@ -41299,6 +41301,83 @@ GraphicsCase GraphicsAncillarySampleId() {
return test;
}
// SPI_BARYC_CNTL[24] selects all-bits mode for uint/bool S_FRONT_FACE;
// float S_FRONT_FACE and __getSpecialVgprFloat(__v_front_face) leave it clear.
GraphicsCase GraphicsFrontFaceBits(bool reversed, bool all_bits) {
GraphicsCase test;
test.name = all_bits ? (reversed ? "GraphicsFrontFaceUintFront" : "GraphicsFrontFaceUintBack")
: (reversed ? "GraphicsFrontFaceFloatFront" : "GraphicsFrontFaceFloatBack");
test.pixel_front_face = true;
test.pixel_front_face_all_bits = all_bits;
// Preserve every raw bit as two finite, exactly representable integer-to-float
// results. Directly exporting the integer front value would export a NaN.
AppendVop3(&test.fragment_code, 0x148, 4, Vgpr(2), InlineU32(0), InlineU32(16));
AppendVop3(&test.fragment_code, 0x148, 5, Vgpr(2), InlineU32(16), InlineU32(16));
test.fragment_code.push_back(EncodeVop1(0x06, 0, Vgpr(4)));
test.fragment_code.push_back(EncodeVop1(0x06, 1, Vgpr(5)));
// Integer inputs test raw zero; float inputs test the sign.
test.fragment_code.push_back(EncodeVopc(all_bits ? 0xc2 : 0x04, InlineU32(0), 2));
AppendVMovLiteral(&test.fragment_code, 3, 0x3f800000u);
test.fragment_code.push_back(EncodeVop2(0x01, 2, InlineU32(0), 3));
test.fragment_code.push_back(EncodeExp0(0x00, 0xf));
test.fragment_code.push_back(EncodeExp1(0, 1, 2, 3));
AppendEnd(&test.fragment_code);
test.opcodes = {ShaderOpcode::V_BFE_U32, ShaderOpcode::V_CVT_F32_U32,
all_bits ? ShaderOpcode::V_CMP_EQ_U32 : ShaderOpcode::V_CMP_GT_F32,
ShaderOpcode::V_CNDMASK_B32, ShaderOpcode::V_MOV_B32,
ShaderOpcode::EXP, ShaderOpcode::S_ENDPGM};
test.vertices = {
0xbf800000u, 0xbf800000u, 0x3e800000u, 0x3f000000u, 0x3f400000u, 0x3f800000u,
0x40400000u, 0xbf800000u, 0x3e800000u, 0x3f000000u, 0x3f400000u, 0x3f800000u,
0xbf800000u, 0x40400000u, 0x3e800000u, 0x3f000000u, 0x3f400000u, 0x3f800000u};
if (reversed) {
std::swap_ranges(test.vertices.begin() + 6, test.vertices.begin() + 12,
test.vertices.begin() + 12);
}
// With the harness's positive-height viewport, the default triangle faces back.
const u32 bits = all_bits ? (reversed ? UINT32_MAX : 0u)
: (reversed ? 0x3f800000u : 0xbf800000u);
test.expected_pixel = {
std::bit_cast<u32>(static_cast<float>(bits & 0xffffu)),
std::bit_cast<u32>(static_cast<float>(bits >> 16u)),
reversed ? 0u : 0x3f800000u, 0x3f800000u};
return test;
}
void CheckPixelFrontFaceModes() {
constexpr const char *name = "PixelFrontFaceModes";
static const std::array<u32, 1> code = {EncodeSopp(0x01)};
HW::PixelShaderInfo regs{};
regs.ps_regs.data_addr = reinterpret_cast<uint64_t>(code.data());
ShaderMapUserData(regs.ps_regs.data_addr,
{.type = Prospero::ShaderBinaryType::kPs,
.code_size_bytes = static_cast<u32>(sizeof(code))});
HW::ShaderRegisters sh{};
sh.ps_input_ena = sh.ps_input_addr = 0x1002u;
const std::array<Prospero::ColorComponentMapping, 8> export_mapping{};
std::vector<u32> float_key;
for (bool all_bits : {false, true}) {
for (u32 other_bits : {0u, 0x120u}) {
sh.baryc_cntl = (all_bits ? 0x01000000u : 0u) | other_bits;
ShaderPixelInputInfo pixel{};
(void)PrepareProgram(regs, sh, export_mapping, pixel);
Require(name, "register format decoding",
pixel.ps_front_face && pixel.ps_front_face_all_bits == all_bits &&
pixel.ps_system_input_base == 2u,
"SPI_BARYC_CNTL[24] did not select the front-face format");
std::vector<u32> key;
BuildStageStaticKey(pixel, key);
if (!all_bits && other_bits == 0u) {
float_key = key;
}
Require(name, "format specialization",
(key != float_key) == all_bits,
"front-face formats must have distinct shader keys and ignore unrelated bits");
}
}
std::printf("[host] %-32s ok\n", name);
}
GraphicsCase GraphicsFlatInterpolatorExport() {
using O = ShaderOpcode;
@@ -42179,6 +42258,10 @@ std::vector<GraphicsCase> MakeGraphicsCases() {
GraphicsAncillaryLayer(false),
GraphicsAncillaryLayer(true),
GraphicsAncillarySampleId(),
GraphicsFrontFaceBits(false, false),
GraphicsFrontFaceBits(true, false),
GraphicsFrontFaceBits(false, true),
GraphicsFrontFaceBits(true, true),
GraphicsFlatInterpolatorExport(),
GraphicsDsAddtidScratchExport(),
GraphicsDirectSgprPushConstantExport(),
@@ -47959,6 +48042,17 @@ int main(int argc, char **argv) {
RunCase(&vulkan, SiblingSharedExitKeepsCapturedConditions());
return 0;
}
if (argc == 2 && std::strcmp(argv[1], "--front-face-only") == 0) {
CheckPixelFrontFaceModes();
VulkanHarness vulkan;
for (bool all_bits : {false, true}) {
for (bool reversed : {false, true}) {
RunGraphicsCase(&vulkan, GraphicsFrontFaceBits(reversed, all_bits));
}
}
RunGraphicsCase(&vulkan, GraphicsAncillaryLayer(true));
return 0;
}
if (argc == 2 && std::strcmp(argv[1], "--position-w-only") == 0) {
VulkanHarness vulkan;
RunGraphicsCase(&vulkan, GraphicsPositionWExport());
@@ -48574,6 +48668,7 @@ int main(int argc, char **argv) {
CheckEmbeddedFetchLaneSpill();
CheckTessellationPrograms();
CheckPixelParameterAliases();
CheckPixelFrontFaceModes();
CheckRectListShaders();
CheckSigned16ImageStore(vulkan);
CheckBoundedFormattedBufferCopy(vulkan);
+62
View File
@@ -1,4 +1,5 @@
#include "common/file.h"
#include "common/localization.h"
#include "common/trophies.h"
#include <chrono>
@@ -8,6 +9,7 @@
#include <filesystem>
#include <map>
#include <string>
#include <string_view>
#include <vector>
namespace {
@@ -20,6 +22,65 @@ void Check(bool value, const char *text) {
}
}
void TestLocalization() {
struct Translation {
std::string_view locale;
std::string_view earned;
};
static constexpr Translation translations[] = {
{"ja-JP", "トロフィーを獲得しました!"},
{"en-US", "Trophy earned!"},
{"fr-FR", "Trophée obtenu !"},
{"es-ES", "¡Ganaste un trofeo!"},
{"de-DE", "Trophäe erhalten!"},
{"it-IT", "Trofeo ottenuto!"},
{"nl-NL", "Trofee behaald!"},
{"pt-PT", "Troféu conquistado!"},
{"ru-RU", "Трофей получен!"},
{"ko-KR", "트로피를 획득했습니다!"},
{"zh-Hant", "已獲得獎盃!"},
{"zh-Hans", "已获得奖杯!"},
{"fi-FI", "Pokaali ansaittu!"},
{"sv-SE", "Trofé intjänad!"},
{"da-DK", "Trofæ optjent!"},
{"no-NO", "Trofé opptjent!"},
{"pl-PL", "Zdobyto trofeum!"},
{"pt-BR", "Troféu conquistado!"},
{"en-GB", "Trophy earned!"},
{"tr-TR", "Kupa kazanıldı!"},
{"es-419", "¡Ganaste un trofeo!"},
{"ar-AE", "تم كسب الكأس!"},
{"fr-CA", "Trophée obtenu !"},
{"cs-CZ", "Trofej získána!"},
{"hu-HU", "Trófea megszerezve!"},
{"el-GR", "Κερδίσατε ένα τρόπαιο!"},
{"ro-RO", "Trofeu câștigat!"},
{"th-TH", "ได้รับถ้วยรางวัลแล้ว!"},
{"vi-VN", "Đã nhận cúp!"},
{"id-ID", "Trofi diperoleh!"},
};
constexpr std::string_view key = "trophy.toast.earned";
const auto japanese = Common::Localization::Get(0, key);
int language = 0;
for (const auto &translation : translations) {
Check(Common::Localization::LocaleName(language) == translation.locale,
"console language selects its locale");
Check(Common::Localization::Get(language, key) == translation.earned,
"embedded notification preserves translated UTF-8 bytes");
++language;
}
Check(japanese == translations[0].earned,
"translation view survives other locale lookups");
for (const int invalid : {-1, 30, 1000}) {
Check(Common::Localization::LocaleName(invalid) == "en-US" &&
Common::Localization::Get(invalid, key) == "Trophy earned!",
"invalid console language falls back to English");
}
Check(Common::Localization::Get(0, "missing.translation") ==
"missing.translation",
"unknown translation returns its key");
}
class TempDirectory {
public:
TempDirectory() {
@@ -252,6 +313,7 @@ void TestUnlockPersistence(const std::filesystem::path &directory) {
} // namespace
int main() {
TestLocalization();
TempDirectory directory;
TestEventExtraction(directory.Path());
TestProgress(directory.Path());