Merge pull request '解锁技能卡特效、PC端模拟陀螺仪、修复缺字bug、修复名片bug' (#7) from chihya72/gkms-localify-dmm:main into main

Reviewed-on: https://git.chinosk6.cn/chinosk/gkms-localify-dmm/pulls/7
This commit is contained in:
chinosk committed 2026-09-17 11:31:26 +08:00
commit 318107b6af
12 files changed
+508 -9

No files matched your search

+4
View File
@@ -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,
+62
View File
@@ -0,0 +1,62 @@
#include "GyroEmu.hpp"
#include "config/Config.hpp"
#include <windows.h>
#include <algorithm>
#include <chrono>
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<float>(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);
}
}
+10
View File
@@ -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();
}
+382 -8
View File
@@ -12,6 +12,7 @@
#include <atomic>
#include <chrono>
#include <cmath>
#include <climits>
#include <ctime>
#include <cstring>
#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 <typename T> T& F(void* obj, size_t off) { return *reinterpret_cast<T*>(reinterpret_cast<char*>(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<void (*)(void*, void*)>(Il2cppUtils::GetMethodPointer("campus-submodule.Runtime.dll", "Campus.Common", "CampusGyroController", ".ctor")),
reinterpret_cast<void (*)(void*, void*)>(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", ".ctor")),
reinterpret_cast<void (*)(void*, void*)>(Il2cppUtils::GetMethodPointer("vl-unity.Runtime.dll", "VL", "VLGyro", "ResetRotation")),
reinterpret_cast<Quaternion* (*)(Quaternion*, void*, void*)>(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<bool>(ctrl, 0x18)) return; // _initialized
const Quaternion identity(0, 0, 0, 1);
auto& gyro = F<void*>(ctrl, 0x10); // _gyro
if (!gyro) { gyro = api->gyroClass->New<void*>(); api->gyroCtor(gyro, nullptr); }
F<int>(gyro, 0x14) = 1; // _updateType = Gyro
F<bool>(gyro, 0x29) = true; // gyroSupported
F<bool>(gyro, 0x2A) = true; // _defaultGyroEnable
for (size_t off : {0x2C, 0x3C, 0x4C, 0x5C}) F<Quaternion>(gyro, off) = identity;
F<bool>(gyro, 0x6C) = true; // _initialized
F<bool>(gyro, 0x6D) = true; // _firstUpdate
api->gyroReset(gyro, nullptr);
Quaternion raw; api->gyroRaw(&raw, gyro, nullptr);
F<Quaternion>(ctrl, 0x1C) = Quaternion(-raw.x, -raw.y, -raw.z, raw.w); // _baseRotation = Inverse(raw)
F<int>(ctrl, 0x2C) = INT_MIN; // _lastAccessFrame
F<Quaternion>(ctrl, 0x30) = identity; // _rotation
F<bool>(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<void*>();
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<void*>(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 <typename F>
void Patch(const char* label, void* wrapper, F** origOut, F* replacement, const char* icallName) {
*origOut = reinterpret_cast<F*>(Il2cppUtils::il2cpp_resolve_icall(icallName));
if (!wrapper) { Log::ErrorFmt("[GyroEmu] %s: wrapper not found", label); return; }
auto p = reinterpret_cast<uint8_t*>(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<void**>(p + i + 7 + *reinterpret_cast<int32_t*>(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<void*>(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<void*> 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<void*, UpdatedFontState> 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<void*>(TMP_Textself);
if (!fontAsset) {
@@ -910,12 +1137,60 @@ namespace GakumasLocal::HookMain {
auto newFont = GetReplaceFont();
if (!newFont) return;
set_sourceFontFile->Invoke<void>(fontAsset, newFont);
if (!updatedFontPtrs.contains(fontAsset)) {
updatedFontPtrs.emplace(fontAsset);
UpdateFontAssetData->Invoke<void>(fontAsset);
const auto fontInstanceId = GetInstanceID->Invoke<int>(fontAsset);
const auto sourceFontInstanceId = GetInstanceID->Invoke<int>(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<void>(fontAsset, newFont);
}
// 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<void>(fontAsset, 1); // AtlasPopulationMode.Dynamic
set_isMultiAtlasTexturesEnabled->Invoke<void>(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<int>(fontAsset) < 4096) {
set_atlasWidth->Invoke<void>(fontAsset, 4096);
set_atlasHeight->Invoke<void>(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<void>(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<void>(true, fontAsset);
}
static int reportedAtlasPages = 1;
const auto atlasPages = get_atlasTextureCount->Invoke<int>(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() : "?");
}
if (updatedFontPtrs.size() > 200) updatedFontPtrs.clear();
set_font->Invoke<void>(TMP_Textself, 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<std::string, std::string> 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<UnityResolve::UnityType::List<void*>* (*)(void*, int, void*)>(getAll_mtd->methodPointer);
auto master = getMaster->Invoke<void*>(nullptr);
if (!master) return;
auto list = getAll(master, 0, (void*)getAll_mtd);
if (!list) return;
// produceCardId -> (最高 cutInType, 皮肤 id)。没有 cut-in 的皮抄过来也没演出,直接跳过
std::unordered_map<std::string, std::pair<int, std::string>> best{};
int valid = 0, withCutIn = 0;
for (auto item : list->ToArray()->ToVector()) {
if (!item) continue; // ToVector 含 List 容量内的 null 槽位
auto id = getId->Invoke<Il2cppString*>(item);
auto cardId = getCardId->Invoke<Il2cppString*>(item);
if (!id || !cardId) continue;
++valid;
// ProduceCardSkinCutInType: Unknown=0 / Small=1 / Mid=2 / Grand=3
const int cutIn = getCutInType->Invoke<int>(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<Il2cppString*>(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<UnityResolve::Field>("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",
+12
View File
@@ -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);
+4
View File
@@ -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;
+10
View File
@@ -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);
}
+5
View File
@@ -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" },
+5
View File
@@ -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", "エラー" },
+5
View File
@@ -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" },
+5
View File
@@ -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" },
+4 -1
View File
@@ -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<name##_Type>(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); }
}