forked from chinosk/gkms-local
Compare commits
1 Commits
main
...
feature-sp
Author | SHA1 | Date |
---|---|---|
|
cd82db9422 |
|
@ -12,22 +12,21 @@ jobs:
|
|||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: set up android development environment
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sdkmanager --install "cmake;3.22.1"
|
||||
echo "cmake.dir=/usr/local/lib/android/sdk/cmake/3.22.1" > local.properties
|
||||
npm install -g pnpm
|
||||
|
||||
- name: Setup Java JDK
|
||||
uses: actions/setup-java@v4.2.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Android Development Environment
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sdkmanager --install "cmake;3.22.1"
|
||||
echo "cmake.dir=$ANDROID_HOME/cmake/3.22.1" > local.properties
|
||||
echo "$ANDROID_HOME/build-tools/34.0.0" >> $GITHUB_PATH
|
||||
npm install -g pnpm
|
||||
|
||||
- name: Update Submodules
|
||||
run: |
|
||||
git submodule foreach --recursive 'git pull --rebase origin main --allow-unrelated-histories'
|
||||
|
@ -59,44 +58,24 @@ jobs:
|
|||
run: ./gradlew build
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
id: upload_unsigned_v4
|
||||
with:
|
||||
name: GakumasLocalify-Unsigned-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Unsigned APK with v3 if v4 failed
|
||||
if: steps.upload_unsigned_v4.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: GakumasLocalify-Unsigned-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
continue-on-error: true
|
||||
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
- uses: ilharp/sign-android-release@v1
|
||||
name: Sign app APK
|
||||
id: sign_app
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/debug
|
||||
signingKeyBase64: ${{ secrets.KEYSTOREB64 }}
|
||||
alias: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
releaseDir: app/build/outputs/apk/debug
|
||||
signingKey: ${{ secrets.KEYSTOREB64 }}
|
||||
keyAlias: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
buildToolsVersion: 33.0.0
|
||||
continue-on-error: true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
id: upload_signed_v4
|
||||
with:
|
||||
name: GakumasLocalify-Signed-apk
|
||||
path: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Signed APK with v3 if v4 failed
|
||||
if: steps.upload_signed_v4.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: GakumasLocalify-Signed-apk
|
||||
path: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||
path: ${{steps.sign_app.outputs.signedFile}}
|
||||
continue-on-error: true
|
||||
|
|
|
@ -39,8 +39,7 @@ std::unordered_set<void*> hookedStubs{};
|
|||
GakumasLocal::Log::InfoFmt("ADD_HOOK: %s at %p", #name, addr); \
|
||||
} \
|
||||
} \
|
||||
else GakumasLocal::Log::ErrorFmt("Hook failed: %s is NULL", #name, addr); \
|
||||
if (Config::lazyInit) UnityResolveProgress::classProgress.current++
|
||||
else GakumasLocal::Log::ErrorFmt("Hook failed: %s is NULL", #name, addr)
|
||||
|
||||
void UnHookAll() {
|
||||
for (const auto i: hookedStubs) {
|
||||
|
@ -98,7 +97,7 @@ namespace GakumasLocal::HookMain {
|
|||
UnityResolve::UnityType::Transform* cameraTransformCache = nullptr;
|
||||
void CheckAndUpdateMainCamera() {
|
||||
if (!Config::enableFreeCamera) return;
|
||||
if (IsNativeObjectAlive(mainCameraCache) && IsNativeObjectAlive(cameraTransformCache)) return;
|
||||
if (IsNativeObjectAlive(mainCameraCache)) return;
|
||||
|
||||
mainCameraCache = UnityResolve::UnityType::Camera::GetMain();
|
||||
cameraTransformCache = mainCameraCache->GetTransform();
|
||||
|
@ -828,8 +827,7 @@ namespace GakumasLocal::HookMain {
|
|||
|
||||
void StartInjectFunctions() {
|
||||
const auto hookInstaller = Plugin::GetInstance().GetHookInstaller();
|
||||
UnityResolve::Init(xdl_open(hookInstaller->m_il2cppLibraryPath.c_str(), RTLD_NOW),
|
||||
UnityResolve::Mode::Il2Cpp, Config::lazyInit);
|
||||
UnityResolve::Init(xdl_open(hookInstaller->m_il2cppLibraryPath.c_str(), RTLD_NOW), UnityResolve::Mode::Il2Cpp);
|
||||
|
||||
ADD_HOOK(AssetBundle_LoadAssetAsync, Il2cppUtils::il2cpp_resolve_icall(
|
||||
"UnityEngine.AssetBundle::LoadAssetAsync_Internal(System.String,System.Type)"));
|
||||
|
@ -961,30 +959,10 @@ namespace GakumasLocal::HookMain {
|
|||
|
||||
Log::Info("Start init plugin...");
|
||||
|
||||
if (Config::lazyInit) {
|
||||
UnityResolveProgress::startInit = true;
|
||||
UnityResolveProgress::assembliesProgress.total = 2;
|
||||
UnityResolveProgress::assembliesProgress.current = 1;
|
||||
UnityResolveProgress::classProgress.total = 36;
|
||||
UnityResolveProgress::classProgress.current = 0;
|
||||
}
|
||||
|
||||
StartInjectFunctions();
|
||||
GKCamera::initCameraSettings();
|
||||
|
||||
if (Config::lazyInit) {
|
||||
UnityResolveProgress::assembliesProgress.current = 2;
|
||||
UnityResolveProgress::classProgress.total = 1;
|
||||
UnityResolveProgress::classProgress.current = 0;
|
||||
}
|
||||
|
||||
Local::LoadData();
|
||||
|
||||
if (Config::lazyInit) {
|
||||
UnityResolveProgress::classProgress.current = 1;
|
||||
UnityResolveProgress::startInit = false;
|
||||
}
|
||||
|
||||
Log::Info("Plugin init finished.");
|
||||
return ret;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
#include <thread>
|
||||
#include <regex>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
#include <cctype>
|
||||
#include <algorithm>
|
||||
#include "BaseDefine.h"
|
||||
|
||||
|
||||
|
@ -19,6 +22,8 @@ namespace GakumasLocal::Local {
|
|||
std::unordered_map<std::string, std::string> i18nDumpData{};
|
||||
std::unordered_map<std::string, std::string> genericText{};
|
||||
std::vector<std::string> genericTextDumpData{};
|
||||
std::vector<std::string> genericSplittedDumpData{};
|
||||
std::vector<std::string> genericOrigTextDumpData{};
|
||||
std::unordered_set<std::string> translatedText{};
|
||||
int genericDumpFileIndex = 0;
|
||||
|
||||
|
@ -26,6 +31,48 @@ namespace GakumasLocal::Local {
|
|||
return Plugin::GetInstance().GetHookInstaller()->localizationFilesDir;
|
||||
}
|
||||
|
||||
std::string trim(const std::string& str) {
|
||||
auto is_not_space = [](char ch) { return !std::isspace(ch); };
|
||||
auto start = std::ranges::find_if(str, is_not_space);
|
||||
auto end = std::ranges::find_if(str | std::views::reverse, is_not_space).base();
|
||||
|
||||
if (start < end) {
|
||||
return {start, end};
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string findInMapIgnoreSpace(const std::string& key, const std::unordered_map<std::string, std::string>& searchMap) {
|
||||
auto is_space = [](char ch) { return std::isspace(ch); };
|
||||
auto front = std::ranges::find_if_not(key, is_space);
|
||||
auto back = std::ranges::find_if_not(key | std::views::reverse, is_space).base();
|
||||
|
||||
std::string prefix(key.begin(), front);
|
||||
std::string suffix(back, key.end());
|
||||
|
||||
std::string trimmedKey = trim(key);
|
||||
if ( auto it = searchMap.find(trimmedKey); it != searchMap.end()) {
|
||||
return prefix + it->second + suffix;
|
||||
}
|
||||
else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
enum class DumpStrStat {
|
||||
DEFAULT = 0,
|
||||
SPLITTABLE_ORIG = 1,
|
||||
SPLITTED = 2
|
||||
};
|
||||
|
||||
enum class SplitTagsTranslationStat {
|
||||
NO_TRANS,
|
||||
PART_TRANS,
|
||||
FULL_TRANS,
|
||||
NO_SPLIT,
|
||||
NO_SPLIT_AND_EMPTY
|
||||
};
|
||||
|
||||
void LoadJsonDataToMap(const std::filesystem::path& filePath, std::unordered_map<std::string, std::string>& dict,
|
||||
const bool insertToTranslated = false, const bool needClearDict = true) {
|
||||
if (!exists(filePath)) return;
|
||||
|
@ -84,7 +131,7 @@ namespace GakumasLocal::Local {
|
|||
}
|
||||
|
||||
void DumpVectorDataToJson(const std::filesystem::path& dumpBasePath, const std::filesystem::path& fileName,
|
||||
const std::vector<std::string>& vec) {
|
||||
const std::vector<std::string>& vec, const std::string& valuePrefix = "") {
|
||||
const auto dumpFilePath = dumpBasePath / fileName;
|
||||
try {
|
||||
if (!is_directory(dumpBasePath)) {
|
||||
|
@ -101,7 +148,12 @@ namespace GakumasLocal::Local {
|
|||
dumpLrcFile.close();
|
||||
auto fileData = nlohmann::ordered_json::parse(fileContent);
|
||||
for (const auto& i : vec) {
|
||||
fileData[i] = i;
|
||||
if (!valuePrefix.empty()) {
|
||||
fileData[i] = valuePrefix + i;
|
||||
}
|
||||
else {
|
||||
fileData[i] = i;
|
||||
}
|
||||
}
|
||||
const auto newStr = fileData.dump(4, 32, false);
|
||||
std::ofstream dumpWriteLrcFile(dumpFilePath, std::ofstream::out);
|
||||
|
@ -199,6 +251,87 @@ namespace GakumasLocal::Local {
|
|||
return ret;
|
||||
}
|
||||
|
||||
SplitTagsTranslationStat GetSplitTagsTranslationFull(const std::string& origTextIn, std::string* newText, std::vector<std::string>& unTransResultRet) {
|
||||
// static const std::u16string splitFlags = u"0123456789++--%%【】.";
|
||||
static const std::unordered_set<char16_t> splitFlags = {u'0', u'1', u'2', u'3', u'4', u'5',
|
||||
u'6', u'7', u'8', u'9', u'+', u'+',
|
||||
u'-', u'-', u'%', u'%', u'【', u'】',
|
||||
u'.', u':', u':', u'×'};
|
||||
|
||||
const auto origText = Misc::ToUTF16(origTextIn);
|
||||
bool isInTag = false;
|
||||
std::vector<std::string> waitingReplaceTexts{};
|
||||
|
||||
std::u16string currentWaitingReplaceText;
|
||||
|
||||
#define checkCurrentWaitingReplaceTextAndClear() \
|
||||
if (!currentWaitingReplaceText.empty()) { \
|
||||
waitingReplaceTexts.push_back(Misc::ToUTF8(currentWaitingReplaceText)); \
|
||||
currentWaitingReplaceText.clear(); }
|
||||
|
||||
for (char16_t currChar : origText) {
|
||||
if (currChar == u'<') {
|
||||
isInTag = true;
|
||||
}
|
||||
if (currChar == u'>') {
|
||||
isInTag = false;
|
||||
checkCurrentWaitingReplaceTextAndClear()
|
||||
continue;
|
||||
}
|
||||
if (isInTag) {
|
||||
checkCurrentWaitingReplaceTextAndClear()
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!splitFlags.contains(currChar)) {
|
||||
currentWaitingReplaceText.push_back(currChar);
|
||||
}
|
||||
else {
|
||||
checkCurrentWaitingReplaceTextAndClear()
|
||||
}
|
||||
}
|
||||
if (waitingReplaceTexts.empty()) {
|
||||
if (currentWaitingReplaceText.empty()) {
|
||||
return SplitTagsTranslationStat::NO_SPLIT_AND_EMPTY;
|
||||
}
|
||||
else {
|
||||
return SplitTagsTranslationStat::NO_SPLIT;
|
||||
}
|
||||
}
|
||||
checkCurrentWaitingReplaceTextAndClear()
|
||||
|
||||
*newText = origTextIn;
|
||||
SplitTagsTranslationStat ret;
|
||||
bool hasTrans = false;
|
||||
bool hasNotTrans = false;
|
||||
if (!waitingReplaceTexts.empty()) {
|
||||
for (const auto& i : waitingReplaceTexts) {
|
||||
const auto searchResult = findInMapIgnoreSpace(i, genericText);
|
||||
if (!searchResult.empty()) {
|
||||
ReplaceString(newText, i, searchResult);
|
||||
hasTrans = true;
|
||||
}
|
||||
else {
|
||||
unTransResultRet.emplace_back(trim(i));
|
||||
hasNotTrans = true;
|
||||
}
|
||||
}
|
||||
if (hasTrans && hasNotTrans) {
|
||||
ret = SplitTagsTranslationStat::PART_TRANS;
|
||||
}
|
||||
else if (hasTrans && !hasNotTrans) {
|
||||
ret = SplitTagsTranslationStat::FULL_TRANS;
|
||||
}
|
||||
else {
|
||||
ret = SplitTagsTranslationStat::NO_TRANS;
|
||||
}
|
||||
}
|
||||
else {
|
||||
ret = SplitTagsTranslationStat::NO_TRANS;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void LoadData() {
|
||||
static auto localizationFile = GetBasePath() / "local-files" / "localization.json";
|
||||
static auto genericFile = GetBasePath() / "local-files" / "generic.json";
|
||||
|
@ -215,7 +348,7 @@ namespace GakumasLocal::Local {
|
|||
if (std::filesystem::exists(genericDir) || std::filesystem::is_directory(genericDir)) {
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator(genericDir)) {
|
||||
if (std::filesystem::is_regular_file(entry.path())) {
|
||||
const auto currFile = entry.path();
|
||||
const auto& currFile = entry.path();
|
||||
if (to_lower(currFile.extension().string()) == ".json") {
|
||||
LoadJsonDataToMap(currFile, genericText, true, false);
|
||||
}
|
||||
|
@ -285,29 +418,47 @@ namespace GakumasLocal::Local {
|
|||
return false;
|
||||
}
|
||||
|
||||
std::string GetDumpGenericFileName() {
|
||||
if (genericDumpFileIndex == 0) return "generic.json";
|
||||
return Log::StringFormat("generic_%d.json", genericDumpFileIndex);
|
||||
std::string GetDumpGenericFileName(DumpStrStat stat = DumpStrStat::DEFAULT) {
|
||||
if (stat == DumpStrStat::SPLITTABLE_ORIG) {
|
||||
if (genericDumpFileIndex == 0) return "generic_orig.json";
|
||||
return Log::StringFormat("generic_orig_%d.json", genericDumpFileIndex);
|
||||
}
|
||||
else {
|
||||
if (genericDumpFileIndex == 0) return "generic.json";
|
||||
return Log::StringFormat("generic_%d.json", genericDumpFileIndex);
|
||||
}
|
||||
}
|
||||
|
||||
bool inDumpGeneric = false;
|
||||
void DumpGenericText(const std::string& origText) {
|
||||
void DumpGenericText(const std::string& origText, DumpStrStat stat = DumpStrStat::DEFAULT) {
|
||||
if (translatedText.contains(origText)) return;
|
||||
|
||||
if (std::find(genericTextDumpData.begin(), genericTextDumpData.end(), origText) != genericTextDumpData.end()) {
|
||||
std::array<std::reference_wrapper<std::vector<std::string>>, 3> targets = {
|
||||
genericTextDumpData,
|
||||
genericOrigTextDumpData,
|
||||
genericSplittedDumpData
|
||||
};
|
||||
|
||||
auto& appendTarget = targets[static_cast<int>(stat)].get();
|
||||
|
||||
if (std::find(appendTarget.begin(), appendTarget.end(), origText) != appendTarget.end()) {
|
||||
return;
|
||||
}
|
||||
if (IsPureStringValue(origText)) return;
|
||||
|
||||
genericTextDumpData.push_back(origText);
|
||||
appendTarget.push_back(origText);
|
||||
static auto dumpBasePath = GetBasePath() / "dump-files";
|
||||
|
||||
if (inDumpGeneric) return;
|
||||
inDumpGeneric = true;
|
||||
std::thread([](){
|
||||
std::this_thread::sleep_for(std::chrono::seconds(5));
|
||||
DumpVectorDataToJson(dumpBasePath, GetDumpGenericFileName(), genericTextDumpData);
|
||||
DumpVectorDataToJson(dumpBasePath, GetDumpGenericFileName(DumpStrStat::DEFAULT), genericTextDumpData);
|
||||
DumpVectorDataToJson(dumpBasePath, GetDumpGenericFileName(DumpStrStat::SPLITTABLE_ORIG), genericOrigTextDumpData);
|
||||
DumpVectorDataToJson(dumpBasePath, GetDumpGenericFileName(DumpStrStat::SPLITTED), genericSplittedDumpData, "[split]");
|
||||
genericTextDumpData.clear();
|
||||
genericSplittedDumpData.clear();
|
||||
genericOrigTextDumpData.clear();
|
||||
inDumpGeneric = false;
|
||||
}).detach();
|
||||
}
|
||||
|
@ -318,25 +469,50 @@ namespace GakumasLocal::Local {
|
|||
return true;
|
||||
}
|
||||
|
||||
auto ret = false;
|
||||
|
||||
std::vector<std::string> unTransResultRet;
|
||||
if (GetSplitTagsTranslation(origText, newStr, unTransResultRet)) {
|
||||
return true;
|
||||
const auto splitTransStat = GetSplitTagsTranslationFull(origText, newStr, unTransResultRet);
|
||||
switch (splitTransStat) {
|
||||
case SplitTagsTranslationStat::FULL_TRANS: {
|
||||
return true;
|
||||
} break;
|
||||
|
||||
case SplitTagsTranslationStat::NO_SPLIT_AND_EMPTY: {
|
||||
return false;
|
||||
} break;
|
||||
|
||||
case SplitTagsTranslationStat::NO_SPLIT: {
|
||||
ret = false;
|
||||
} break;
|
||||
|
||||
case SplitTagsTranslationStat::NO_TRANS: {
|
||||
ret = false;
|
||||
} break;
|
||||
|
||||
case SplitTagsTranslationStat::PART_TRANS: {
|
||||
ret = true;
|
||||
} break;
|
||||
}
|
||||
|
||||
if (!Config::dumpText) {
|
||||
return false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (unTransResultRet.empty()) {
|
||||
if (unTransResultRet.empty() || (splitTransStat == SplitTagsTranslationStat::NO_SPLIT)) {
|
||||
DumpGenericText(origText);
|
||||
}
|
||||
else {
|
||||
for (const auto& i : unTransResultRet) {
|
||||
DumpGenericText(i);
|
||||
DumpGenericText(i, DumpStrStat::SPLITTED);
|
||||
}
|
||||
// 若未翻译部分长度为1,且未翻译文本等于原文本,则不 dump 到原文本文件
|
||||
//if (unTransResultRet.size() != 1 || unTransResultRet[0] != origText) {
|
||||
DumpGenericText(origText, DumpStrStat::SPLITTABLE_ORIG);
|
||||
//}
|
||||
}
|
||||
|
||||
return false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::string ChangeDumpTextIndex(int changeValue) {
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace GakumasLocal::Config {
|
|||
|
||||
bool dbgMode = false;
|
||||
bool enabled = true;
|
||||
bool lazyInit = true;
|
||||
bool replaceFont = true;
|
||||
bool forceExportResource = true;
|
||||
bool textTest = false;
|
||||
|
@ -56,7 +55,6 @@ namespace GakumasLocal::Config {
|
|||
|
||||
GetConfigItem(dbgMode);
|
||||
GetConfigItem(enabled);
|
||||
GetConfigItem(lazyInit);
|
||||
GetConfigItem(replaceFont);
|
||||
GetConfigItem(forceExportResource);
|
||||
GetConfigItem(gameOrientation);
|
||||
|
|
|
@ -5,7 +5,6 @@ namespace GakumasLocal::Config {
|
|||
|
||||
extern bool dbgMode;
|
||||
extern bool enabled;
|
||||
extern bool lazyInit;
|
||||
extern bool replaceFont;
|
||||
extern bool forceExportResource;
|
||||
extern int gameOrientation;
|
||||
|
|
|
@ -47,18 +47,6 @@
|
|||
#include "../../GakumasLocalify/Log.h"
|
||||
#include "../../GakumasLocalify/Misc.hpp"
|
||||
|
||||
class UnityResolveProgress final {
|
||||
public:
|
||||
struct Progress {
|
||||
long current = 0;
|
||||
long total = 1;
|
||||
};
|
||||
|
||||
static bool startInit;
|
||||
static Progress assembliesProgress;
|
||||
static Progress classProgress;
|
||||
};
|
||||
|
||||
class UnityResolve final {
|
||||
public:
|
||||
struct Assembly;
|
||||
|
@ -81,16 +69,8 @@ public:
|
|||
|
||||
[[nodiscard]] auto Get(const std::string& strClass, const std::string& strNamespace = "*", const std::string& strParent = "*") const -> Class* {
|
||||
if (!this) return nullptr;
|
||||
/*
|
||||
if (lazyInit_ && classes.empty()) {
|
||||
const auto image = Invoke<void*>("il2cpp_assembly_get_image", address);
|
||||
ForeachClass(const_cast<Assembly *>(this), image);
|
||||
}*/
|
||||
for (const auto pClass : classes) if (strClass == pClass->name && (strNamespace == "*" || pClass->namespaze == strNamespace) && (strParent == "*" || pClass->parent == strParent)) return pClass;
|
||||
if (lazyInit_) {
|
||||
return FillClass_Il2ccpp(const_cast<Assembly *>(this), strNamespace.c_str(), strClass.c_str());
|
||||
}
|
||||
return nullptr;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -299,17 +279,14 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
static auto Init(void* hmodule, const Mode mode = Mode::Mono, const bool lazyInit = false) -> void {
|
||||
static auto Init(void* hmodule, const Mode mode = Mode::Mono) -> void {
|
||||
mode_ = mode;
|
||||
hmodule_ = hmodule;
|
||||
lazyInit_ = lazyInit;
|
||||
|
||||
if (mode_ == Mode::Il2Cpp) {
|
||||
if (!lazyInit) UnityResolveProgress::startInit = true;
|
||||
pDomain = Invoke<void*>("il2cpp_domain_get");
|
||||
Invoke<void*>("il2cpp_thread_attach", pDomain);
|
||||
ForeachAssembly();
|
||||
if (!lazyInit) UnityResolveProgress::startInit = false;
|
||||
}
|
||||
else {
|
||||
pDomain = Invoke<void*>("mono_get_root_domain");
|
||||
|
@ -584,11 +561,7 @@ private:
|
|||
if (mode_ == Mode::Il2Cpp) {
|
||||
size_t nrofassemblies = 0;
|
||||
const auto assemblies = Invoke<void**>("il2cpp_domain_get_assemblies", pDomain, &nrofassemblies);
|
||||
|
||||
if (!lazyInit_) UnityResolveProgress::assembliesProgress.total = nrofassemblies;
|
||||
|
||||
for (auto i = 0; i < nrofassemblies; i++) {
|
||||
if (!lazyInit_) UnityResolveProgress::assembliesProgress.current = i + 1;
|
||||
const auto ptr = assemblies[i];
|
||||
if (ptr == nullptr) continue;
|
||||
auto assembly = new Assembly{ .address = ptr };
|
||||
|
@ -596,9 +569,7 @@ private:
|
|||
assembly->file = Invoke<const char*>("il2cpp_image_get_filename", image);
|
||||
assembly->name = Invoke<const char*>("il2cpp_image_get_name", image);
|
||||
UnityResolve::assembly.push_back(assembly);
|
||||
if (!lazyInit_) {
|
||||
ForeachClass(assembly, image);
|
||||
}
|
||||
ForeachClass(assembly, image);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
@ -619,60 +590,11 @@ private:
|
|||
}
|
||||
}
|
||||
|
||||
static auto GetPClassFromUnknownNamespace(void* image, const char* klassName) -> void* {
|
||||
const auto count = Invoke<int>("il2cpp_image_get_class_count", image);
|
||||
for (auto i = 0; i < count; i++) {
|
||||
const auto pClass = Invoke<void*>("il2cpp_image_get_class", image, i);
|
||||
const auto className = Invoke<const char*>("il2cpp_class_get_name", pClass);
|
||||
if (strcmp(className, klassName) == 0) {
|
||||
return pClass;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static auto FillClass_Il2ccpp(Assembly* assembly, const char* namespaze, const char* klassName) -> Class* {
|
||||
auto image = Invoke<void*>("il2cpp_assembly_get_image", assembly->address);
|
||||
void* pClass;
|
||||
if (strcmp(namespaze, "*") == 0) {
|
||||
pClass = GetPClassFromUnknownNamespace(image, klassName);
|
||||
}
|
||||
else {
|
||||
pClass = Invoke<void*>("il2cpp_class_from_name", image, namespaze, klassName);
|
||||
}
|
||||
if (!pClass && (strlen(namespaze) == 0)) {
|
||||
pClass = GetPClassFromUnknownNamespace(image, klassName);
|
||||
}
|
||||
if (pClass == nullptr) return nullptr;
|
||||
const auto pAClass = new Class();
|
||||
pAClass->address = pClass;
|
||||
pAClass->name = Invoke<const char*>("il2cpp_class_get_name", pClass);
|
||||
if (const auto pPClass = Invoke<void*>("il2cpp_class_get_parent", pClass)) pAClass->parent = Invoke<const char*>("il2cpp_class_get_name", pPClass);
|
||||
// pAClass->namespaze = Invoke<const char*>("il2cpp_class_get_namespace", pClass);
|
||||
pAClass->namespaze = namespaze;
|
||||
assembly->classes.push_back(pAClass);
|
||||
|
||||
ForeachFields(pAClass, pClass);
|
||||
ForeachMethod(pAClass, pClass);
|
||||
|
||||
void* i_class{};
|
||||
void* iter{};
|
||||
do {
|
||||
if ((i_class = Invoke<void*>("il2cpp_class_get_interfaces", pClass, &iter))) {
|
||||
ForeachFields(pAClass, i_class);
|
||||
ForeachMethod(pAClass, i_class);
|
||||
}
|
||||
} while (i_class);
|
||||
return pAClass;
|
||||
}
|
||||
|
||||
static auto ForeachClass(Assembly* assembly, void* image) -> void {
|
||||
// 遍历类
|
||||
if (mode_ == Mode::Il2Cpp) {
|
||||
const auto count = Invoke<int>("il2cpp_image_get_class_count", image);
|
||||
if (!lazyInit_) UnityResolveProgress::classProgress.total = count;
|
||||
for (auto i = 0; i < count; i++) {
|
||||
if (!lazyInit_) UnityResolveProgress::classProgress.current = i + 1;
|
||||
const auto pClass = Invoke<void*>("il2cpp_image_get_class", image, i);
|
||||
if (pClass == nullptr) continue;
|
||||
const auto pAClass = new Class();
|
||||
|
@ -1471,6 +1393,25 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto ToWString() const -> std::u16string {
|
||||
#if WINDOWS_MODE
|
||||
if (IsBadReadPtr(this, sizeof(String))) return {};
|
||||
if (IsBadReadPtr(m_firstChar, m_stringLength)) return {};
|
||||
#endif
|
||||
if (!this) return {};
|
||||
try {
|
||||
// using convert_typeX = std::codecvt_utf8<wchar_t>;
|
||||
// std::wstring_convert<convert_typeX> converterX;
|
||||
// return converterX.to_bytes(m_firstChar);
|
||||
return {chars};
|
||||
}
|
||||
catch (std::exception& e) {
|
||||
std::cout << "String Invoke Error\n";
|
||||
GakumasLocal::Log::ErrorFmt("String Invoke Error: %s", e.what());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
auto operator=(const std::string& newString) const -> String* { return New(newString); }
|
||||
|
||||
auto operator==(const std::wstring& newString) const -> bool { return Equals(newString); }
|
||||
|
@ -2664,7 +2605,6 @@ public:
|
|||
private:
|
||||
inline static Mode mode_{};
|
||||
inline static void* hmodule_;
|
||||
inline static bool lazyInit_;
|
||||
inline static std::unordered_map<std::string, void*> address_{};
|
||||
inline static void* pDomain{};
|
||||
};
|
||||
|
|
|
@ -15,10 +15,6 @@ JavaVM* g_javaVM = nullptr;
|
|||
jclass g_gakumasHookMainClass = nullptr;
|
||||
jmethodID showToastMethodId = nullptr;
|
||||
|
||||
bool UnityResolveProgress::startInit = false;
|
||||
UnityResolveProgress::Progress UnityResolveProgress::assembliesProgress{};
|
||||
UnityResolveProgress::Progress UnityResolveProgress::classProgress{};
|
||||
|
||||
namespace
|
||||
{
|
||||
class AndroidHookInstaller : public GakumasLocal::HookInstaller
|
||||
|
@ -118,37 +114,8 @@ Java_io_github_chinosk_gakumas_localify_GakumasHookMain_loadConfig(JNIEnv *env,
|
|||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jint JNICALL
|
||||
JNIEXPORT void JNICALL
|
||||
Java_io_github_chinosk_gakumas_localify_GakumasHookMain_pluginCallbackLooper(JNIEnv *env,
|
||||
jclass clazz) {
|
||||
GakumasLocal::Log::ToastLoop(env, clazz);
|
||||
|
||||
if (UnityResolveProgress::startInit) {
|
||||
return 9;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_io_github_chinosk_gakumas_localify_models_NativeInitProgress_pluginInitProgressLooper(
|
||||
JNIEnv *env, jclass clazz, jobject progress) {
|
||||
|
||||
// jclass progressClass = env->GetObjectClass(progress);
|
||||
|
||||
static jfieldID startInitFieldID = env->GetStaticFieldID(clazz, "startInit", "Z");
|
||||
|
||||
static jmethodID setAssembliesProgressDataMethodID = env->GetMethodID(clazz, "setAssembliesProgressData", "(JJ)V");
|
||||
static jmethodID setClassProgressDataMethodID = env->GetMethodID(clazz, "setClassProgressData", "(JJ)V");
|
||||
|
||||
// jboolean startInit = env->GetStaticBooleanField(clazz, startInitFieldID);
|
||||
|
||||
env->SetStaticBooleanField(clazz, startInitFieldID, UnityResolveProgress::startInit);
|
||||
|
||||
env->CallVoidMethod(progress, setAssembliesProgressDataMethodID,
|
||||
UnityResolveProgress::assembliesProgress.current, UnityResolveProgress::assembliesProgress.total);
|
||||
env->CallVoidMethod(progress, setClassProgressDataMethodID,
|
||||
UnityResolveProgress::classProgress.current, UnityResolveProgress::classProgress.total);
|
||||
|
||||
}
|
|
@ -17,7 +17,6 @@ interface ConfigListener {
|
|||
fun onForceExportResourceChanged(value: Boolean)
|
||||
fun onTextTestChanged(value: Boolean)
|
||||
fun onReplaceFontChanged(value: Boolean)
|
||||
fun onLazyInitChanged(value: Boolean)
|
||||
fun onEnableFreeCameraChanged(value: Boolean)
|
||||
fun onTargetFpsChanged(s: CharSequence, start: Int, before: Int, count: Int)
|
||||
fun onUnlockAllLiveChanged(value: Boolean)
|
||||
|
@ -112,11 +111,6 @@ interface ConfigUpdateListener: ConfigListener, IHasConfigItems {
|
|||
pushKeyEvent(KeyEvent(1145, 30))
|
||||
}
|
||||
|
||||
override fun onLazyInitChanged(value: Boolean) {
|
||||
config.lazyInit = value
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
override fun onTextTestChanged(value: Boolean) {
|
||||
config.textTest = value
|
||||
saveConfig()
|
||||
|
|
|
@ -34,9 +34,7 @@ import java.util.Locale
|
|||
import kotlin.system.measureTimeMillis
|
||||
import io.github.chinosk.gakumas.localify.hookUtils.FileHotUpdater
|
||||
import io.github.chinosk.gakumas.localify.mainUtils.json
|
||||
import io.github.chinosk.gakumas.localify.models.NativeInitProgress
|
||||
import io.github.chinosk.gakumas.localify.models.ProgramConfig
|
||||
import io.github.chinosk.gakumas.localify.ui.game_attach.InitProgressUI
|
||||
|
||||
val TAG = "GakumasLocalify"
|
||||
|
||||
|
@ -51,7 +49,6 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
|
||||
private var getConfigError: Exception? = null
|
||||
private var externalFilesChecked: Boolean = false
|
||||
private var gameActivity: Activity? = null
|
||||
|
||||
override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
|
||||
// if (lpparam.packageName == "io.github.chinosk.gakumas.localify") {
|
||||
|
@ -138,7 +135,6 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
super.beforeHookedMethod(param)
|
||||
Log.d(TAG, "onStart")
|
||||
val currActivity = param.thisObject as Activity
|
||||
gameActivity = currActivity
|
||||
if (getConfigError != null) {
|
||||
showGetConfigFailed(currActivity)
|
||||
}
|
||||
|
@ -152,7 +148,6 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
Log.d(TAG, "onResume")
|
||||
val currActivity = param.thisObject as Activity
|
||||
gameActivity = currActivity
|
||||
if (getConfigError != null) {
|
||||
showGetConfigFailed(currActivity)
|
||||
}
|
||||
|
@ -211,30 +206,9 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
private fun startLoop() {
|
||||
GlobalScope.launch {
|
||||
val interval = 1000L / 30
|
||||
var lastFrameStartInit = NativeInitProgress.startInit
|
||||
val initProgressUI = InitProgressUI()
|
||||
|
||||
while (isActive) {
|
||||
val timeTaken = measureTimeMillis {
|
||||
val returnValue = pluginCallbackLooper() // plugin main thread loop
|
||||
if (returnValue == 9) {
|
||||
NativeInitProgress.startInit = true
|
||||
}
|
||||
|
||||
if (NativeInitProgress.startInit) { // if init, update data
|
||||
NativeInitProgress.pluginInitProgressLooper(NativeInitProgress)
|
||||
gameActivity?.let { initProgressUI.updateData(it) }
|
||||
}
|
||||
|
||||
if ((gameActivity != null) && (lastFrameStartInit != NativeInitProgress.startInit)) { // change status
|
||||
if (NativeInitProgress.startInit) {
|
||||
initProgressUI.createView(gameActivity!!)
|
||||
}
|
||||
else {
|
||||
initProgressUI.finishLoad(gameActivity!!)
|
||||
}
|
||||
}
|
||||
lastFrameStartInit = NativeInitProgress.startInit
|
||||
pluginCallbackLooper()
|
||||
}
|
||||
delay(interval - timeTaken)
|
||||
}
|
||||
|
@ -439,7 +413,7 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
|
|||
}
|
||||
|
||||
@JvmStatic
|
||||
external fun pluginCallbackLooper(): Int
|
||||
external fun pluginCallbackLooper()
|
||||
}
|
||||
|
||||
init {
|
||||
|
|
|
@ -6,7 +6,6 @@ import kotlinx.serialization.Serializable
|
|||
data class GakumasConfig (
|
||||
var dbgMode: Boolean = false,
|
||||
var enabled: Boolean = true,
|
||||
var lazyInit: Boolean = true,
|
||||
var replaceFont: Boolean = true,
|
||||
var textTest: Boolean = false,
|
||||
var dumpText: Boolean = false,
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
package io.github.chinosk.gakumas.localify.models
|
||||
|
||||
data class ProgressData(
|
||||
var current: Long = 0,
|
||||
var total: Long = 1
|
||||
)
|
||||
|
||||
object NativeInitProgress {
|
||||
var assembliesProgress = ProgressData()
|
||||
var classProgress = ProgressData()
|
||||
var startInit: Boolean = false
|
||||
|
||||
fun setAssembliesProgressData(current: Long, total: Long) {
|
||||
assembliesProgress.current = current
|
||||
assembliesProgress.total = total
|
||||
}
|
||||
|
||||
fun setClassProgressData(current: Long, total: Long) {
|
||||
classProgress.current = current
|
||||
classProgress.total = total
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
external fun pluginInitProgressLooper(progress: NativeInitProgress)
|
||||
}
|
|
@ -1,176 +0,0 @@
|
|||
package io.github.chinosk.gakumas.localify.ui.game_attach
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.graphics.drawable.shapes.RectShape
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import io.github.chinosk.gakumas.localify.TAG
|
||||
import io.github.chinosk.gakumas.localify.models.NativeInitProgress
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class InitProgressUI {
|
||||
private var uiCreated = false
|
||||
private lateinit var rootView: ViewGroup
|
||||
private lateinit var container: LinearLayout
|
||||
private lateinit var assembliesProgressBar: ProgressBar
|
||||
private lateinit var classProgressBar: ProgressBar
|
||||
private lateinit var titleText: TextView
|
||||
private lateinit var assembliesProgressText: TextView
|
||||
private lateinit var classProgressText: TextView
|
||||
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun createView(context: Context) {
|
||||
if (uiCreated) return
|
||||
uiCreated = true
|
||||
val activity = context as? Activity ?: return
|
||||
rootView = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||
|
||||
container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
marginEnd = 20
|
||||
marginStart = 20
|
||||
topMargin = 100
|
||||
}
|
||||
setBackgroundColor(Color.WHITE)
|
||||
setPadding(20, 20, 20, 20)
|
||||
}
|
||||
|
||||
// Set up the container layout
|
||||
assembliesProgressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = 20
|
||||
}
|
||||
max = 100
|
||||
}
|
||||
|
||||
// Set up the class progress bar
|
||||
classProgressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = 20
|
||||
}
|
||||
max = 100
|
||||
}
|
||||
|
||||
assembliesProgressBar.progressTintList = ColorStateList.valueOf(Color.parseColor("#FFF89400"))
|
||||
classProgressBar.progressTintList = ColorStateList.valueOf(Color.parseColor("#FFF89400"))
|
||||
|
||||
// Set up the text views
|
||||
titleText = TextView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = 20
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
setTextColor(Color.BLACK)
|
||||
text = "Initializing"
|
||||
textSize = 20f
|
||||
setTypeface(typeface, Typeface.BOLD)
|
||||
}
|
||||
|
||||
val textLayout = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = 20
|
||||
}
|
||||
|
||||
assembliesProgressText = TextView(context).apply {
|
||||
layoutParams = textLayout
|
||||
setTextColor(Color.BLACK)
|
||||
}
|
||||
|
||||
classProgressText = TextView(context).apply {
|
||||
layoutParams = textLayout
|
||||
setTextColor(Color.BLACK)
|
||||
}
|
||||
|
||||
// Add container to the root view
|
||||
context.runOnUiThread {
|
||||
// Add views to the container
|
||||
container.addView(titleText)
|
||||
container.addView(assembliesProgressText)
|
||||
container.addView(assembliesProgressBar)
|
||||
container.addView(classProgressText)
|
||||
container.addView(classProgressBar)
|
||||
|
||||
rootView.addView(container)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun finishLoad(context: Activity) {
|
||||
if (!uiCreated) return
|
||||
uiCreated = false
|
||||
GlobalScope.launch {
|
||||
context.runOnUiThread {
|
||||
assembliesProgressBar.progressTintList = ColorStateList.valueOf(Color.parseColor("#FF28B463"))
|
||||
classProgressBar.progressTintList = ColorStateList.valueOf(Color.parseColor("#FF28B463"))
|
||||
titleText.text = "Finished"
|
||||
}
|
||||
delay(1500L)
|
||||
context.runOnUiThread {
|
||||
rootView.removeView(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeView(context: Activity) {
|
||||
if (!uiCreated) return
|
||||
uiCreated = false
|
||||
context.runOnUiThread {
|
||||
rootView.removeView(container)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun updateData(context: Activity) {
|
||||
if (!uiCreated) return
|
||||
//return
|
||||
|
||||
context.runOnUiThread {
|
||||
val assembliesProgress = NativeInitProgress.assembliesProgress
|
||||
val classProgress = NativeInitProgress.classProgress
|
||||
|
||||
assembliesProgressText.text = "${assembliesProgress.current}/${assembliesProgress.total}"
|
||||
classProgressText.text = "${classProgress.current}/${classProgress.total}"
|
||||
|
||||
assembliesProgressBar.setProgress((assembliesProgress.current * 100 / assembliesProgress.total).toInt(), true)
|
||||
classProgressBar.setProgress((classProgress.current * 100 / classProgress.total).toInt(), true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -141,14 +141,9 @@ fun HomePage(modifier: Modifier = Modifier,
|
|||
v -> context?.onEnabledChanged(v)
|
||||
}
|
||||
|
||||
GakuSwitch(modifier, stringResource(R.string.lazy_init), checked = config.value.lazyInit) {
|
||||
v -> context?.onLazyInitChanged(v)
|
||||
}
|
||||
|
||||
GakuSwitch(modifier, stringResource(R.string.replace_font), checked = config.value.replaceFont) {
|
||||
v -> context?.onReplaceFontChanged(v)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
<string name="gakumas_localify">Gakumas Localify</string>
|
||||
<string name="enable_plugin">启用插件 (不可热重载)</string>
|
||||
<string name="replace_font">替换字体</string>
|
||||
<string name="lazy_init">快速初始化(懒加载配置)</string>
|
||||
<string name="enable_free_camera">启用自由视角(可热重载; 需使用实体键盘)</string>
|
||||
<string name="start_game">以上述配置启动游戏/重载配置</string>
|
||||
<string name="setFpsTitle">最大 FPS (0 为保持游戏原设置)</string>
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
<string name="gakumas_localify">Gakumas Localify</string>
|
||||
<string name="enable_plugin">Enable Plugin (Not Hot Reloadable)</string>
|
||||
<string name="replace_font">Replace Font</string>
|
||||
<string name="lazy_init">Fast Initialization (Lazy loading)</string>
|
||||
<string name="enable_free_camera">Enable Free Camera</string>
|
||||
<string name="start_game">Start Game / Hot Reload Config</string>
|
||||
<string name="setFpsTitle">Max FPS (0 is Use Original Settings)</string>
|
||||
|
|
Loading…
Reference in New Issue