From 274ed7983fc25bc79c622b51925a314cdd02f09a Mon Sep 17 00:00:00 2001 From: Eddoursul Date: Thu, 30 Jul 2026 01:47:46 +0200 Subject: [PATCH] Added enforcing language with EnderalLanguage.ini --- source/Enderal DLL/src/Main.cpp | 3 + .../Enderal DLL/src/Patches/ForceLanguage.h | 115 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 source/Enderal DLL/src/Patches/ForceLanguage.h diff --git a/source/Enderal DLL/src/Main.cpp b/source/Enderal DLL/src/Main.cpp index dac717465..a1022fc80 100644 --- a/source/Enderal DLL/src/Main.cpp +++ b/source/Enderal DLL/src/Main.cpp @@ -16,6 +16,7 @@ #include "Patches/PluginsTxtPatch.h" #include "Patches/FormTypeCollisionDetector.h" #include "Patches/GogVramLeakFix.h" +#include "Patches/ForceLanguage.h" using namespace SKSE; @@ -214,6 +215,8 @@ SKSEPluginLoad(const LoadInterface* skse) { RE::INISettingCollection::GetSingleton()->GetSetting("sIntroSequence:General")->data.s = nullptr; + ForceLanguage::Install("Data/SKSE/Plugins/EnderalLanguage.ini"); + EventListener::Install(); GetPapyrusInterface()->Register(Papyrus::Bind); diff --git a/source/Enderal DLL/src/Patches/ForceLanguage.h b/source/Enderal DLL/src/Patches/ForceLanguage.h new file mode 100644 index 000000000..cb7fd6446 --- /dev/null +++ b/source/Enderal DLL/src/Patches/ForceLanguage.h @@ -0,0 +1,115 @@ +#pragma once + +#include + +// 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\_.STRINGS / .DLSTRINGS / .ILSTRINGS +// Interface\Translate_.txt +// Interface\Controls\\.txt +// credits_.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 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(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 _ReadSetting; +};