From 5af7956318ba51f4fe5b346a749f99ae8f788c58 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Mon, 10 Aug 2026 17:13:02 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=9C=AA=E6=8B=A5=E6=9C=89?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=8D=A1=E7=9A=AE=E8=82=A4=E6=97=B6=E4=B9=9F?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E8=AF=A5=E5=8D=A1=E7=9A=84=20cut-in=20?= =?UTF-8?q?=E6=BC=94=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 考试内所有皮肤表现都由 ExamCardData.ProduceCardSkinId 派生: PrePlayCardAsync -> PlayCutInAsync(skinId) -> TryGetCutInParams -> mov_{id}_skillcard.usm + cutInType,以及 ExamCardButtonImage -> GetThumbnailLoopMovieName(skinId) 的手牌循环缩略图。 于是在该 getter 上,把没挂皮肤的卡按 produceCardId 补成主表里 CutInType 最高的皮肤 id,一个 hook 覆盖 cut-in 与手牌缩略图。 映射表从 ProduceCardSkinMaster 惰性构建一次。 跟随 dbgMode 生效,不额外加配置项和 GUI 开关。 已知代价:该 getter 另有 ExamDataSerializeData 与 ExamParameterModel 两个读取方,伪造的 skinId 会进考试状态序列化和 play log, 竞赛回放会随之上传。 Co-Authored-By: Claude Opus 5 --- src/GakumasLocalify/Hook.cpp | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/GakumasLocalify/Hook.cpp b/src/GakumasLocalify/Hook.cpp index 7d2e294..060aee7 100644 --- a/src/GakumasLocalify/Hook.cpp +++ b/src/GakumasLocalify/Hook.cpp @@ -1724,6 +1724,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 +2549,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", {"*", "*", "*", "*", "*"})); From 937c45ba8c59def05ea2e9a1fa1566569729d3b8 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Tue, 8 Sep 2026 16:17:05 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E6=B1=89=E5=8C=96=E5=90=8E=E7=89=B9?= =?UTF-8?q?=E6=95=88=E5=A4=A7=E5=AD=97=E9=9A=8F=E6=9C=BA=E7=BC=BA=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 游戏的 TMP_FontAsset 是全局共享的 Dynamic 资产,特效大字与普通 UI 共用同一张 2048² 图集,单页只装约 735 个字形,主页几屏就会溢出到 第 1 页。第 1 页字形拿到第二个材质进入 meshInfo[1],而特效文字组件 只提交 meshInfo[0],于是这些字消失(保留 advance,表现为空格)。 修法(UpdateFont): - 替换字体时把图集抬到 4096²(约 2900 字形),开启多图集; - 用 ClearFontAssetData(false) 代替 UpdateFontAssetData,后者会把 原字符表全集重新加回来白占第 0 页; - 清空后广播 ON_FONT_PROPERTY_CHANGED,让已生成网格的文字重排, 否则 ruby 等先排版的文字会拿着失效的图集坐标显示错字或空白; - 以 (fontAsset 地址, InstanceID, sourceFont InstanceID) 追踪资产, 只有真正新资产才重建;Resources.UnloadUnusedAssets 回收替换字体 后重新赋值即可,不再清空图集。 - 图集溢出到第 1 页时记录错误日志,便于观察天花板。 Co-Authored-By: Claude Fable 5.1 --- src/GakumasLocalify/Hook.cpp | 106 ++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 9 deletions(-) diff --git a/src/GakumasLocalify/Hook.cpp b/src/GakumasLocalify/Hook.cpp index 060aee7..1fc283e 100644 --- a/src/GakumasLocalify/Hook.cpp +++ b/src/GakumasLocalify/Hook.cpp @@ -869,7 +869,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 +897,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 +950,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); From 860f02d32ad14670aab8d814330efecf616e3cc8 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Tue, 8 Sep 2026 16:17:31 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20PC=20=E7=89=88=20Live/=E6=8B=8D?= =?UTF-8?q?=E6=91=84=E9=99=80=E8=9E=BA=E4=BB=AA=E6=A8=A1=E6=8B=9F=EF=BC=88?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E6=8B=96=E5=8A=A8=20+=20=E5=B0=8F=E9=94=AE?= =?UTF-8?q?=E7=9B=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 没有陀螺仪,Live 全景拍摄的陀螺仪按钮在 DMM 版被禁用。 新增 enableGyroEmu 开关(GUI 摄像机设置,含灵敏度与轴反转),打开后 按住鼠标右键拖动转动视角,小键盘 4/6 8/2 左右上下、7/9 翻滚、5 复位。 实现分三层,每层都有过失败尝试,原因记录在注释里: - GyroIcall:改写 il2cpp icall 包装里的 static 缓存指针,向 SystemInfo.IsGyroAvailable / Gyroscope.getEnabled/setEnabled/ attitude/gravity 提供假读数。不能 hook 原生 icall——Windows 上它们是 return 常量的桩,被链接器 ICF 合并进 IsDebugBuild 等无关函数,改一个 等于改全局(实测 [Debug] 变 True、开场黑屏);也不能 hook 托管包装—— PC 版 MSVC 把它们内联进了调用方,实测一次不被调用。 - GyroForceInit:PC 版把 IsPanoramaGyroDisabledOnPlatform() 内联成 常量 true,CampusGyroController 根本不会创建,IsGyroReady 永远 false。 在 LivePanoramaCameraManager.LateUpdate 里自己 new 出控制器和 VLGyro, 按 3.3.0 布局置字段后复用游戏自己的 ResetRotation。 - 两个方法体足够大、实测会被调用的托管 hook 放开 UI 闸门。 姿态换算来自反汇编:gyroRotationRaw = base ⊗ (-a.x, -a.y, a.z, a.w), 相对旋转里 base 被消掉,因此 attitude 直接编码相机欧拉角。 顺带:Windows 版日志同步写 gakumas-local/log.txt,便于无控制台排查。 Co-Authored-By: Claude Fable 5.1 --- resource/localizationConfig.json | 4 + src/GakumasLocalify/GyroEmu.cpp | 66 ++++++++++ src/GakumasLocalify/GyroEmu.hpp | 10 ++ src/GakumasLocalify/Hook.cpp | 155 ++++++++++++++++++++++++ src/GakumasLocalify/config/Config.cpp | 12 ++ src/GakumasLocalify/config/Config.hpp | 4 + src/gkmsGUI/gkmsGUILoop.cpp | 10 ++ src/gkmsGUI/i18nData/strings_en.hpp | 5 + src/gkmsGUI/i18nData/strings_ja.hpp | 5 + src/gkmsGUI/i18nData/strings_zh-rCN.hpp | 5 + src/gkmsGUI/i18nData/strings_zh-rTW.hpp | 5 + src/platformDefine.hpp | 3 + 12 files changed, 284 insertions(+) create mode 100644 src/GakumasLocalify/GyroEmu.cpp create mode 100644 src/GakumasLocalify/GyroEmu.hpp 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..5c38af1 --- /dev/null +++ b/src/GakumasLocalify/GyroEmu.cpp @@ -0,0 +1,66 @@ +#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; + } + + // 小键盘:4/6 yaw, 8/2 pitch, 7/9 roll, 5 复位 + const float step = 60.f * dt * Config::gyroSensitivity; + if (Down(VK_NUMPAD4)) yaw -= step * sx; + if (Down(VK_NUMPAD6)) yaw += step * sx; + if (Down(VK_NUMPAD8)) pitch -= step * sy; + if (Down(VK_NUMPAD2)) pitch += step * sy; + 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 1fc283e..8cdc60f 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,151 @@ 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 根本不会被 + // 创建/初始化(log 证实 IsGyroAvailable 从未被查询),IsGyroReady 永远 false。这里在 LateUpdate 里自己把它建出来。 + // 字段偏移来自 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); } + + void Ensure(void* manager) { + static auto ctrlClass = Il2cppUtils::GetClass("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController"); + static auto gyroClass = Il2cppUtils::GetClass("vl-unity.Runtime.dll", "VL", "VLGyro"); + static auto ctrlCtor = reinterpret_cast(Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController", ".ctor")); + static auto gyroCtor = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", ".ctor")); + static auto gyroReset = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "ResetRotation")); + static auto gyroRaw = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "get_gyroRotationRaw")); + static bool reported = false; + if (!ctrlClass || !gyroClass || !ctrlCtor || !gyroCtor || !gyroReset || !gyroRaw) { + if (!reported) { reported = true; Log::ErrorFmt("[GyroEmu] force-init: missing class/method (%p %p %p %p %p %p)", ctrlClass, gyroClass, ctrlCtor, gyroCtor, gyroReset, gyroRaw); } + return; + } + const Quaternion identity(0, 0, 0, 1); + auto& ctrl = F(manager, 0x60); // _gyroController + if (!ctrl) { ctrl = ctrlClass->New(); ctrlCtor(ctrl, nullptr); } + if (F(ctrl, 0x18)) return; // _initialized + auto& gyro = F(ctrl, 0x10); // _gyro + if (!gyro) { gyro = gyroClass->New(); 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 + gyroReset(gyro, nullptr); + Quaternion raw; 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); + } + } + + DEFINE_HOOK(void, LivePanoramaCameraManager_LateUpdate, (void* self, void* mtd)) { + if (Config::enableGyroEmu && self) GyroForceInit::Ensure(self); + LivePanoramaCameraManager_LateUpdate_Orig(self, 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) { @@ -2890,6 +3037,14 @@ 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(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..770a5ab 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 4/6 8/2 yaw/pitch, 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..e06b5c6 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", "右クリックドラッグで視点回転;テンキー 4/6 8/2 左右上下, 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..6b54102 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", "按住鼠标右键拖动转动视角;小键盘 4/6 8/2 左右上下, 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..3606db0 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", "按住滑鼠右鍵拖動轉動視角;數字鍵盤 4/6 8/2 左右上下, 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..3d6050d 100644 --- a/src/platformDefine.hpp +++ b/src/platformDefine.hpp @@ -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); } } From d372b0d22c08155d19066ab6bdc50523d5294496 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Wed, 9 Sep 2026 02:01:28 +0800 Subject: [PATCH 4/5] bump version to v3.5.0 --- src/platformDefine.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platformDefine.hpp b/src/platformDefine.hpp index 3d6050d..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); \ From 9462265bca382f567f1463268c2b992aeb331251 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Thu, 17 Sep 2026 09:33:51 +0800 Subject: [PATCH 5/5] fix: restore gyro and meishi fixes for v3.5.0 --- src/GakumasLocalify/GyroEmu.cpp | 6 +- src/GakumasLocalify/Hook.cpp | 99 +++++++++++++++++++------ src/gkmsGUI/i18nData/strings_en.hpp | 2 +- src/gkmsGUI/i18nData/strings_ja.hpp | 2 +- src/gkmsGUI/i18nData/strings_zh-rCN.hpp | 2 +- src/gkmsGUI/i18nData/strings_zh-rTW.hpp | 2 +- 6 files changed, 83 insertions(+), 30 deletions(-) diff --git a/src/GakumasLocalify/GyroEmu.cpp b/src/GakumasLocalify/GyroEmu.cpp index 5c38af1..f29a625 100644 --- a/src/GakumasLocalify/GyroEmu.cpp +++ b/src/GakumasLocalify/GyroEmu.cpp @@ -38,12 +38,8 @@ namespace GakumasLocal::GyroEmu { dragging = false; } - // 小键盘:4/6 yaw, 8/2 pitch, 7/9 roll, 5 复位 + // 小键盘:7/9 翻滚, 5 复位。4/6/8/2 是游戏自己拍摄页的镜头键,不占用 const float step = 60.f * dt * Config::gyroSensitivity; - if (Down(VK_NUMPAD4)) yaw -= step * sx; - if (Down(VK_NUMPAD6)) yaw += step * sx; - if (Down(VK_NUMPAD8)) pitch -= step * sy; - if (Down(VK_NUMPAD2)) pitch += step * sy; if (Down(VK_NUMPAD7)) roll += step; if (Down(VK_NUMPAD9)) roll -= step; if (Down(VK_NUMPAD5)) Reset(); diff --git a/src/GakumasLocalify/Hook.cpp b/src/GakumasLocalify/Hook.cpp index 8cdc60f..5363f03 100644 --- a/src/GakumasLocalify/Hook.cpp +++ b/src/GakumasLocalify/Hook.cpp @@ -234,52 +234,92 @@ namespace GakumasLocal::HookMain { return Photography3DPresenter_get_IsGyroSupported_Orig(self, mtd); } - // PC 版把 LiveUtility.IsPanoramaGyroDisabledOnPlatform()(恒 true)内联成常量,CampusGyroController 根本不会被 - // 创建/初始化(log 证实 IsGyroAvailable 从未被查询),IsGyroReady 永远 false。这里在 LateUpdate 里自己把它建出来。 + // 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); } - void Ensure(void* manager) { - static auto ctrlClass = Il2cppUtils::GetClass("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController"); - static auto gyroClass = Il2cppUtils::GetClass("vl-unity.Runtime.dll", "VL", "VLGyro"); - static auto ctrlCtor = reinterpret_cast(Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController", ".ctor")); - static auto gyroCtor = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", ".ctor")); - static auto gyroReset = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "ResetRotation")); - static auto gyroRaw = reinterpret_cast(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "get_gyroRotationRaw")); - static bool reported = false; - if (!ctrlClass || !gyroClass || !ctrlCtor || !gyroCtor || !gyroReset || !gyroRaw) { - if (!reported) { reported = true; Log::ErrorFmt("[GyroEmu] force-init: missing class/method (%p %p %p %p %p %p)", ctrlClass, gyroClass, ctrlCtor, gyroCtor, gyroReset, gyroRaw); } - return; - } + 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& ctrl = F(manager, 0x60); // _gyroController - if (!ctrl) { ctrl = ctrlClass->New(); ctrlCtor(ctrl, nullptr); } - if (F(ctrl, 0x18)) return; // _initialized auto& gyro = F(ctrl, 0x10); // _gyro - if (!gyro) { gyro = gyroClass->New(); gyroCtor(gyro, nullptr); } + 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 - gyroReset(gyro, nullptr); - Quaternion raw; gyroRaw(&raw, gyro, nullptr); + 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) GyroForceInit::Ensure(self); + 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 合并影响。 @@ -1459,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(); @@ -2842,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"); @@ -3044,6 +3097,10 @@ namespace GakumasLocal::HookMain { 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")); diff --git a/src/gkmsGUI/i18nData/strings_en.hpp b/src/gkmsGUI/i18nData/strings_en.hpp index 770a5ab..ecf5a6f 100644 --- a/src/gkmsGUI/i18nData/strings_en.hpp +++ b/src/gkmsGUI/i18nData/strings_en.hpp @@ -17,7 +17,7 @@ namespace I18nData { { "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 4/6 8/2 yaw/pitch, 7/9 roll, 5 reset" }, + { "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" }, diff --git a/src/gkmsGUI/i18nData/strings_ja.hpp b/src/gkmsGUI/i18nData/strings_ja.hpp index e06b5c6..c1c4d80 100644 --- a/src/gkmsGUI/i18nData/strings_ja.hpp +++ b/src/gkmsGUI/i18nData/strings_ja.hpp @@ -58,7 +58,7 @@ namespace I18nData { { "enable_breast_param", "胸のパラメーターを有効化" }, { "enable_free_camera", "フリーカメラを有効化" }, { "enable_gyro_emu", "ジャイロ模擬を有効化(ライブ/撮影)" }, - { "gyro_emu_help", "右クリックドラッグで視点回転;テンキー 4/6 8/2 左右上下, 7/9 ロール, 5 リセット" }, + { "gyro_emu_help", "右クリックドラッグで視点回転;テンキー 7/9 ロール, 5 リセット" }, { "gyro_sensitivity", "ジャイロ感度" }, { "gyro_invert_x", "左右反転" }, { "gyro_invert_y", "上下反転" }, diff --git a/src/gkmsGUI/i18nData/strings_zh-rCN.hpp b/src/gkmsGUI/i18nData/strings_zh-rCN.hpp index 6b54102..15d65fa 100644 --- a/src/gkmsGUI/i18nData/strings_zh-rCN.hpp +++ b/src/gkmsGUI/i18nData/strings_zh-rCN.hpp @@ -17,7 +17,7 @@ namespace I18nData { { "lazy_init", "快速初始化(懒加载配置)" }, { "enable_free_camera", "启用自由视角(可热重载; 需使用实体键盘)" }, { "enable_gyro_emu", "启用陀螺仪模拟(Live/拍摄)" }, - { "gyro_emu_help", "按住鼠标右键拖动转动视角;小键盘 4/6 8/2 左右上下, 7/9 翻滚, 5 复位" }, + { "gyro_emu_help", "按住鼠标右键拖动转动视角;小键盘 7/9 翻滚, 5 复位" }, { "gyro_sensitivity", "陀螺仪灵敏度" }, { "gyro_invert_x", "反转左右" }, { "gyro_invert_y", "反转上下" }, diff --git a/src/gkmsGUI/i18nData/strings_zh-rTW.hpp b/src/gkmsGUI/i18nData/strings_zh-rTW.hpp index 3606db0..e9dbd09 100644 --- a/src/gkmsGUI/i18nData/strings_zh-rTW.hpp +++ b/src/gkmsGUI/i18nData/strings_zh-rTW.hpp @@ -17,7 +17,7 @@ namespace I18nData { { "lazy_init", "快速初始化(懶人設定)" }, { "enable_free_camera", "啟用自由視角(可熱重載; 需使用實體鍵盤)" }, { "enable_gyro_emu", "啟用陀螺儀模擬(Live/拍攝)" }, - { "gyro_emu_help", "按住滑鼠右鍵拖動轉動視角;數字鍵盤 4/6 8/2 左右上下, 7/9 翻滾, 5 重設" }, + { "gyro_emu_help", "按住滑鼠右鍵拖動轉動視角;數字鍵盤 7/9 翻滾, 5 重設" }, { "gyro_sensitivity", "陀螺儀靈敏度" }, { "gyro_invert_x", "反轉左右" }, { "gyro_invert_y", "反轉上下" },