当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。
尘言头像尘言 · CHNYNNYA

Phigros 板块实现解析:TapTap 协议复刻、云存档解密与推分算法

拆解 rRanker 的 Phigros 板块:复刻 TapTap 设备码登录与 LeanCloud 云存档协议、AES 解密与二进制解析、RKS/B30 计算与全量模拟式推分推荐、像素级复刻 phi-plugin 的成绩图与按需字体工程。

引言

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_idrandStr(32) 随机生成(phigros-auth.ts:105-110,130),响应里带 device_codeqrcode_urlexpires_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 令牌(kidaccess_tokenmac_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 位随机串,uripathname + searchhost 是主机名,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、kidaccess_tokenmac_keyexpires_inplatform: '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)做两层防御:先过滤掉没有 summarygameFile 的空记录(官方存档表里有大量无档占位行),再按 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(必选,缺失直接抛错)、usergameProgress(可选,解析失败置 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 手动转成 WordArrayuint8ArrayToWordArray,phigros.ts:80-91),再以 { ciphertext: ... } 形式传入——crypto-js 的类型定义不认这个运行时契约,代码里专门加了注释说明(phigros.ts:104)。解密结果再转回 Uint8Array(phigros.ts:72-78)。

ByteReader:手写小端二进制读取器

解密后是裸二进制。ByteReader(phigros.ts:8-70)封装了全部读取原语,全部小端

  • getByte:u8
  • getShort / 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) + bodygetGameRecordKey(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):

  1. 首 varint 不可信gameRecord 开头的 varint 只是歌曲数提示,代码注释直言:实测常见值为 27,若以它为循环上限,“会只解析 B27 条而非完整存档”(phigros.ts:313-317)。循环条件只用 r.remaining() > 0
  2. 键长度超 128 直接 break(phigros.ts:325)。曲名键长必然很小,异常长度说明解错位了。
  3. 逐条回退。每一条在进入前记录 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:指向当前版本的 catalogmanifest、可选 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.005gainNeeded = 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)有两条规则:

  1. 先判 Good(Good 不断连击,FC 与 XING-GOOD 兼容)
  2. 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)做了三道校验:

  1. 压缩包大小与 SHA-256 双查(phigros-font-cache.ts:135-141)
  2. 解包后强制要求包内恰好一个文件且名字匹配(phigros-font-cache.ts:143-146)
  3. 字体文件字节数与 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),captureRefbest-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”,代码原样保留)、challengeModeRanksaveUpdatedAtdataAmount(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),失败账号保留上次持久化元数据——慢网络下列表依然可用。

结语

复盘这条链路,几个设计值得单独记住:

  1. 协议复刻以”官方客户端等价物”为标准platform=unityLeanCloud-CSharp-SDK/1.0.3 UA、MAC 签名串、authData.taptap 的字段名,全部照抄官方行为——协议能跑通,靠的不是猜,是逐字节对齐。
  2. 二进制解析的防御哲学:首 varint 不可信、键长超限即断、逐条回退、可选文件失败降级,把”官方加个字段就崩”的概率压到最低。
  3. 显示值与精确值双轨:四舍五入的显示 RKS 和精确 RKS 之间隔着 ±0.005,B30 推 0.01 与推分模块的 exactTarget 都用同一套”进位门槛”语义,两位小数的 Acc 上取整 + 回代验证杜绝假推荐。
  4. 全量模拟式推分:不搞近似公式,每次候选评估都真的替换记录重算 Best27 + Phi3,配合 maxPossibleGain 预筛控制成本。
  5. 模板复刻与字体按需:DOM/CSS 契约对齐 phi-plugin 模板,12 种字体用 SHA-256 双校验 + .part 原子落盘 + Unicode 脚本启发式按需下载,曲绘走 file:// 舞台目录而非 base64——移动端资源工程的每个细节都踩过坑。

Phigros 板块证明了一件事:没有官方 API 的游戏,只要数据还在玩家的设备与云端,逆向出的链路一样可以做到”比官方客户端还好用的查分体验”。

下一篇舞萌DX 板块实现解析:从 Score Hub 同步到 B50 成绩图