Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d06afec
feat: support skse translation (#309)
Sieluna Dec 14, 2024
3cb34cb
fix: reorder loading order and improve utils
Sieluna Dec 14, 2024
92928ed
refactor: test more translations
Sieluna Dec 15, 2024
9e8d93f
style: 🎨 apply clang-format changes
Sieluna Dec 15, 2024
529e197
refactor: modified general setting
Sieluna Dec 15, 2024
d7ab57b
Merge branch 'localization' of https://github.com/Sieluna/skyrim-comm…
Sieluna Dec 15, 2024
27dee83
refactor: improve translation by Pentalimbed's advice
Sieluna Dec 15, 2024
aaa5ec2
refactor: small fix and more translations
Sieluna Dec 16, 2024
bdee7ad
feat: create font subset feature
Sieluna Dec 17, 2024
e939838
style: 🎨 apply clang-format changes
Sieluna Dec 17, 2024
8ac690a
feat: dynamic resolve args and better ci
Sieluna Dec 17, 2024
95f0f2c
style: 🎨 apply clang-format changes
Sieluna Dec 17, 2024
5c0aa19
fix: disable few configs and make more translations
Sieluna Dec 17, 2024
c292a71
fix: force build all latin
Sieluna Dec 17, 2024
4915cc8
fix: improve styles and fix string format
Sieluna Dec 18, 2024
5cbdfe9
fix: improve and add missing translations
Sieluna Dec 18, 2024
9a3a116
fix: improve japanese translations
Sieluna Dec 18, 2024
696b6a5
chore: test working-tree-encoding
Sieluna Dec 18, 2024
67b15ad
chore: bake utf8
Sieluna Dec 18, 2024
68827d5
fix: fix typos
Sieluna Dec 18, 2024
835afe2
feat: wip dynamic load font
Sieluna Dec 25, 2024
2abc221
fix: fix few mistakes
Sieluna Dec 25, 2024
ab30724
refactor: load font good
Sieluna Dec 25, 2024
861923e
style: 🎨 apply clang-format changes
Sieluna Dec 25, 2024
509a9ce
refactor: move warning closer to input
Sieluna Dec 25, 2024
e9136b8
style: 🎨 apply clang-format changes
Sieluna Dec 25, 2024
20f8d4d
ci: prepare translation json
Sieluna Dec 25, 2024
74e51e7
ci: wip loaklise integrate
Sieluna Dec 25, 2024
b79d59a
fix: fix a mistake
Sieluna Dec 25, 2024
aa21c67
ci: save cache in branch
Sieluna Dec 25, 2024
9dac59b
fix: try allow pr
Sieluna Dec 25, 2024
95c9f32
ci: force lokalise not create pr
Sieluna Dec 25, 2024
a51ca43
fix: incorrect iso mapping
Sieluna Dec 25, 2024
7e1151e
fix: bad fix
Sieluna Dec 25, 2024
74a5553
fix: none translates path
Sieluna Dec 26, 2024
3526de2
ci: wip drop the fucking action
Sieluna Dec 26, 2024
da69a4d
ci: use esm
Sieluna Dec 26, 2024
4234b04
ci: es module fix
Sieluna Dec 26, 2024
5c09b42
fix: number parse issue
Sieluna Dec 26, 2024
9f7984e
revert: address the issue
Sieluna Dec 26, 2024
2118b5e
fix: to executable
Sieluna Dec 26, 2024
31b82c4
ci: use relative path
Sieluna Dec 26, 2024
7de7b32
Merge remote-tracking branch 'upstream/dev' into localization
Sieluna Jul 14, 2025
60058b8
fix: few small fix
Sieluna Jul 14, 2025
bba85aa
chore: check translation failure
alandtse Jul 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ jobs:
with:
submodules: recursive

- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"

- uses: ilammy/[email protected]

- uses: lukka/[email protected]
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ build/
bin/
dist/
.vs*/
.idea/
CMakeUserPresets.json
shadertoolsconfig.json
.vscode
.vscode
fonts/
33 changes: 32 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ if(AUTO_PLUGIN_DEPLOYMENT)
copy_shaders.stamp
${DEPLOY_TARGET_HASHES}
)

