Added enforcing language with EnderalLanguage.ini

This commit is contained in:
Eddoursul 2026-07-30 01:47:46 +02:00
parent 8b7b1084c0
commit 274ed7983f
2 changed files with 118 additions and 0 deletions

View File

@ -16,6 +16,7 @@
#include "Patches/PluginsTxtPatch.h" #include "Patches/PluginsTxtPatch.h"
#include "Patches/FormTypeCollisionDetector.h" #include "Patches/FormTypeCollisionDetector.h"
#include "Patches/GogVramLeakFix.h" #include "Patches/GogVramLeakFix.h"
#include "Patches/ForceLanguage.h"
using namespace SKSE; using namespace SKSE;
@ -214,6 +215,8 @@ SKSEPluginLoad(const LoadInterface* skse) {
RE::INISettingCollection::GetSingleton()->GetSetting("sIntroSequence:General")->data.s = nullptr; RE::INISettingCollection::GetSingleton()->GetSetting("sIntroSequence:General")->data.s = nullptr;
ForceLanguage::Install("Data/SKSE/Plugins/EnderalLanguage.ini");
EventListener::Install(); EventListener::Install();
GetPapyrusInterface()->Register(Papyrus::Bind); GetPapyrusInterface()->Register(Papyrus::Bind);

View File

@ -0,0 +1,115 @@
#pragma once
#include <SimpleIni.h>
// Reads the language from Data/SKSE/Plugins/EnderalLanguage.ini and enforces it at runtime,
// independently of sLanguage in Skyrim.ini.
//
// The engine keeps the active language in the string setting sLanguage:General and reads that
// Setting's data.s directly (the object is statically allocated, so the reads are hardcoded
// addresses) whenever it builds a localized path:
//
// Strings\<plugin>_<language>.STRINGS / .DLSTRINGS / .ILSTRINGS
// Interface\Translate_<language>.txt
// Interface\Controls\<device>\<language>.txt
// credits_<language>.txt
//
// Overwriting data.s therefore switches the language everywhere at once, but only inside a narrow
// window: INISettingCollection::ReadSetting puts the Skyrim.ini value back (that file does contain
// sLanguage, unlike sIntroSequence), and TESDataHandler has already loaded the .STRINGS files by
// the time SKSE dispatches kDataLoaded. Hooking ReadSetting sidesteps both problems - the value is
// re-applied right after every read of that setting, whenever the engine reads the INI.
class ForceLanguage final : public RE::INISettingCollection
{
public:
static void Install(const char* a_iniPath)
{
if (!ReadINI(a_iniPath)) {
return;
}
const auto collection = RE::INISettingCollection::GetSingleton();
setting = collection ? collection->GetSetting("sLanguage:General") : nullptr;
if (!setting) {
logger::error("sLanguage:General is missing, cannot enforce the language");
return;
}
REL::Relocation<uintptr_t> vtbl(RE::INISettingCollection::VTABLE[0]);
_ReadSetting = vtbl.write_vfunc(0x4, &ReadSettingEx);
Apply();
logger::info("Enforcing language {}", requested);
}
private:
bool ReadSettingEx(RE::Setting* a_setting)
{
const auto result = _ReadSetting(this, a_setting);
if (a_setting == setting) {
Apply();
}
return result;
}
// The engine owns data.s once the setting is managed and may overwrite it in place with a value
// of equal or smaller length, so hand out a private buffer and restamp it from requested.
static void Apply()
{
strcpy_s(buffer, sizeof(buffer), requested.c_str());
setting->data.s = buffer;
}
static bool ReadINI(const char* a_iniPath)
{
if (!std::filesystem::exists(a_iniPath)) {
return false;
}
CSimpleIniA ini;
ini.SetUnicode(false);
ini.SetMultiKey(false);
if (const auto result = ini.LoadFile(a_iniPath); result != SI_OK) {
logger::error("Failed to read {} (error {})", a_iniPath, static_cast<int>(result));
return false;
}
// [General] mirrors Skyrim.ini, a section-less key is accepted as well.
requested = ini.GetValue("General", "sLanguage", ini.GetValue("", "sLanguage", ""));
constexpr auto whitespace = " \t\r\n"sv;
const auto first = requested.find_first_not_of(whitespace);
requested = first == std::string::npos
? std::string()
: requested.substr(first, requested.find_last_not_of(whitespace) - first + 1);
if (requested.empty()) {
logger::warn("{} has no sLanguage value, keeping the language from Skyrim.ini", a_iniPath);
return false;
}
if (requested.size() >= sizeof(buffer)) {
logger::error("sLanguage value \"{}\" in {} is too long", requested, a_iniPath);
requested.clear();
return false;
}
// Enderal ships its strings loose, so a missing file means the language is most likely misspelled.
const auto strings = std::format("Data/Strings/enderal - forgotten stories_{}.strings", requested);
if (!std::filesystem::exists(strings)) {
logger::warn("{} does not exist, {} may not be a supported language", strings, requested);
}
return true;
}
inline static std::string requested; // as configured, never handed to the engine
inline static char buffer[64]{}; // what data.s points at, may be overwritten in place
inline static RE::Setting* setting{ nullptr };
inline static REL::Relocation<decltype(&ReadSettingEx)> _ReadSetting;
};