diff --git a/resource/localizationConfig.json b/resource/localizationConfig.json index a8de5cd..ea05379 100644 --- a/resource/localizationConfig.json +++ b/resource/localizationConfig.json @@ -11,6 +11,10 @@ "gameOrientation": 0, "forceExportResource": false, "enableFreeCamera": false, + "enableGyroEmu": false, + "gyroSensitivity": 1.0, + "gyroInvertX": false, + "gyroInvertY": false, "targetFrameRate": 0, "unlockAllLive": true, "unlockAllLiveCostume": true, diff --git a/src/GakumasLocalify/GyroEmu.cpp b/src/GakumasLocalify/GyroEmu.cpp new file mode 100644 index 0000000..f29a625 --- /dev/null +++ b/src/GakumasLocalify/GyroEmu.cpp @@ -0,0 +1,62 @@ +#include "GyroEmu.hpp" +#include "config/Config.hpp" +#include +#include +#include + +namespace GakumasLocal::GyroEmu { + using Quaternion = UnityResolve::UnityType::Quaternion; + using Vector3 = UnityResolve::UnityType::Vector3; + + static float pitch = 0, yaw = 0, roll = 0; // 度,Unity 相机欧拉角 + static POINT lastCursor{}; + static bool dragging = false; + static auto lastTick = std::chrono::steady_clock::now(); + + static bool Down(int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; } + + void Reset() { pitch = yaw = roll = 0; } + + static void PollInput() { + const auto now = std::chrono::steady_clock::now(); + const float dt = (std::min)(std::chrono::duration(now - lastTick).count(), 0.1f); + lastTick = now; + + const float sx = Config::gyroSensitivity * (Config::gyroInvertX ? -1.f : 1.f); + const float sy = Config::gyroSensitivity * (Config::gyroInvertY ? -1.f : 1.f); + + // 右键拖动:鼠标位移 -> yaw/pitch + if (Down(VK_RBUTTON)) { + POINT cur; GetCursorPos(&cur); + if (dragging) { + yaw += (cur.x - lastCursor.x) * 0.1f * sx; + pitch += (cur.y - lastCursor.y) * 0.1f * sy; + } + lastCursor = cur; + dragging = true; + } else { + dragging = false; + } + + // 小键盘:7/9 翻滚, 5 复位。4/6/8/2 是游戏自己拍摄页的镜头键,不占用 + const float step = 60.f * dt * Config::gyroSensitivity; + if (Down(VK_NUMPAD7)) roll += step; + if (Down(VK_NUMPAD9)) roll -= step; + if (Down(VK_NUMPAD5)) Reset(); + + pitch = std::clamp(pitch, -89.f, 89.f); + } + + Quaternion Attitude() { + PollInput(); + Quaternion q; q.Euler(pitch, yaw, roll); + // 反汇编 VLGyro.get_gyroRotationRaw:raw = gyroBaseRotation * Quaternion(-a.x, -a.y, a.z, a.w); + // CampusGyroController.Rotation = Inverse(raw@reset) * raw,base 被消掉,所以直接把相机欧拉角编码进 a 即可。 + return Quaternion(-q.x, -q.y, q.z, q.w); + } + + Vector3 Gravity() { + // ponytail: 竖持手机时重力恒指向设备 -Y,VLGyro 没用到重力,够用 + return Vector3(0.f, -1.f, 0.f); + } +} diff --git a/src/GakumasLocalify/GyroEmu.hpp b/src/GakumasLocalify/GyroEmu.hpp new file mode 100644 index 0000000..b9e41c3 --- /dev/null +++ b/src/GakumasLocalify/GyroEmu.hpp @@ -0,0 +1,10 @@ +#pragma once +#include "../deps/UnityResolve/UnityResolve.hpp" + +// 用鼠标右键拖动 / 小键盘模拟手机陀螺仪姿态(PC 版无陀螺仪) +namespace GakumasLocal::GyroEmu { + // 读一次输入并返回当前 Input.gyro.attitude + UnityResolve::UnityType::Quaternion Attitude(); + UnityResolve::UnityType::Vector3 Gravity(); + void Reset(); +} diff --git a/src/GakumasLocalify/Hook.cpp b/src/GakumasLocalify/Hook.cpp index 7d2e294..5363f03 100644 --- a/src/GakumasLocalify/Hook.cpp +++ b/src/GakumasLocalify/Hook.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include "camera/camera.hpp" @@ -29,6 +30,7 @@ #ifdef GKMS_WINDOWS #include "../windowsPlatform.hpp" + #include "GyroEmu.hpp" #include "cpprest/details/http_helpers.h" #include "../resourceUpdate/resourceUpdate.hpp" #endif @@ -212,6 +214,191 @@ namespace GakumasLocal::HookMain { // 从游戏自己的调用里捕获真实对象指针: // Camera.get_main / Component.get_transform 是带 MethodInfo* 尾参的 managed 方法, // 用 hook(而不是 UnityResolve 的 managed Invoke)才能拿到可靠结果。 +#ifdef GKMS_WINDOWS + // 陀螺仪模拟(PC 无陀螺仪),三层: + // 1. GyroIcall —— 替换 il2cpp icall 缓存指针提供假读数。不能 hook 原生 icall(Windows 上它们是 return 常量 + // 的桩,被链接器 ICF 合并进 IsDebugBuild 等,实测 [Debug] 变 True、开场黑屏),也不能 hook + // 托管包装(PC 上被内联,实测一次不被调用)。 + // 2. GyroForceInit —— PC 把 IsPanoramaGyroDisabledOnPlatform() 内联成常量 true,控制器根本不创建,LateUpdate 里自己建。 + // 3. 两个方法体足够大、实测会被调用的托管 hook 放开 UI 闸门。 +#define GYRO_LOG_ONCE(name) { static bool logged = false; if (!logged) { logged = true; Log::InfoFmt("[GyroEmu] %s called (emu=%d)", name, (int)Config::enableGyroEmu); } } + + DEFINE_HOOK(void, LiveScenePanoramaContentPresenter_Initialize, (void* self, void* screenRoot, bool isGyroSupported, void* mtd)) { + GYRO_LOG_ONCE("PanoramaContentPresenter.Initialize"); + LiveScenePanoramaContentPresenter_Initialize_Orig(self, screenRoot, isGyroSupported || Config::enableGyroEmu, mtd); + } + + DEFINE_HOOK(bool, Photography3DPresenter_get_IsGyroSupported, (void* self, void* mtd)) { + GYRO_LOG_ONCE("Photography3DPresenter.IsGyroSupported"); + if (Config::enableGyroEmu) return true; + return Photography3DPresenter_get_IsGyroSupported_Orig(self, mtd); + } + + // PC 版把 LiveUtility.IsPanoramaGyroDisabledOnPlatform()(恒 true)内联成常量,CampusGyroController 的 InitializeAsync + // 被整段跳过(log 证实 IsGyroAvailable 从未被查询),IsGyroReady 永远 false。Live 与拍摄各持有一个控制器, + // 所以在构造函数 hook 里统一把它置成初始化完成态,再在两条链路各留一个“控制器为空就自己建”的兜底。 + // 字段偏移来自 3.3.0 il2cpp.h(CampusGyroController / VLGyro / LivePanoramaCameraManager)。 + namespace GyroForceInit { + using Quaternion = UnityResolve::UnityType::Quaternion; + template T& F(void* obj, size_t off) { return *reinterpret_cast(reinterpret_cast(obj) + off); } + + struct Api { + UnityResolve::Class* ctrlClass; UnityResolve::Class* gyroClass; + void (*ctrlCtor)(void*, void*); void (*gyroCtor)(void*, void*); void (*gyroReset)(void*, void*); + Quaternion* (*gyroRaw)(Quaternion*, void*, void*); + }; + static const Api* GetApi() { + static Api api{ + Il2cppUtils::GetClass("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController"), + Il2cppUtils::GetClass("vl-unity.Runtime.dll", "VL", "VLGyro"), + reinterpret_cast(Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController", ".ctor")), + reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", ".ctor")), + reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "ResetRotation")), + reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "get_gyroRotationRaw")), + }; + static const bool ok = [] { + if (api.ctrlClass && api.gyroClass && api.ctrlCtor && api.gyroCtor && api.gyroReset && api.gyroRaw) return true; + Log::ErrorFmt("[GyroEmu] force-init: missing class/method (%p %p %p %p %p %p)", api.ctrlClass, api.gyroClass, api.ctrlCtor, api.gyroCtor, api.gyroReset, api.gyroRaw); + return false; + }(); + return ok ? &api : nullptr; + } + + // 把一个已构造的 CampusGyroController 直接置成“初始化完成”态(等价于它自己 InitializeAsync 成功后的状态) + void InitController(void* ctrl) { + auto api = GetApi(); + if (!api || !ctrl || F(ctrl, 0x18)) return; // _initialized + const Quaternion identity(0, 0, 0, 1); + auto& gyro = F(ctrl, 0x10); // _gyro + if (!gyro) { gyro = api->gyroClass->New(); api->gyroCtor(gyro, nullptr); } + F(gyro, 0x14) = 1; // _updateType = Gyro + F(gyro, 0x29) = true; // gyroSupported + F(gyro, 0x2A) = true; // _defaultGyroEnable + for (size_t off : {0x2C, 0x3C, 0x4C, 0x5C}) F(gyro, off) = identity; + F(gyro, 0x6C) = true; // _initialized + F(gyro, 0x6D) = true; // _firstUpdate + api->gyroReset(gyro, nullptr); + Quaternion raw; api->gyroRaw(&raw, gyro, nullptr); + F(ctrl, 0x1C) = Quaternion(-raw.x, -raw.y, -raw.z, raw.w); // _baseRotation = Inverse(raw) + F(ctrl, 0x2C) = INT_MIN; // _lastAccessFrame + F(ctrl, 0x30) = identity; // _rotation + F(ctrl, 0x18) = true; // _initialized + Log::InfoFmt("[GyroEmu] force-init done: controller %p gyro %p", ctrl, gyro); + } + + void* NewController() { + auto api = GetApi(); + if (!api) return nullptr; + auto ctrl = api->ctrlClass->New(); + api->ctrlCtor(ctrl, nullptr); // 构造函数 hook 会顺手 InitController + InitController(ctrl); + return ctrl; + } + } + + // 所有 CampusGyroController 一出生就初始化完成,Live / 拍摄 / 其他持有者都不用再关心 PC 上被跳过的 InitializeAsync + DEFINE_HOOK(void, CampusGyroController_ctor, (void* self, void* mtd)) { + CampusGyroController_ctor_Orig(self, mtd); + if (Config::enableGyroEmu) GyroForceInit::InitController(self); + } + + // Live:PC 版可能连控制器都没建,LateUpdate 里兜底 + DEFINE_HOOK(void, LivePanoramaCameraManager_LateUpdate, (void* self, void* mtd)) { + if (Config::enableGyroEmu && self) { + auto& ctrl = GyroForceInit::F(self, 0x60); // _gyroController + if (!ctrl) ctrl = GyroForceInit::NewController(); + else GyroForceInit::InitController(ctrl); + } + LivePanoramaCameraManager_LateUpdate_Orig(self, mtd); + } + + // 拍摄:PhotographySceneManager 把控制器交给 PhotographyActivityController,这里是它进相机控制器的唯一入口 + DEFINE_HOOK(void, PhotographyActivityController_Initialize, (void* self, void* volumeProfile, void* gyroController, float arg, void* mtd)) { + if (Config::enableGyroEmu) { + if (!gyroController) gyroController = GyroForceInit::NewController(); + else GyroForceInit::InitController(gyroController); + } + PhotographyActivityController_Initialize_Orig(self, volumeProfile, gyroController, arg, mtd); + } + // PC 版 MSVC 把 SystemInfo.supportsGyroscope / Gyroscope.* 这些托管包装内联进了调用方,hook 函数本体拦不到。 + // il2cpp 的 icall 包装里有一个 static 缓存指针(_il2cpp_icall_func),内联后所有副本共用同一个变量, + // 直接把这个变量改成我们的函数即可,不碰任何机器码,也不受链接器 ICF 合并影响。 + namespace GyroIcall { + using Quaternion = UnityResolve::UnityType::Quaternion; + using Vector3 = UnityResolve::UnityType::Vector3; + bool (*orig_IsGyroAvailable)() = nullptr; + bool (*orig_getEnabled)(int) = nullptr; + void (*orig_setEnabled)(int, bool) = nullptr; + void (*orig_attitude)(int, Quaternion*) = nullptr; + void (*orig_gravity)(int, Vector3*) = nullptr; + + bool IsGyroAvailable() { + GYRO_LOG_ONCE("icall SystemInfo.IsGyroAvailable"); + if (Config::enableGyroEmu) return true; + return orig_IsGyroAvailable ? orig_IsGyroAvailable() : false; + } + bool getEnabled(int idx) { + GYRO_LOG_ONCE("icall Gyroscope.getEnabled"); + if (Config::enableGyroEmu) return true; + return orig_getEnabled ? orig_getEnabled(idx) : false; + } + void setEnabled(int idx, bool v) { + GYRO_LOG_ONCE("icall Gyroscope.setEnabled"); + if (Config::enableGyroEmu) return; // Windows 无传感器对象,原生实现解空指针 + if (orig_setEnabled) orig_setEnabled(idx, v); + } + void attitude(int idx, Quaternion* ret) { + GYRO_LOG_ONCE("icall Gyroscope.attitude"); + if (Config::enableGyroEmu) { *ret = GyroEmu::Attitude(); return; } + if (orig_attitude) orig_attitude(idx, ret); else *ret = Quaternion(0, 0, 0, 1); + } + void gravity(int idx, Vector3* ret) { + GYRO_LOG_ONCE("icall Gyroscope.gravity"); + if (Config::enableGyroEmu) { *ret = GyroEmu::Gravity(); return; } + if (orig_gravity) orig_gravity(idx, ret); else *ret = Vector3(0, 0, 0); + } + + // 在 icall 包装函数体里找 `mov rXX,[rip+disp]; test rXX,rXX` 定位缓存变量,写入替换函数 + template + void Patch(const char* label, void* wrapper, F** origOut, F* replacement, const char* icallName) { + *origOut = reinterpret_cast(Il2cppUtils::il2cpp_resolve_icall(icallName)); + if (!wrapper) { Log::ErrorFmt("[GyroEmu] %s: wrapper not found", label); return; } + auto p = reinterpret_cast(wrapper); + for (int i = 0; i < 0x80; i++) { + if (p[i] != 0x48 || p[i + 1] != 0x8B || (p[i + 2] & 0xC7) != 0x05) continue; + const int reg = (p[i + 2] >> 3) & 7; + const uint8_t testModrm = 0xC0 | (reg << 3) | reg; + bool ok = false; + for (int j = i + 7; j <= i + 7 + 8; j++) + if (p[j] == 0x48 && p[j + 1] == 0x85 && p[j + 2] == testModrm) { ok = true; break; } + if (!ok) continue; + auto cache = reinterpret_cast(p + i + 7 + *reinterpret_cast(p + i + 3)); + MEMORY_BASIC_INFORMATION mbi{}; + if (!VirtualQuery(cache, &mbi, sizeof(mbi)) || !(mbi.Protect & (PAGE_READWRITE | PAGE_WRITECOPY))) { + Log::ErrorFmt("[GyroEmu] %s: cache %p not writable", label, cache); return; + } + Log::InfoFmt("[GyroEmu] %s: icall cache %p (was %p, orig %p) -> %p", label, cache, *cache, *origOut, replacement); + *cache = reinterpret_cast(replacement); + return; + } + Log::ErrorFmt("[GyroEmu] %s: icall cache pattern not found at %p", label, wrapper); + } + + void Install() { + Patch("IsGyroAvailable", Il2cppUtils::GetMethodPointer("UnityEngine.CoreModule.dll", "UnityEngine", "SystemInfo", "IsGyroAvailable"), + &orig_IsGyroAvailable, &IsGyroAvailable, "UnityEngine.SystemInfo::IsGyroAvailable()"); + Patch("getEnabled_Internal", Il2cppUtils::GetMethodPointer("UnityEngine.InputLegacyModule.dll", "UnityEngine", "Gyroscope", "getEnabled_Internal"), + &orig_getEnabled, &getEnabled, "UnityEngine.Gyroscope::getEnabled_Internal(System.Int32)"); + Patch("setEnabled_Internal", Il2cppUtils::GetMethodPointer("UnityEngine.InputLegacyModule.dll", "UnityEngine", "Gyroscope", "setEnabled_Internal"), + &orig_setEnabled, &setEnabled, "UnityEngine.Gyroscope::setEnabled_Internal(System.Int32,System.Boolean)"); + Patch("attitude_Internal_Injected", Il2cppUtils::GetMethodPointer("UnityEngine.InputLegacyModule.dll", "UnityEngine", "Gyroscope", "attitude_Internal_Injected"), + &orig_attitude, &attitude, "UnityEngine.Gyroscope::attitude_Internal_Injected(System.Int32,UnityEngine.Quaternion&)"); + Patch("gravity_Internal_Injected", Il2cppUtils::GetMethodPointer("UnityEngine.InputLegacyModule.dll", "UnityEngine", "Gyroscope", "gravity_Internal_Injected"), + &orig_gravity, &gravity, "UnityEngine.Gyroscope::gravity_Internal_Injected(System.Int32,UnityEngine.Vector3&)"); + } + } +#endif + DEFINE_HOOK(UnityResolve::UnityType::Camera*, Camera_get_main, (void* mtd)) { auto ret = Camera_get_main_Orig(mtd); if (Config::enableFreeCamera && ret && ret != mainCameraCache) { @@ -869,7 +1056,16 @@ namespace GakumasLocal::HookMain { } #endif - std::unordered_set updatedFontPtrs{}; + // A managed object address can be reused after the original object is collected, and the + // replacement Font is dropped by Resources.UnloadUnusedAssets on scene loads. Pair the address + // with both InstanceIDs so a recycled address or a reloaded Font is set up again - and so the + // map is never cleared wholesale, which would re-initialize every live asset and reshuffle the + // atlas under text that is already on screen. + struct UpdatedFontState { + int fontAssetInstanceId; + int sourceFontInstanceId; + }; + std::unordered_map updatedFontStates{}; void UpdateFont(void* TMP_Textself) { if (!Config::replaceFont) return; static auto get_font = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", @@ -888,8 +1084,39 @@ namespace GakumasLocal::HookMain { static auto set_sourceFontFile = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "set_sourceFontFile"); - static auto UpdateFontAssetData = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", "TMPro", - "TMP_FontAsset", "UpdateFontAssetData"); + static auto set_atlasPopulationMode = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "set_atlasPopulationMode", + { "TMPro.AtlasPopulationMode" }); + static auto set_isMultiAtlasTexturesEnabled = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "set_isMultiAtlasTexturesEnabled", + { "System.Boolean" }); + static auto ClearFontAssetData = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "ClearFontAssetData", + { "System.Boolean" }); + static auto get_atlasWidth = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", "TMPro", + "TMP_FontAsset", "get_atlasWidth"); + static auto set_atlasWidth = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "set_atlasWidth", + { "System.Int32" }); + static auto set_atlasHeight = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMP_FontAsset", "set_atlasHeight", + { "System.Int32" }); + static auto get_atlasTextureCount = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", "TMPro", + "TMP_FontAsset", "get_atlasTextureCount"); + static auto GetInstanceID = Il2cppUtils::GetMethod( + "UnityEngine.CoreModule.dll", "UnityEngine", "Object", "GetInstanceID"); + // TMP's own "this font asset changed, re-layout everything using it" broadcast. + static auto ON_FONT_PROPERTY_CHANGED = Il2cppUtils::GetMethod( + "Unity.TextMeshPro.dll", "TMPro", "TMPro_EventManager", "ON_FONT_PROPERTY_CHANGED", + { "System.Boolean", "UnityEngine.Object" }); + + if (!get_font || !set_font || !get_name || !set_sourceFontFile || !set_atlasPopulationMode || + !set_isMultiAtlasTexturesEnabled || !ClearFontAssetData || !get_atlasWidth || + !set_atlasWidth || !set_atlasHeight || !get_atlasTextureCount || !GetInstanceID || + !ON_FONT_PROPERTY_CHANGED) { + Log::Error("UpdateFont disabled: a TMP_FontAsset method could not be resolved"); + return; + } auto fontAsset = get_font->Invoke(TMP_Textself); if (!fontAsset) { @@ -910,13 +1137,61 @@ namespace GakumasLocal::HookMain { auto newFont = GetReplaceFont(); if (!newFont) return; - set_sourceFontFile->Invoke(fontAsset, newFont); - if (!updatedFontPtrs.contains(fontAsset)) { - updatedFontPtrs.emplace(fontAsset); - UpdateFontAssetData->Invoke(fontAsset); + const auto fontInstanceId = GetInstanceID->Invoke(fontAsset); + const auto sourceFontInstanceId = GetInstanceID->Invoke(newFont); + const auto known = updatedFontStates.find(fontAsset); + const bool isNewAsset = known == updatedFontStates.end() || + known->second.fontAssetInstanceId != fontInstanceId; + const bool sourceFontReloaded = !isNewAsset && + known->second.sourceFontInstanceId != sourceFontInstanceId; + if (isNewAsset || sourceFontReloaded) { + // Mark the asset before rebuilding it: TMP can re-enter one of the text hooks below + // while the rebuild is in progress. + updatedFontStates.insert_or_assign( + fontAsset, UpdatedFontState{ fontInstanceId, sourceFontInstanceId }); + set_sourceFontFile->Invoke(fontAsset, newFont); } - if (updatedFontPtrs.size() > 200) updatedFontPtrs.clear(); - + // Only a genuinely new asset is rebuilt. Resources.UnloadUnusedAssets drops the replacement + // Font on most scene loads, and re-clearing on every reload throws away glyphs that text + // already on screen is still pointing at - it keeps its stale atlas coordinates and renders + // the wrong characters, or nothing at all once the atlas is no longer refilled. The glyphs + // already in the atlas came from an identical font file, so a reloaded Font can simply be + // re-assigned without touching them. + if (isNewAsset) { + set_atlasPopulationMode->Invoke(fontAsset, 1); // AtlasPopulationMode.Dynamic + set_isMultiAtlasTexturesEnabled->Invoke(fontAsset, true); + + // Glyphs that do not fit on atlas page 0 get a second material, which puts them in + // textInfo.meshInfo[1]. The game's stylized text only submits meshInfo[0], so those + // characters vanish - correct advance, no quad - while ordinary UI still draws them. + // Measured from a frame capture: shelf pitch 79px, ~735 glyphs per 2048x2048 page, and + // page 0 was completely full, so the spill was guaranteed rather than occasional. + // 4096x4096 holds ~2900 glyphs (16MB of A8); 20 minutes of play reaches about a third. + // ponytail: still a ceiling. The atlasTextureCount check below reports when it is hit; + // the real fix is making the stylized text submit its sub meshes. + if (get_atlasWidth->Invoke(fontAsset) < 4096) { + set_atlasWidth->Invoke(fontAsset, 4096); + set_atlasHeight->Invoke(fontAsset, 4096); + } + // Clear, not UpdateFontAssetData: the latter re-adds the asset's entire original + // character table first, spending page 0 on glyphs the translation never shows. + ClearFontAssetData->Invoke(fontAsset, false); + // Clearing invalidates the atlas coordinates baked into every mesh already generated + // from this asset. Without this broadcast those texts keep the stale coordinates: the + // game's ruby text objects, laid out before the rebuild, showed wrong glyphs when the + // old character table was re-added and nothing at all once it was not. + ON_FONT_PROPERTY_CHANGED->Invoke(true, fontAsset); + } + + static int reportedAtlasPages = 1; + const auto atlasPages = get_atlasTextureCount->Invoke(fontAsset); + if (atlasPages > reportedAtlasPages) { + reportedAtlasPages = atlasPages; + Log::ErrorFmt("Font atlas spilled to page %d on [%s] - stylized text will start " + "dropping characters", + atlasPages - 1, fontAssetName ? fontAssetName->ToString().c_str() : "?"); + } + set_font->Invoke(TMP_Textself, fontAsset); // auto fontMaterial = get_material->Invoke(fontAsset); @@ -1224,10 +1499,21 @@ namespace GakumasLocal::HookMain { return ret; }*/ + // 名片编辑页的 P 偶像页签用 UserIdolCardSkinList.Exists(skin.Id) 过滤“拥有的皮肤”,随后按底卡去 + // UserIdolCardList 查 UserIdolCard 取 IllustrationStage。下面 Exists 对所有 i_card-skin-* 返回 true 会把 + // 没有底卡的皮肤也放进去,查到 null 直接 NRE,整个界面初始化中断(表现为卡死)。该模型构造期间放行真实结果。 + static bool meishiModelConstructing = false; + DEFINE_HOOK(void, MeishiBasePIdolTabPageModel_ctor, (void* self, void* mtd)) { + meishiModelConstructing = true; + MeishiBasePIdolTabPageModel_ctor_Orig(self, mtd); + meishiModelConstructing = false; + } + DEFINE_HOOK(bool, UserIdolCardSkinCollection_Exists, (void* self, Il2cppString* id, void* mtd)) { // Live默认选择 auto ret = UserIdolCardSkinCollection_Exists_Orig(self, id, mtd); // Log::DebugFmt("UserIdolCardSkinCollection_Exists: %s, ret: %d", id->ToString().c_str(), ret); if (!(Config::dbgMode && Config::unlockAllLive)) return ret; + if (meishiModelConstructing) return ret; if (id) { std::string idStr = id->ToString(); @@ -1724,6 +2010,75 @@ namespace GakumasLocal::HookMain { return AddPhotographyIdolSkinItems(ret, characterId ? characterId->ToString() : "", getItem, getId); } + // ---- 技能卡皮肤(ProduceCardSkin):没有专属皮也播专属皮的演出 ---- + // 考试内所有皮肤表现都由 ExamCardData.ProduceCardSkinId 派生: + // PrePlayCardAsync -> PlayCutInAsync(skinId) -> TryGetCutInParams -> mov_{id}_skillcard.usm + cutInType + // ExamCardButtonImage.SetCard -> GetThumbnailLoopMovieName(skinId) -> 手牌循环缩略图 + // 所以在这个 getter 上,把「没挂皮肤」的卡补成主表里同 ProduceCardId、CutInType 最高的专属皮即可。 + // 注意:该 getter 另有 ExamDataSerializeData / ExamParameterModel 两个读取方, + // 伪造的 id 会进考试状态序列化和 play log(竞赛回放会上传),纯本地表现之外的副作用仅此。 + std::unordered_map produceCardSkinCutInRemap{}; // produceCardId -> 专属皮 id + bool produceCardSkinCutInRemapBuilt = false; + + void BuildProduceCardSkinCutInRemap() { + if (produceCardSkinCutInRemapBuilt) return; + static auto getMaster = Il2cppUtils::GetMethod("Assembly-CSharp.dll", "Campus.Common.Master", "MasterManager", "get_ProduceCardSkinMaster"); + static auto masterKlass = Il2cppUtils::GetClass("Assembly-CSharp.dll", "Campus.Common.Master", "ProduceCardSkinMaster"); + static auto getId = Il2cppUtils::GetMethod("Assembly-CSharp.dll", "Campus.Common.Proto.Client.Master", "ProduceCardSkin", "get_Id"); + static auto getCardId = Il2cppUtils::GetMethod("Assembly-CSharp.dll", "Campus.Common.Proto.Client.Master", "ProduceCardSkin", "get_ProduceCardId"); + static auto getCutInType = Il2cppUtils::GetMethod("Assembly-CSharp.dll", "Campus.Common.Proto.Client.Master", "ProduceCardSkin", "get_CutInType"); + if (!getMaster || !masterKlass || !getId || !getCardId || !getCutInType) return; + // 取 1 参重载 GetAllWithSortByKey(sortType),末尾补隐藏 MethodInfo* + static auto getAll_mtd = Il2cppUtils::il2cpp_class_get_method_from_name(masterKlass->address, "GetAllWithSortByKey", 1); + if (!getAll_mtd) return; + auto getAll = reinterpret_cast* (*)(void*, int, void*)>(getAll_mtd->methodPointer); + + auto master = getMaster->Invoke(nullptr); + if (!master) return; + auto list = getAll(master, 0, (void*)getAll_mtd); + if (!list) return; + + // produceCardId -> (最高 cutInType, 皮肤 id)。没有 cut-in 的皮抄过来也没演出,直接跳过 + std::unordered_map> best{}; + int valid = 0, withCutIn = 0; + for (auto item : list->ToArray()->ToVector()) { + if (!item) continue; // ToVector 含 List 容量内的 null 槽位 + auto id = getId->Invoke(item); + auto cardId = getCardId->Invoke(item); + if (!id || !cardId) continue; + ++valid; + // ProduceCardSkinCutInType: Unknown=0 / Small=1 / Mid=2 / Grand=3 + const int cutIn = getCutInType->Invoke(item); + if (cutIn <= 0) continue; + ++withCutIn; + auto& slot = best[cardId->ToString()]; + if (cutIn > slot.first) slot = { cutIn, id->ToString() }; + } + if (!valid) return; // 主表还没加载,下次再建 + + for (const auto& [cardId, slot] : best) produceCardSkinCutInRemap.emplace(cardId, slot.second); + produceCardSkinCutInRemapBuilt = true; + Log::InfoFmt("ProduceCardSkin cut-in remap built: %zu cards (rows=%d, withCutIn=%d)", + produceCardSkinCutInRemap.size(), valid, withCutIn); + } + + // 卡本身没挂皮肤时,按 produceCardId 补一个带 cut-in 的皮肤 id; + // 已经挂了皮肤的走原样,不覆盖玩家自己的选择 + DEFINE_HOOK(Il2cppString*, ExamCardData_get_ProduceCardSkinId, (void* self, void* mtd)) { + auto ret = ExamCardData_get_ProduceCardSkinId_Orig(self, mtd); + if (!Config::dbgMode) return ret; + if (ret && !ret->ToString().empty()) return ret; + + static auto ExamCardData_get_Id = Il2cppUtils::GetMethod("Assembly-CSharp.dll", "Campus.InGame.Card", "ExamCardData", "get_Id"); + if (!ExamCardData_get_Id) return ret; + auto cardId = ExamCardData_get_Id->Invoke(self); + if (!cardId) return ret; + + BuildProduceCardSkinCutInRemap(); + auto it = produceCardSkinCutInRemap.find(cardId->ToString()); + return it == produceCardSkinCutInRemap.end() ? ret : Il2cppString::New(it->second); + } + void* getCompletedUniTask() { static auto unitask_klass = Il2cppUtils::GetClass("UniTask.dll", "Cysharp.Threading.Tasks", "UniTask"); static auto CompletedTask_field = unitask_klass->Get("CompletedTask"); @@ -2480,6 +2835,11 @@ namespace GakumasLocal::HookMain { "ExchangeSpToNormal", { "Campus.Common.Proto.Client.Enums.ProduceStepType" })); + ADD_HOOK(ExamCardData_get_ProduceCardSkinId, + Il2cppUtils::GetMethodPointer( + "Assembly-CSharp.dll", "Campus.InGame.Card", "ExamCardData", + "get_ProduceCardSkinId")); + /* // 此 block 为 MasterBase 相关的 hook,后来发现它们最后都会调用 MessageExtensions.MergeFrom 进行构造,遂停用。现留档以备用 // ADD_HOOK(MasterBase_GetAll, Il2cppUtils::GetMethodPointer("quaunity-master-manager.Runtime.dll", "Qua.Master", // "MasterBase`2", "GetAll", {"*", "*", "*", "*", "*"})); @@ -2533,6 +2893,8 @@ namespace GakumasLocal::HookMain { if (UserIdolCardSkinCollection_Exists_mtd) { ADD_HOOK(UserIdolCardSkinCollection_Exists, UserIdolCardSkinCollection_Exists_mtd->methodPointer); } + ADD_HOOK(MeishiBasePIdolTabPageModel_ctor, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Campus.OutGame", + "MeishiBasePIdolTabPageModel", ".ctor")); auto UserCostumeCollection_klass = Il2cppUtils::GetClass("Assembly-CSharp.dll", "Campus.Common.User", "UserCostumeCollection"); @@ -2728,6 +3090,18 @@ namespace GakumasLocal::HookMain { "RenderPipeline", "EndCameraRendering")); #ifdef GKMS_WINDOWS + ADD_HOOK(LiveScenePanoramaContentPresenter_Initialize, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Campus.Live", + "LiveScenePanoramaContentPresenter", "Initialize")); + ADD_HOOK(Photography3DPresenter_get_IsGyroSupported, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Campus.Photography", + "Photography3DPresenter", "get_IsGyroSupported")); + GyroIcall::Install(); + ADD_HOOK(LivePanoramaCameraManager_LateUpdate, Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Live", + "LivePanoramaCameraManager", "LateUpdate")); + ADD_HOOK(CampusGyroController_ctor, Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Common", + "CampusGyroController", ".ctor")); + ADD_HOOK(PhotographyActivityController_Initialize, Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Photography", + "PhotographyActivityController", "Initialize")); + ADD_HOOK(WindowHandle_SetWindowLong, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Campus.Common.StandAloneWindow", "WindowHandle", "SetWindowLong")); //ADD_HOOK(WindowHandle_SetWindowLong32, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Campus.Common.StandAloneWindow", diff --git a/src/GakumasLocalify/config/Config.cpp b/src/GakumasLocalify/config/Config.cpp index f1bacea..3c11592 100644 --- a/src/GakumasLocalify/config/Config.cpp +++ b/src/GakumasLocalify/config/Config.cpp @@ -19,6 +19,10 @@ namespace GakumasLocal::Config { bool dumpText = false; bool dumpRuntimeTexture = false; bool enableFreeCamera = false; + bool enableGyroEmu = false; + float gyroSensitivity = 1.0f; + bool gyroInvertX = false; + bool gyroInvertY = false; int targetFrameRate = 0; bool unlockAllLive = false; bool unlockAllLiveCostume = false; @@ -77,6 +81,10 @@ namespace GakumasLocal::Config { GetConfigItem(dumpRuntimeTexture); GetConfigItem(targetFrameRate); GetConfigItem(enableFreeCamera); + GetConfigItem(enableGyroEmu); + GetConfigItem(gyroSensitivity); + GetConfigItem(gyroInvertX); + GetConfigItem(gyroInvertY); GetConfigItem(unlockAllLive); GetConfigItem(unlockAllLiveCostume); GetConfigItem(enableLiveCustomeDress); @@ -135,6 +143,10 @@ namespace GakumasLocal::Config { SetConfigItem(dumpRuntimeTexture); SetConfigItem(targetFrameRate); SetConfigItem(enableFreeCamera); + SetConfigItem(enableGyroEmu); + SetConfigItem(gyroSensitivity); + SetConfigItem(gyroInvertX); + SetConfigItem(gyroInvertY); SetConfigItem(unlockAllLive); SetConfigItem(unlockAllLiveCostume); SetConfigItem(enableLiveCustomeDress); diff --git a/src/GakumasLocalify/config/Config.hpp b/src/GakumasLocalify/config/Config.hpp index 348f0e3..b5a38cb 100644 --- a/src/GakumasLocalify/config/Config.hpp +++ b/src/GakumasLocalify/config/Config.hpp @@ -15,6 +15,10 @@ namespace GakumasLocal::Config { extern bool dumpText; extern bool dumpRuntimeTexture; extern bool enableFreeCamera; + extern bool enableGyroEmu; + extern float gyroSensitivity; + extern bool gyroInvertX; + extern bool gyroInvertY; extern int targetFrameRate; extern bool unlockAllLive; extern bool unlockAllLiveCostume; diff --git a/src/gkmsGUI/gkmsGUILoop.cpp b/src/gkmsGUI/gkmsGUILoop.cpp index 624a0b0..ebe8561 100644 --- a/src/gkmsGUI/gkmsGUILoop.cpp +++ b/src/gkmsGUI/gkmsGUILoop.cpp @@ -318,6 +318,16 @@ namespace GkmsGUILoop { if (ImGui::CollapsingHeader(ts("camera_settings"), ImGuiTreeNodeFlags_DefaultOpen)) { ImGui::Indent(indentWidth); ImGui::Checkbox(ts("enable_free_camera"), &Config::enableFreeCamera); + ImGui::Checkbox(ts("enable_gyro_emu"), &Config::enableGyroEmu); + if (Config::enableGyroEmu) { + ImGui::Indent(indentWidth); + ImGui::TextWrapped("%s", ts("gyro_emu_help")); + ImGui::SliderFloat(ts("gyro_sensitivity"), &Config::gyroSensitivity, 0.1f, 5.0f); + ImGui::Checkbox(ts("gyro_invert_x"), &Config::gyroInvertX); + ImGui::SameLine(); + ImGui::Checkbox(ts("gyro_invert_y"), &Config::gyroInvertY); + ImGui::Unindent(indentWidth); + } ImGui::Unindent(indentWidth); } diff --git a/src/gkmsGUI/i18nData/strings_en.hpp b/src/gkmsGUI/i18nData/strings_en.hpp index 94fa3a1..ecf5a6f 100644 --- a/src/gkmsGUI/i18nData/strings_en.hpp +++ b/src/gkmsGUI/i18nData/strings_en.hpp @@ -16,6 +16,11 @@ namespace I18nData { { "replace_texture", "Replace Texture" }, { "lazy_init", "Fast Initialization (Lazy loading)" }, { "enable_free_camera", "Enable Free Camera" }, + { "enable_gyro_emu", "Enable Gyroscope Emulation (Live/Photo)" }, + { "gyro_emu_help", "Hold right mouse button and drag to rotate; Numpad 7/9 roll, 5 reset" }, + { "gyro_sensitivity", "Gyro Sensitivity" }, + { "gyro_invert_x", "Invert X" }, + { "gyro_invert_y", "Invert Y" }, { "start_game", "Start Game / Hot Reload Config" }, { "setFpsTitle", "Max FPS (0 is Use Original Settings)" }, { "unlockAllLive", "Unlock All Live" }, diff --git a/src/gkmsGUI/i18nData/strings_ja.hpp b/src/gkmsGUI/i18nData/strings_ja.hpp index 48cd54d..c1c4d80 100644 --- a/src/gkmsGUI/i18nData/strings_ja.hpp +++ b/src/gkmsGUI/i18nData/strings_ja.hpp @@ -57,6 +57,11 @@ namespace I18nData { { "dropdown_menu", "ドロップダウンメニュー" }, { "enable_breast_param", "胸のパラメーターを有効化" }, { "enable_free_camera", "フリーカメラを有効化" }, + { "enable_gyro_emu", "ジャイロ模擬を有効化(ライブ/撮影)" }, + { "gyro_emu_help", "右クリックドラッグで視点回転;テンキー 7/9 ロール, 5 リセット" }, + { "gyro_sensitivity", "ジャイロ感度" }, + { "gyro_invert_x", "左右反転" }, + { "gyro_invert_y", "上下反転" }, { "enable_plugin", "プラグイン有効化 (ホットリロードなし)" }, { "error_a11y_label", "エラー: 無効" }, { "error_icon_content_description", "エラー" }, diff --git a/src/gkmsGUI/i18nData/strings_zh-rCN.hpp b/src/gkmsGUI/i18nData/strings_zh-rCN.hpp index f9c890c..15d65fa 100644 --- a/src/gkmsGUI/i18nData/strings_zh-rCN.hpp +++ b/src/gkmsGUI/i18nData/strings_zh-rCN.hpp @@ -16,6 +16,11 @@ namespace I18nData { { "replace_texture", "替换贴图" }, { "lazy_init", "快速初始化(懒加载配置)" }, { "enable_free_camera", "启用自由视角(可热重载; 需使用实体键盘)" }, + { "enable_gyro_emu", "启用陀螺仪模拟(Live/拍摄)" }, + { "gyro_emu_help", "按住鼠标右键拖动转动视角;小键盘 7/9 翻滚, 5 复位" }, + { "gyro_sensitivity", "陀螺仪灵敏度" }, + { "gyro_invert_x", "反转左右" }, + { "gyro_invert_y", "反转上下" }, { "start_game", "以上述配置启动游戏/重载配置" }, { "setFpsTitle", "最大 FPS (0 为保持游戏原设置)" }, { "unlockAllLive", "解锁所有 Live" }, diff --git a/src/gkmsGUI/i18nData/strings_zh-rTW.hpp b/src/gkmsGUI/i18nData/strings_zh-rTW.hpp index 13fe5cf..e9dbd09 100644 --- a/src/gkmsGUI/i18nData/strings_zh-rTW.hpp +++ b/src/gkmsGUI/i18nData/strings_zh-rTW.hpp @@ -16,6 +16,11 @@ namespace I18nData { { "replace_texture", "替換貼圖" }, { "lazy_init", "快速初始化(懶人設定)" }, { "enable_free_camera", "啟用自由視角(可熱重載; 需使用實體鍵盤)" }, + { "enable_gyro_emu", "啟用陀螺儀模擬(Live/拍攝)" }, + { "gyro_emu_help", "按住滑鼠右鍵拖動轉動視角;數字鍵盤 7/9 翻滾, 5 重設" }, + { "gyro_sensitivity", "陀螺儀靈敏度" }, + { "gyro_invert_x", "反轉左右" }, + { "gyro_invert_y", "反轉上下" }, { "start_game", "以上述設定啟動遊戲/重載設定" }, { "setFpsTitle", "最大 FPS (0 為保持遊戲原設定)" }, { "unlockAllLive", "解鎖所有 Live" }, diff --git a/src/platformDefine.hpp b/src/platformDefine.hpp index 219fb10..feab6be 100644 --- a/src/platformDefine.hpp +++ b/src/platformDefine.hpp @@ -11,7 +11,7 @@ #define LogMinVersion ANDROID_LOG_DEBUG -#define PLUGIN_VERSION "v3.4.1" +#define PLUGIN_VERSION "v3.5.0" #define ADD_HOOK(name, addr) \ name##_Addr = reinterpret_cast(addr); \ @@ -41,4 +41,7 @@ static void __android_log_write(int prio, const char* tag, const char* msg) { prio = 1; } printf("[%s] %s: %s\n", logLevels[prio], tag, msg); + // ponytail: 同步落盘,方便无控制台时排查 hook 是否装上 + static FILE* logFile = fopen("gakumas-local/log.txt", "w"); + if (logFile) { fprintf(logFile, "[%s] %s: %s\n", logLevels[prio], tag, msg); fflush(logFile); } }