if(NOT DEFINED ENV{CommunityShadersOutputDir})
message("When using AUTO_PLUGIN_DEPLOYMENT option, you need to set environment variable 'CommunityShadersOutputDir'")
endif()
Expand Down Expand Up @@ -265,3 +265,34 @@ if(AIO_ZIP_TO_DIST)
WORKING_DIRECTORY ${AIO_DIR}
)
endif()

# #######################################################################################################################
# # Generate font subset when translations updated
# #######################################################################################################################

find_package(Python3 COMPONENTS Interpreter)

if (Python3_Interpreter_FOUND)
set(SCRIPT "${CMAKE_SOURCE_DIR}/subset_font.py" CACHE STRING "Path to the Python script")
set(ORIGINAL_FONT "https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc" CACHE STRING "Original font file URL or path")
set(SUBSET_FONT "${CMAKE_SOURCE_DIR}/package/Interface/CommunityShaders/Fonts/CommunityShaders.ttf" CACHE STRING "Output subset font file path")
set(TRANSLATIONS_DIR "${CMAKE_SOURCE_DIR}/package/Interface/Translations" CACHE STRING "Translations directory")

file(GLOB_RECURSE TRANSLATIONS CONFIGURE_DEPENDS "${TRANSLATIONS_DIR}/*")

add_custom_command(
OUTPUT "${SUBSET_FONT}"
COMMAND Python3::Interpreter "${SCRIPT}"
--input "${ORIGINAL_FONT}"
--output "${SUBSET_FONT}"
--text_dir "${TRANSLATIONS_DIR}"
DEPENDS ${TRANSLATIONS}
COMMENT "Generating subset font based on updated translations."
VERBATIM
)

add_custom_target(GenSubsetFont ALL DEPENDS "${SUBSET_FONT}")
add_dependencies(${PROJECT_NAME} GenSubsetFont)
else()
message(WARNING "Python3 not found: Skipping subset font generation logic.")
endif()
Binary file not shown.
Binary file not shown.
Binary file not shown.
332 changes: 145 additions & 187 deletions src/Menu.cpp

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Menu.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class Menu
bool inTestMode = false; // Whether we're in test mode
bool usingTestConfig = false; // Whether we're using the test config

std::chrono::steady_clock::time_point lastTestSwitch = high_resolution_clock::now(); // Time of last test switch
steady_clock::time_point lastTestSwitch = high_resolution_clock::now(); // Time of last test switch

Menu() = default;
void SetupImGuiStyle() const;
Expand Down
1 change: 1 addition & 0 deletions src/Util.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
#include "Utils/Game.h"
#include "Utils/GameSetting.h"
#include "Utils/Serialize.h"
#include "Utils/Translate.h"
#include "Utils/UI.h"
#include "Utils/WinApi.h"
17 changes: 17 additions & 0 deletions src/Utils/Translate.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include "Translate.h"

#include "SKSE/Translation.h"

namespace Util
{
std::string Translate(const std::string& key)
{
std::string buffer;

if (SKSE::Translation::Translate(key, buffer)) {
return buffer;
}

return key;
}
}
90 changes: 90 additions & 0 deletions src/Utils/Translate.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#pragma once

#include <fmt/core.h>
#include <string>
#include <utility>
#include <vector>

