引言
Phigros 官方从未开放过任何公开查分接口。玩家数据唯一可靠出口,是游戏内置的”云存档”:登录 TapTap 后,成绩以加密 zip 的形式躺在 LeanCloud 的 _GameSave 表里,只有拿着 TapTap 会话的客户端才能读到。rRanker(D:\Projects\rRanker)的 Phigros 板块做的,就是把这条官方客户端私有的链路完整复刻一遍:设备码授权 → MAC 签名 → sessionToken 换发 → 存档下载 → 二进制解密解析 → RKS/B30 计算 → 推分推荐 → 成绩图渲染。
这篇文章按数据流逐层拆解这条链路,所有结论均来自 apps/mobile 下的源码,关键点标注到文件与行号。
登录链路:TapTap 设备码 OAuth2 的完整复刻
整个登录协议集中在 src/providers/phigros-auth.ts。先看几个硬编码常量,它们本身就是协议指纹:
const TAPTAP_CLIENT_ID = 'rAK3FfdieFob2Nn8Am'; // phigros-auth.ts:4
const TAPTAP_SCOPE = 'public_profile'; // phigros-auth.ts:5
const LC_SERVER = 'https://rak3ffdi.cloud.tds1.tapapis.cn'; // phigros-auth.ts:6
const LC_APP_KEY = 'Qr9AEqtuoSVS3zeD6iVbM4ZC0AtkJcQ89tywVyi0'; // phigros-auth.ts:7
Phigros 的 LeanCloud 应用 ID 恰好就是 TapTap 的 client_id,这说明官方客户端把两个账号体系绑在同一个应用下——复刻时这成为关键的桥梁。
第一步:请求设备码
requestDeviceCode(phigros-auth.ts:129-147)向 https://accounts.tapapis.cn/oauth2/v1/device/code 发表单请求,字段完全是官方 Unity 客户端的翻版:
client_id = rAK3FfdieFob2Nn8Am
response_type = device_code
scope = public_profile
platform = unity
info = {"device_id": "<32 位随机串>"}
device_id 由 randStr(32) 随机生成(phigros-auth.ts:105-110,130),响应里带 device_code、qrcode_url、expires_in 和轮询间隔 interval(默认 5 秒)。响应用 zod 严格校验(DeviceCodeResponseSchema),协议漂移时直接抛错而不是带病运行。
第二步:轮询换取令牌
pollForToken(phigros-auth.ts:149-168)向 /oauth2/v1/token POST grant_type=device_token,带上设备码。授权未完成时,服务端返回 authorization_pending / authorization_waiting,调用方据此返回 'pending' | 'waiting',由上层驱动轮询节奏——PhigrosScoreProvider.login 里是”每 interval 秒一查,直到 expiresIn 超时”的经典循环(phigros-score-provider.ts:97-113)。
成功拿到的是 TapTap 的 OAuth 令牌(kid、access_token、mac_key),注意此时还不能访问存档。
第三步:MAC 签名拉取用户资料
getProfile(phigros-auth.ts:170-197)请求 https://open.tapapis.cn/account/profile/v1,这是整条链路里最”协议味”的一段:用 mac_key 对请求做 HMAC-SHA1 签名,签名串格式为:
ts\nnonce\nMETHOD\nuri\nhost\nport\n\n
对应代码(phigros-auth.ts:179):
const sigBase = `${ts}\n${nonce}\n${method}\n${uri}\n${host}\n${port}\n\n`;
const mac = CryptoJS.enc.Base64.stringify(
CryptoJS.HmacSHA1(sigBase, token.mac_key),
);
其中 ts 是 10 位时间戳(padStart(10,'0')),nonce 是 16 位随机串,uri 是 pathname + search,host 是主机名,port 固定 443。签名放进 Authorization 头:MAC id="kid", ts="...", nonce="...", mac="..."(phigros-auth.ts:189)。这是标准的 OAuth MAC 认证方案,TS 秒级、nonce 防重放。
第四步:LeanCloud 换 sessionToken
有了 TapTap 身份,就能用它”登录”LeanCloud。exchangeSessionToken(phigros-auth.ts:199-238)POST /1.1/users,请求头是 LeanCloud REST 的标准鉴权:
const lcHash = CryptoJS.MD5(ts + LC_APP_KEY).toString();
const lcSign = `${lcHash},${ts}`; // phigros-auth.ts:203-204
// X-LC-Id: rAK3FfdieFob2Nn8Am(= TapTap client_id)
// X-LC-Sign: <MD5(ts + appKey)>,ts
Body 是 LeanCloud 第三方登录的标准 authData 结构,taptap 字段里打包 openid、name、avatar、kid、access_token、mac_key、expires_in、platform: 'TapTap'(phigros-auth.ts:216-229)。服务端据此创建或登录对应 user 并返回 sessionToken——这就是官方客户端”云存档与账号绑定”的实现本体。
第五步:拉存档与下载
持有 sessionToken 后,getPlayerId(phigros-auth.ts:240-259)GET /1.1/users/me 拿游戏内昵称,getGameSave(phigros-auth.ts:261-287)GET /1.1/classes/_GameSave?order=-updatedAt&limit=20 拉最近 20 条存档记录。注意这里带上了 User-Agent: LeanCloud-CSharp-SDK/1.0.3——连 UA 都在复刻官方 C# SDK。
pickLatestGameSave(phigros-auth.ts:73-88)做两层防御:先过滤掉没有 summary 或 gameFile 的空记录(官方存档表里有大量无档占位行),再按 updatedAt 取最新。最后 downloadSave(phigros-auth.ts:289-307)下载 gameFile.url 时追加 _ts 查询参数(phigros-auth.ts:294)做缓存穿透,并同时设 cache: 'no-store' 与 Cache-Control: no-cache。
客户端侧的深链唤起
设备码流程需要用户去 TapTap 确认,rRanker 用深链把体验收敛回客户端内(ProviderLoginSheet.tsx:260-281):先试 taptap://taptap.com/to?url=<encoded qrcode_url>(ProviderLoginSheet.tsx:270-272),唤起失败再回退到网页版 qrcode_url(ProviderLoginSheet.tsx:274)。
云存档:解包、AES 解密与二进制解析
下载到的是 zip。decodeSaveZip(phigros.ts:549-590)用 JSZip 解包,取三个文件:gameRecord(必选,缺失直接抛错)、user 与 gameProgress(可选,解析失败置 null,不阻断主流程)。每个文件的首字节是格式版本号,gameRecord 要求必须等于 1(phigros.ts:560),剩余部分整体 AES 加密。
AES 密钥与 crypto-js 用法
密钥/IV 以 base64 硬编码(phigros.ts:5-6):
const AES_KEY_B64 = '6Jaa0qVAJZuXkZCLiOa/Ax5tIZVu+taKUN1V1nqwkks=';
const AES_IV_B64 = 'Kk/wisgNYwcAV8WVGMgyUw==';
注意一个易错点:这个 base64 解码后是 32 字节,即 AES-256-CBC(IV 16 字节),并非坊间流传的 AES-128。解密用 crypto-js,CBC + PKCS7(crypto-js 默认填充):
const key = CryptoJS.enc.Base64.parse(AES_KEY_B64);
const iv = CryptoJS.enc.Base64.parse(AES_IV_B64);
const decrypted = CryptoJS.AES.decrypt(encryptedBase64, key, { iv }); // phigros.ts:93-98
由于 key 以 WordArray 形式传入,crypto-js 不会走”口令派生”分支,第一个参数按 base64 密文解析,行为与标准 AES-CBC 一致。decryptBytes(phigros.ts:100-110)处理二进制密文时,需要先把 Uint8Array 手动转成 WordArray(uint8ArrayToWordArray,phigros.ts:80-91),再以 { ciphertext: ... } 形式传入——crypto-js 的类型定义不认这个运行时契约,代码里专门加了注释说明(phigros.ts:104)。解密结果再转回 Uint8Array(phigros.ts:72-78)。
ByteReader:手写小端二进制读取器
解密后是裸二进制。ByteReader(phigros.ts:8-70)封装了全部读取原语,全部小端:
getByte:u8getShort/getInt:i16 / i32,逐字节拼位(phigros.ts:25-38)getFloat:f32,getFloat32(pos, true)显式小端(phigros.ts:40-44)getVarInt:Phigros 专用变长编码——首字节 < 128 占 1 字节,否则占 2 字节,值是(b & 0x7f) | (next << 7)(phigros.ts:46-51)getString:varint 长度 + UTF-8(phigros.ts:53-59)
parseSummary:存档头部
parseSummary(phigros.ts:275-297)的字节布局值得逐字段对齐(存档里这条 summary 是 base64 字符串,GameSaveMeta 里随列表一起下发,不需要下载整个 zip——这一点是轻量刷新的前提,后面会展开):
offset 类型 字段
0 u8 saveVersion
1 i16 课题模式 rank(level*100 + rank 编码)
3 f32 rankingScore(玩家总 RKS)
7 varint gameVersion
… string avatar(头像 key)
… i16 × 4 cleared[EZ,HD,IN,AT]
… i16 × 4 fullCombo[...]
… i16 × 4 phi[...]
parseGameRecord:逐曲目循环
成绩主体 gameRecord 是一个”键值对序列”:每一条是 varshort(keyLen) + utf8(keyLen-2) + 2 字节校验和 + u8(bodyLen) + body。getGameRecordKey(phigros.ts:62-69)实现键的读取:先读 varint 长度 len,只取前 len-2 字节解码为 UTF-8 曲名,末尾 2 字节是校验和直接跳过——这与社区工具 phiTool 的 PhigrosLibrary.GameRecord.read 一致(注释见 phigros.ts:319-320)。
body 结构(phigros.ts:335-355):
u8 exist 难度位图(bit0=EZ … bit3=AT)
u8 fcFlag 各难度 FC 位图
对 exist 置位的每个难度:
i32 score 分数(0–1,000,000)
f32 rawAcc 存档原始准确率(0–100,未舍入)
FC 的判定值得单独拎出来(phigros.ts:343):
const isFullCombo = (score === 1000000 && rawAcc >= 99.995) || !!((fcFlag >> lv) & 1);
即”满分 1000000 且 Acc ≥ 99.995”或”FC 位图置位”二选一。前者正是为了兜住官方服务器用浮点存档、判分时按 99.996 这类值算 100% 的边界(注释见 phigros.ts:368-371,isAcc100Percent 同款阈值 99.995)。
工程化防御:二进制解析的脏活
二进制解析最怕”错一位,全盘皆错”。这里有三层防线(phigros.ts:313-363):
- 首 varint 不可信。
gameRecord开头的 varint 只是歌曲数提示,代码注释直言:实测常见值为 27,若以它为循环上限,“会只解析 B27 条而非完整存档”(phigros.ts:313-317)。循环条件只用r.remaining() > 0。 - 键长度超 128 直接 break(phigros.ts:325)。曲名键长必然很小,异常长度说明解错位了。
- 逐条回退。每一条在进入前记录
entryStart,body 长度非法(<= 0或超出剩余字节)或任何读取抛异常,都把指针回滚到entryStart并终止循环(phigros.ts:322-362)。宁可丢尾部,不吞错数据。
user 与 gameProgress
parsePhigrosUser(phigros.ts:210-219)读玩家档案:showPlayerId 标志位、自我介绍、头像 key、背景曲 ID。parsePhigrosGameProgress(phigros.ts:222-256)是另一段硬核布局:连续 5 个 varint 的 money 字段(phigros.ts:230)记录游戏内 Data 数据量(KiB/MiB/GiB/TiB/PiB 五档,formatPhigrosDataMoney phigros.ts:258-266 负责格式化),后面跟着 Spasmodic/Igallta/Rrharil 三首隐藏曲的解锁旗标、randomVersionUnlocked、以及第 8 章的 4 个解锁位(phigros.ts:231-254)——这些就是”玩家 Data 多少 G、隐藏曲解没解”这类展示数据的来源。
曲库数据:OSS 三件套与版本合并
谱面定数与物量不在存档里,rRanker 自建了资源管线:OSS 根 https://rranker-phigros-data.cn-nb1.rains3.com(account-avatar.ts:2),按游戏版本发布目录。
phigros/current.json:指向当前版本的catalog、manifest、可选noteCounts路径(phigros-catalog-provider.ts:10-16)phigros/releases/<version>/metadata/difficulty.tsv:每行songId\tEZ\tHD\tIN\tAT的定数表(phigros-score-provider.ts:150-156, phigros.ts:592-604)- 物量表
note_counts.tsv:每格是 JSON[Tap,Hold,Drag,Flick](phigros.ts:611-653)
关键设计是 loadMergedDifficultyTable(phigros-score-provider.ts:158-178):存档版本与当前版本各拉一份定数表,合并时旧版本(存档版本)优先,当前版本只补缺——因为玩家可能停在旧版本,新曲目的定数在存档版本里不存在。mergeDifficultyTables(phigros.ts:655-667)正是”已存在则不覆盖”的合并。物量表拉取则有多级回退:current.noteCounts 指针 → 当前版本约定路径 → 重拉 current.json 再试(phigros-catalog-provider.ts:134-162)。
RKS 与 B30:数学内核
单曲 RKS
calculateRks(phigros.ts:393-396)是整条数据链的核心公式:
if (rawAcc < 70) return 0;
return difficulty * ((rawAcc - 55) / 45) ** 2;
difficulty 是谱面定数,rawAcc 是存档原始 Acc(0–100)。Acc 低于 70 视为未入门直接归零;否则平方增长——这就是为什么玩家总 RKS 由”定数 × Acc 增益”决定,Acc 从 99 提到 100 的收益远大于 90 提到 91。
100% 判定与 Phi3
isAcc100Percent(phigros.ts:369-371):rawAcc >= 99.995。游戏内显示 100% 的存档浮点可能是 99.996、99.999,只有用原始值比阈值才稳。
selectPhi3(phigros.ts:408-413):从全部成绩中筛出 acc≥100% 的谱面,按谱面定数降序取前 3。注意 Phi3 槽位的贡献是定数本身而非成绩定数(sumPhi3Contribution,phigros.ts:416-418)——一个 100% 的 16.8 定数谱和 15.9 的 100% 谱,贡献就是 16.8 与 15.9。
B30 总计算
computeB30(phigros.ts:473-547)与 phiTool 的 parse_b27 口径一致:
B30 = (ΣBest27.rks + ΣPhi3.定数) / 30,结果 roundRks 保留 4 位
best27:全部成绩按 RKS 降序取前 27(phigros.ts:479-480)best27RksSum:27 首 RKS 之和phi3ContributionSum:Phi3 三首定数之和(phigros.ts:484)- 二者相加除以 30,
roundRks=Math.round(x*10000)/10000(phigros.ts:398-400)
“推 0.01” 目标 Acc 的二分求解
每个 Best27 曲目还附带 targetAccForPlusOne——“推到多少 Acc 总 RKS 能涨 0.01”(UI 显示为两位小数)。思路(phigros.ts:487-536):
displayRks2 = floor(finalRks * 100) / 100 // 游戏内两位显示值
targetRks = displayRks2 + 0.01 - 0.005 // 涨 0.01 的精确门槛
减 0.005 是显示值的”安全边界”:由于游戏内是两位四舍五入,只要精确值 ≥ display + 0.005 就能进位(同款逻辑在推分模块里还会再出现)。然后对当前曲目的 Acc 做 100 次二分:每一步用 calculateRks 重算该曲 RKS,重算 Best27 和 Phi3 双分区——特别注意若二分中途的 Acc 越过 100%,Phi3 候选集要重新按定数取前 3(phigros.ts:507-520),模拟”新曲挤掉旧 Phi3”的真实结果。命中后 targetAcc 上取整到两位小数(phigros.ts:534)。已 100% 的曲子没有推分空间,直接标 null(phigros.ts:491)。
推分推荐:全量模拟 + 二分求目标 Acc
src/domain/phigros-push.ts 是 B30 二分的”放大版”:用户给定”想涨 delta 点 RKS、愿意打 songCost 首歌”,算法对每张谱面求出”要打到多少 Acc”。入口 PhigrosScoreProvider.getPushRecommendations(phigros-score-provider.ts:235-242),工具入口已在游戏工具箱注册(game-toolbox.ts:78-79),成绩卡片的推分提示(当前 Acc → 目标 Acc)在 PhigrosScoreCard.tsx:71-83 渲染。
显示值双轨制
resolvePushExactTarget(phigros-push.ts:65-72)把”显示层”和”精确层”分开:
const displayRks = Math.round(currentRks * 100) / 100;
return {
displayRks,
exactTarget: displayRks + delta - 0.005, // 达成显示目标所需精确 RKS
displayTarget: displayRks + delta, // 用户看到的期望显示值
};
用户看到的 currentRks 是四位小数(存档 rankingScore),但游戏内显示两位——所以”涨 0.01”的精确门槛是 显示值 + 0.005。gainNeeded = max(0, exactTarget - currentRks)(phigros-push.ts:152)。
均摊份额与全量模拟
核心洞察(注释见 phigros-push.ts:134-138):songCost 首歌均摊目标,每首只需把总 RKS 抬高 perSongShare = gainNeeded / songCost(phigros-push.ts:153),于是每张谱面的单曲目标为 perSongTarget = currentRks + perSongShare(phigros-push.ts:155)。
模拟层是 B30 计算的投影版。SimRecord 只保留 RKS 计算需要的字段(phigros-push.ts:56-62),calculateFinalRks(phigros-push.ts:84-93)与 computeB30 同口径:Best27 取 RKS 前 27 + Phi3 取 isPhi 前 3 的定数之和,除以 30。withReplacedChart(phigros-push.ts:95-111)原地替换对应 songId+level 的条目,若该谱面没打过则追加——一次候选评估 = 构造一份新的模拟记录集再算总 RKS。
候选筛选与二分
为避免对几百张谱面全部跑”替换 + 全量模拟”,先做预筛:把该谱面替换为 100% Acc 的模拟记录,算 maxPossibleGain,小于 minGainGate = max(perSongShare * 0.99, 1e-6) 直接跳过(phigros-push.ts:181-187)。下限取份额的 99% 是为了容忍浮点噪声。
每个候选谱面做 100 次二分(phigros-push.ts:193-208),初始下界 lowAcc = max(55.01, currentAcc - 5)(phigros-push.ts:189)——55.01 对应 RKS 公式的零点,currentAcc - 5 则是”推分题至少比现在高一点”的经验约束。二分过程每次迭代都跑 withReplacedChart + calculateFinalRks 的完整模拟,收敛到”总 RKS ≥ perSongTarget”的最小 Acc。
之后是展示层修正(phigros-push.ts:212-223):
let snappedAcc = Math.ceil(targetAcc * 100 - 1e-9) / 100; // 两位小数上取整
// 回代验证:若上取整后过不了线,再 +0.01
if (calculateFinalRks(verify) < perSongTarget) snappedAcc = Math.min(100, snappedAcc + 0.01);
因为玩家打出的 Acc 只能到两位小数(99.99%),必须把二分出的精确值上取整到两位,再回代验证一次,防”理论值可行、两位小数打不出来”的假推荐。includePhi=false 时排除目标为 100% 的谱面(phigros-push.ts:225)。最终结果按 accDiff 升序、定数升序排序(phigros-push.ts:258)——最省力的推分优先。
XING 判定:把”性歌”变成精确数学
XING(玩家俗称”性歌”)指”整局只差 1 个 Good 或 1 个 Miss”的成绩,是 Phigros 玩家圈的标志性成就。src/domain/phigros-xing.ts 把它做成纯数学判定。
理论 Acc 公式
Phigros 的 Acc 计分里 Good 权重 0.65、Perfect 权重 1。设物量为 N:
Good 1 个(其余 Perfect):Acc = (100(N−1) + 65) / N %
Miss 1 个(其余 Perfect):Acc = 100(N−1) / N %
代码的巧妙处在于用整数分子避免浮点误差(phigros-xing.ts:10-17):
const percentNumerator = kind === 'good'
? 100 * (totalNotes - 1) + 65
: 100 * (totalNotes - 1);
return Math.round((percentNumerator * 100) / totalNotes) / 100;
如果先算 (N-0.35)/N*100,对 N=1000 这类大物量会有小数尾差;先乘后除再整体舍入到两位,分子全程整数,结果可复现。
判定与 FC 特判
isPhigrosXingAcc(phigros-xing.ts:20-24)比较两位小数的 Acc 与理论值。resolvePhigrosXingKind(phigros-xing.ts:30-42)有两条规则:
- 先判 Good(Good 不断连击,FC 与 XING-GOOD 兼容)
- XING-MISS 必断连击,FC 一律不算 MISS(phigros-xing.ts:39-40,注释直接写明原因)
物量数据来自曲库 note_counts.tsv 装配的查找表(buildPhigrosNoteTotalByKey,phigros-best-image-custom.ts:50-61),无物量时返回 null 不判定。该判定同时服务成绩卡片角标(PhigrosScoreCard.tsx:44,69)与自定义成绩图/随机谱面的筛选(phigros-best-image-custom.ts:33, 随机谱面 phigros-filters 组合见 random-charts.ts:193-240)。
成绩图生成:像素级复刻 phi-plugin 模板
src/features/phigros-best-image/ 与 PhigrosBestImageScreen.tsx 是最大的子系统,产出”Phigros 玩家标准配置”的 B30 长图。
分区与 OVER FLOW
appendPhigrosOverflowRecords(phigros-best-image.ts:31-47):Best30 图由 Phi3、Best27 和可选 OVER FLOW 组成。OVER FLOW(0/3/6/9 可选,PhigrosBestImageScreen.tsx:75)从全部成绩里剔除已进入非 Phi 分区(Best27)的曲目,再按 RKS 取前 N 追加为独立分区(phigros-best-image.ts:38-44)。分页 paginatePhigrosBestImageSections(phigros-best-image.ts:50-73)按每页 30 张(Best30 类型为 30 + overflow)切分,且保证分区标题不跨页。
DOM/CSS 契约
build-phigros-best-image-html.ts 开头注释写得很直白(第 1-4 行):DOM 与 CSS 契约直接对齐 phi-plugin 的 resources/html/b19/b19.art。b19.css、common.css、snow.css 三份参考样式表随 App 打包(load-phigros-reference-template-assets.ts:20-28),运行时拼装:snow.css + common.css(去 @import、回填背景图)+ b19.css(load-phigros-reference-template-assets.ts:159-163)。模板里的头像、评价图标、课题图标、数据图标全部走 require() 资源映射(load-phigros-reference-template-assets.ts:30-51),生成 data URI 注入 HTML。
成绩卡片(scoreCard,build-phigros-best-image-html.ts:82-115)的编号规则:Phi3 区是 P1/P2/P3(build-phigros-best-image-html.ts:143),Best 区是 #1-#27(phigros-best-image-html.ts:152)。评价图标按分数分段(ratingName,build-phigros-best-image-html.ts:45-55):满分 phi、有 FC 标 FC、700k 以下 F、700k-820k C、820k-880k B、880k-920k A、920k-960k S、以上 V。
推分建议公式
每张卡片底部有一行推分建议,公式(build-phigros-best-image-html.ts:57-72):
let minimumIncrease = Math.floor(playerRks * 100) / 100 + 0.005 - playerRks;
if (minimumIncrease < 0) minimumIncrease += 0.01;
const acc = 45 * Math.sqrt((referenceRks + minimumIncrease * 30) / difficulty) + 55;
这是 RKS 公式的反函数:目标 = (参考 RKS + 推 0.01 所需增益 × 30) / 定数,开方解 Acc。minimumIncrease 的语义是”当前显示值推到下一档的最小精确增量”,与推分模块的 ±0.005 逻辑一脉相承。计算出的 Acc 按五档区间给样式(build-phigros-best-image-html.ts:70):<98.5、<99、<99.5、<99.7、<99.85,再往上第五档;≥100 时若允许 φ 兜底则显示 100.00% 否则”无法推分”(build-phigros-best-image-html.ts:67-69)。参考 RKS 取 B30 的截断值(第 27 名成绩的 RKS 或更高,build-phigros-best-image-html.ts:131-156)——因为总 RKS 的提升主要取决于第 27 名的水位。
Avg 彩带
phi-plugin 模板的彩带(展示”这首成绩高于/低于同段位平均”)调用了社区服务 https://phib19.top:8080(load-phigros-acc-averages.ts:3):POST /get/scoreList/allAccAvg,带 songIds(内部 ID 规则是补齐 .0 后缀,load-phigros-acc-averages.ts:15-17)、minRks/maxRks。查询区间按玩家 RKS 对齐 0.05 网格(load-phigros-acc-averages.ts:61-62)。逻辑对齐 phi-plugin Save.getB19:先查同区间,逐曲比较后记 Lower/Higher;若前 27 曲全部高于区间均值,再整体上调两档(+2×0.05)重查,命中则切换为 Hyper/Finished 配色(load-phigros-acc-averages.ts:65-88)——语义是”这玩家比同段位强太多,得找更高的参照系”。彩带在 HTML 里渲染为带 UP/Finished 图标的 accAvg 块(build-phigros-best-image-html.ts:77-80)。
素材管线:file:// 而非 base64
曲绘走 loadPhigrosIllustrations(load-phigros-image-assets.ts:62-75):先用 expo-image 预取到磁盘缓存,再复制到舞台目录 Documents/rranker/phigros-illustration-stage/(load-phigros-image-assets.ts:9-13),文件名 = URL 的 SHA-256 前 32 位 + 扩展名(load-phigros-image-assets.ts:28-33)。代码注释(load-phigros-image-assets.ts:36-38)点明了动机:不再读成 base64,避免几十张曲绘在 JS 堆里膨胀把 App 拖垮。WebView 通过 allowingReadAccessToURL 指向 Documents/rranker(load-phigros-image-assets.ts:15-20, PhigrosBestImageScreen.tsx:570)来覆盖字体与曲绘目录。
头像素材是双轨:模板内置头像走 require() 映射 + 大小写归一 + 别名表(如 'Cipher : /2&//<|0' → Cipher1,load-phigros-reference-template-assets.ts:105-123);玩家当前头像则从 OSS 按版本拉 metadata/tmp.tsv 别名表解析文件名(phigros-avatar-resolver.ts:9-46,54-58),avatar. 前缀会被剥掉(phigros-avatar-resolver.ts:57)。
字体工程:12 种字体按需下载
成绩图要复刻模板排版,而模板依赖 PHI(游戏同款数字字体)、Aldrich(页脚)、Noto 系列(阿拉伯文/日文/卡纳达文…)等 12 种字体,清单硬编码在 PHIGROS_FONT_MANIFEST(phigros-font-cache.ts:45-58)——含 phi 字体的 zip 压缩包约 5.4MB、解包后 8.5MB,NotoColorEmoji 更是 7.6MB 压缩 / 24MB 解包。下载管线(downloadFont,phigros-font-cache.ts:122-161)做了三道校验:
- 压缩包大小与 SHA-256 双查(phigros-font-cache.ts:135-141)
- 解包后强制要求包内恰好一个文件且名字匹配(phigros-font-cache.ts:143-146)
- 字体文件字节数与 SHA-256 再查(phigros-font-cache.ts:148-150)
落盘是原子式:先写 .part,校验全过才 move 到最终文件名,异常时清理残留(phigros-font-cache.ts:151-160)。已存在的文件按”大小 + SHA-256”验证,通过则跳过下载(isValidFont,phigros-font-cache.ts:117-120)。流程分两阶段:核心字体(phi、Aldrich)并行优先,扩展字体串行补全,UI 上能先出图再慢慢补字体(phigros-font-cache.ts:195-225)。
真正聪明的是”按需”:resolveNeededPhigrosFonts(phigros-font-coverage.ts:108-133)扫描成绩图所有可见文本(collectPhigrosBestImageVisibleStrings 负责收集),按 Unicode 脚本区块启发式映射字体——emoji 只认补充平面(0x1F300-0x1FAFF、区域旗标、U+20E3 组合符),BMP 符号走 NotoSansSymbols2,避免误拉 24MB 的彩色 Emoji(phigros-font-coverage.ts:41-46);阿拉伯文 0x0600-0x06FF 等五段、平假名/片假名走 NotoSansJP、藏文走 HIMALAYA(phigros-font-coverage.ts:48-92)。配套的 trimPhigrosBestImageCss(phigros-font-coverage.ts:145-192)会把模板 CSS 里没下载的 @font-face 块删掉、body 字体栈按需缩短——没文本用到的字体连 CSS 引用都不留。
导出:WebView 计量 + captureRef
预览与导出共用同一份 HTML。运行时协议通过 postMessage 回传(build-phigros-best-image-html.ts:199-209):best-image-height 报实测高度、best-image-ready 报渲染就绪。HTML 内有个 12 秒的 Promise.race 兜底(build-phigros-best-image-html.ts:208),防止某张图挂起导致永不 ready。导出时用全屏黑底 Modal 承载 WebView(PhigrosBestImageScreen.tsx:583),captureRef 按 best-image-height 回传的像素高度截屏(PhigrosBestImageScreen.tsx:429-449),三档宽度 1080/1440/2160(PhigrosBestImageScreen.tsx:74)。iOS 上输出尺寸要除以 PixelRatio,且宽度 ≥1440 时改用 useRenderInContext(best-image-export.ts:6-25),失败时再降级重试(PhigrosBestImageScreen.tsx:441)。
课题模式与评价体系
课题模式的编码藏在 summary.challengeModeRank 一个 i16 里:level × 100 + rank(phigros.ts:299-305)。parseChallengeModeRank 拆成两段——level = min(5, floor(value/100)) 对应白/绿/蓝/红/金/彩六档主题(phigros-challenge-theme.ts:7-74 逐档定义了填充色、描边色、星标色),rank = value % 100 是 1-99 的课题等级(0 表示未激活)。成绩图头部与账号列表徽标都吃这个解析。
评价等级 phigrosScoreToRate(phigros.ts:376-390)对齐 phi-plugin 的 fCompute.rate:
score = 1,000,000 → φ
FC 或 score ≥ 960k → V
≥ 920k → S ≥ 880k → A ≥ 820k → B
≥ 700k → C > 0 → F 否则 → new
注意 V 的判定是”FC 或 ≥96%“,与 maimai 的 FC 分级思路不同——Phigros 的 V 同时涵盖”FC 但不满分”与”没 FC 但高 Acc”两类成绩。展示侧的单曲 RKS 永远是两位小数(formatPhigrosSongRks,phigros.ts:403-405),与总 RKS 的四位小数刻意区分。
数据装配与轻量刷新
useGameData 的 phigros 分支
src/hooks/use-game-data.ts 的 phigros 分支(use-game-data.ts:131-192)是”顶层数据装配”。同步前先 invalidateCache()(use-game-data.ts:133)保证拿到的是最新存档,然后七个请求并行(use-game-data.ts:138-146):
const [player, records, bestSections, gameVersion, summary, userProfile, gameProgress] =
await Promise.all([
scoreProvider.getPlayer(), // 玩家信息 + 总 RKS
scoreProvider.getRecords(), // 全部成绩
scoreProvider.getBestSections(), // Phi3 + Best27 分区
phiCatalog.getGameVersion(), // 当前游戏版本
scoreProvider.getSummary(), // 存档头部
scoreProvider.getUserProfile(), // 玩家档案
scoreProvider.getGameProgress(), // 进度/Data 量
]);
曲库 provider 优先复用会话里已有的 PhigrosCatalogProvider(use-game-data.ts:135-137),避免每次同步都重建实例、重拉 OSS 并误刷新”资源拉取时间”。组装出的 GamePayload(kind=‘phigros’,game-data.ts:46-65)携带 playerScore(label 字面值为 “Raking Score”,代码原样保留)、challengeModeRank、saveUpdatedAt、dataAmount(Data 量字符串)、progress(四难度 C/FC/φ 计数)。
成功后有一串副作用(use-game-data.ts:260-275):回写账号列表(分数、昵称、头像 URL、课题等级)→ 把同样的元数据持久化到 SecureStore → 异步缓存头像。这保证了账号列表页不需要下载存档就能显示最新的 RKS 与课题色。
账户摘要轻量刷新
hydratePhigrosAccountSummaries.ts(共 37 行)是”轻量刷新”的范例:对所有 phi-taptap 账号,只做 getSummary()——它内部只拉 _GameSave 列表 + 解析 base64 summary,不下载 zip、不解密成绩(phigros-score-provider.ts:58-64)。于是列表刷新成本从”每账号解一个几 MB 的加密包”降到”一次小请求”。单个账号失败不阻断其余账号(hydrate-phigros-account-summaries.ts:33-35),失败账号保留上次持久化元数据——慢网络下列表依然可用。
结语
复盘这条链路,几个设计值得单独记住:
- 协议复刻以”官方客户端等价物”为标准:
platform=unity、LeanCloud-CSharp-SDK/1.0.3UA、MAC 签名串、authData.taptap的字段名,全部照抄官方行为——协议能跑通,靠的不是猜,是逐字节对齐。 - 二进制解析的防御哲学:首 varint 不可信、键长超限即断、逐条回退、可选文件失败降级,把”官方加个字段就崩”的概率压到最低。
- 显示值与精确值双轨:四舍五入的显示 RKS 和精确 RKS 之间隔着 ±0.005,B30 推 0.01 与推分模块的 exactTarget 都用同一套”进位门槛”语义,两位小数的 Acc 上取整 + 回代验证杜绝假推荐。
- 全量模拟式推分:不搞近似公式,每次候选评估都真的替换记录重算 Best27 + Phi3,配合 maxPossibleGain 预筛控制成本。
- 模板复刻与字体按需:DOM/CSS 契约对齐 phi-plugin 模板,12 种字体用 SHA-256 双校验 +
.part原子落盘 + Unicode 脚本启发式按需下载,曲绘走 file:// 舞台目录而非 base64——移动端资源工程的每个细节都踩过坑。
Phigros 板块证明了一件事:没有官方 API 的游戏,只要数据还在玩家的设备与云端,逆向出的链路一样可以做到”比官方客户端还好用的查分体验”。
