feat: 未拥有技能卡皮肤时也播放该卡的 cut-in 演出

考试内所有皮肤表现都由 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 <noreply@anthropic.com>
This commit is contained in:
chihya72andClaude Opus 5 committed 2026-08-10 17:16:50 +08:00
1 parent 098a41459e
commit 5af7956318
1 file changed
+74
+74
View File
@@ -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<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 +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", {"*", "*", "*", "*", "*"}));