namespace Util
{
std::string Translate(const std::string& key);

struct Translatable
{
std::string_view key;

template <typename... Args>
std::string operator()(Args&&... args) const
{
auto [keyArgs, otherArgs] = filterArgs(std::forward<Args>(args)...);
std::string preprocessedKey = preprocessKey(key, keyArgs);
std::string translatedKey = Translate(preprocessedKey);
return formatWithArgs(translatedKey, otherArgs);
}

explicit operator std::string() const noexcept
{
return Translate(std::string(key));
}

private:
template <typename... Args>
static std::pair<std::vector<std::string_view>, std::vector<std::string>> filterArgs(Args&&... args)
{
std::vector<std::string_view> keyArgs;
std::vector<std::string> otherArgs;

auto processArg = [&](auto&& arg) {
if constexpr (std::is_convertible_v<decltype(arg), std::string_view>) {
if (std::string_view argView(arg); argView.starts_with('$'))
keyArgs.emplace_back(argView);
else
otherArgs.emplace_back(argView);
} else {
otherArgs.emplace_back(fmt::format("{}", arg));
}
};

(processArg(std::forward<Args>(args)), ...);
return { std::move(keyArgs), std::move(otherArgs) };
}

static std::string preprocessKey(const std::string_view key, const std::vector<std::string_view>& keyArgs)
{
std::string result(key);
size_t pos = 0;

for (const auto& arg : keyArgs) {
if ((pos = result.find("{}", pos)) != std::string::npos) {
result.replace(pos, 2, "{" + std::string(arg) + "}");
pos += arg.size() + 2;
} else
break;
}

return result;
}

static std::string formatWithArgs(const std::string& translation, const std::vector<std::string>& args)
{
std::vector<fmt::format_context::format_arg> formatArgs;
for (const auto& arg : args) {
formatArgs.push_back(fmt::detail::make_arg<fmt::format_context>(arg));
}

return vformat(translation, fmt::basic_format_args(formatArgs.data(), static_cast<int>(formatArgs.size())));
}
};
Comment on lines +68 to +77
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid using fmt internal APIs.

The code uses fmt::detail::make_arg which is an internal API that may change between fmt library versions.

Use fmt's public API instead:

 static std::string formatWithArgs(const std::string& translation, const std::vector<std::string>& args)
 {
-    std::vector<fmt::format_context::format_arg> formatArgs;
-    for (const auto& arg : args) {
-        formatArgs.push_back(fmt::detail::make_arg<fmt::format_context>(arg));
-    }
-
-    return vformat(translation, fmt::basic_format_args(formatArgs.data(), static_cast<int>(formatArgs.size())));
+    return std::apply([&translation](const auto&... args) {
+        return fmt::vformat(translation, fmt::make_format_args(args...));
+    }, std::tuple_cat(std::make_tuple(), args));
 }

Alternatively, if variadic expansion isn't suitable, consider using fmt::dynamic_format_arg_store which is part of the public API.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/Utils/Translate.h around lines 68 to 77, the code uses the internal API
fmt::detail::make_arg which is not stable across fmt versions. Replace this with
the public API by using fmt::dynamic_format_arg_store to build the argument list
dynamically, then pass it to vformat. This avoids reliance on internal details
and ensures compatibility with future fmt releases.

}

inline Util::Translatable operator"" _i18n(const char* key, std::size_t) noexcept
{
return Util::Translatable{ std::string_view(key) };
}

inline const char* operator"" _i18n_cs(const char* key, std::size_t) noexcept
{
thread_local std::string translation;
translation = Util::Translate(std::string(key));
return translation.c_str();
}
Comment on lines +85 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Thread-local storage may cause issues with multiple calls in the same expression.

The _i18n_cs operator uses a single thread-local string, which means calling it multiple times in the same expression will invalidate previous pointers.

Example of the issue:

// Both pointers will point to the same string (the last translation)
printf("%s %s", "key1"_i18n_cs, "key2"_i18n_cs);

Consider using a thread-local circular buffer or documenting this limitation clearly:

 inline const char* operator"" _i18n_cs(const char* key, std::size_t) noexcept
 {
-    thread_local std::string translation;
-    translation = Util::Translate(std::string(key));
-    return translation.c_str();
+    thread_local std::array<std::string, 4> translations;
+    thread_local size_t index = 0;
+    translations[index] = Util::Translate(std::string(key));
+    const char* result = translations[index].c_str();
+    index = (index + 1) % translations.size();
+    return result;
 }
