From 5af7956318ba51f4fe5b346a749f99ae8f788c58 Mon Sep 17 00:00:00 2001 From: chihya72 Date: Mon, 10 Aug 2026 17:13:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AA=E6=8B=A5=E6=9C=89=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=8D=A1=E7=9A=AE=E8=82=A4=E6=97=B6=E4=B9=9F=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E8=AF=A5=E5=8D=A1=E7=9A=84=20cut-in=20=E6=BC=94?= =?UTF-8?q?=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", {"*", "*", "*", "*", "*"}));