🤖 Prompt for AI Agents
In src/Utils/Translate.h around lines 85 to 90, the operator"" _i18n_cs uses a
single thread-local std::string which causes returned pointers to be overwritten
on multiple calls in the same expression. To fix this, implement a thread-local
circular buffer of strings to store multiple translations simultaneously,
cycling through the buffer on each call to avoid pointer invalidation, or
alternatively add clear documentation warning users about this limitation.

2 changes: 2 additions & 0 deletions src/XSEPlugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ void MessageHandler(SKSE::MessagingInterface::Message* message)
shaderCache.WriteDiskCacheInfo();
}

SKSE::Translation::ParseTranslation("CommunityShaders");

if (!REL::Module::IsVR()) {
RE::GetINISetting("bEnableImprovedSnow:Display")->data.b = false;
RE::GetINISetting("bIBLFEnable:Display")->data.b = false;
Expand Down
110 changes: 110 additions & 0 deletions subset_font.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlretrieve

try:
from fontTools import subset
except ImportError:
print("fonttools not found. Installing via pip...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "fonttools"])
from fontTools import subset

def parse_arguments():
parser = argparse.ArgumentParser(
description="Automate collecting unique characters and generate font subset."
)
parser.add_argument(
"--input",
type=str,
default="https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc",
help="Path or URL to the original font file (e.g., C:/Windows/Fonts/msyh.ttc)."
)
parser.add_argument(
"--output",
type=str,
default="package/Interface/CommunityShaders/Fonts/CommunityShaders.ttf",
help="Path for the output subset font file (e.g., SubsetFont.ttf)."
)
parser.add_argument(
"--text_dir",
type=str,
default="package/Interface/Translations",
help="Directory to scan for translated files."
)
parser.add_argument(
"--font_number",
type=int,
default=0,
help="Font number of the font file (default: 0)."
)
return parser.parse_args()

def is_url(path):
try:
result = urlparse(path)
return all([result.scheme, result.netloc])
except ValueError:
return False

def get_cache_dir():
cache_path = Path.cwd() / "fonts"
cache_path.mkdir(parents=True, exist_ok=True)
return cache_path

def download_font(url):
download_path = get_cache_dir() / Path(url).name
if not download_path.is_file():
print(f"Downloading font from '{url}' to '{download_path}'...")
try:
urlretrieve(url, download_path)
except Exception as e:
sys.exit(f"Error downloading font: {e}")
return str(download_path)

def collect_unique_chars(input_dir, extension=".txt"):
unique_chars = set()
for file in Path(input_dir).rglob(f"*{extension}"):
try:
with file.open('r', encoding='utf-16-le', errors='ignore') as f:
unique_chars.update(c for c in f.read() if c.isprintable())
except Exception as e:
print(f"Error reading '{file}': {e}", file=sys.stderr)
return unique_chars

def subset_font(original_path, subset_path, text, font_number):
try:
options = subset.Options(font_number = font_number)
subsetter = subset.Subsetter(options=options)
subsetter.populate(text=text)

with subset.load_font(str(original_path), options) as font:
subsetter.subset(font)
subset.save_font(font, str(subset_path), options)
print(f"Subset font saved to '{subset_path}'.")
except Exception as e:
sys.exit(f"Error during font subsetting: {e}")

def main():
args = parse_arguments()

original_font = download_font(args.input) if is_url(args.input) else args.input
if not Path(original_font).is_file():
sys.exit(f"Error: Font file '{original_font}' does not exist.")

unique_chars = collect_unique_chars(args.text_dir)
if not unique_chars:
sys.exit("No characters found. Exiting.")

basic_latin = {chr(c) for c in range(0x0020, 0x0100)}
combined_chars = unique_chars | basic_latin

print(f"Total characters to include in subset: {len(combined_chars)}.")

subset_font(original_font, args.output, ''.join(combined_chars), args.font_number)

if __name__ == "__main__":
main()
Loading