嘛,本来只有上下两篇的,上篇讲 JS 相关,下篇试着看看能不能摸出点 WASM 的东西,结果……

这谁顶得住,正好趁这个机会了解一下 SC 音乐资源的编号方式,不也挺好的吗(
ToC
结果
总之今天的目的是(早就)达到了,结果在这里:
demo
main
资源加载
我们知道,SC 有大量的资源需要加载,这在客户端下载资源的时长以及浏览器的各种 Now Loading 就能看出来。
因此,我们推测,一定有一些资源索引文件,记录了当前所有的资源文件信息。而根据源码的阅读,这个推测被证实是正确的。
总索引
由于资源实在太多,因此 SC 将索引分类两层,一层是总索引,索引的是各分索引,而各分索引对应的则是到真正数据的索引。总索引位于 /assets/asset-map.json,加密时的两个输入则为 asset-map.json 和 asset-map,得到的输出是 aac011a61415a220560587aaf0177b2d98e8c7897d7697db4dd51b509162040d。根据索引的命名规则,在路径前面要加上 asset-map-,于是最终得到的路径就是 https://shinycolors.enza.fun/assets/asset-map-aac011a61415a220560587aaf0177b2d98e8c7897d7697db4dd51b509162040d,和观察的结果是一致的。

这时候我们不妨来看看这个文件的内容:

挺标准的分 chunk,还记录了 version 和 totalSize。这里 totalSize 估算了一下,快 6 个 G 了。

分索引
分索引记录的就是实际的素材了,像这种:

这个 Object 对应的 value 项就是资源的版本了,因此我们可以通过后者来判断资源是否需要更新。
最后,对于获取到的数据,SC 会把它们统一都塞进 hashMap 里。生成一个巨大的 Object,如下图所示(浏览器卡爆了,所以只截了一点):

资源的分类
在我们可以获取明文返回内容之后,我们首先想要知道的就是资源的具体分类方式。通过上面的图片我们也不难发现,SC 的资源本质是以目录的形式组织的,比如 ae/common/com_eff01_front_catastrophe/data.json 这种,对应的实际路径就是 https://shinycolors.enza.fun/assets/----经过 encryptPath 的字符串---(当然了,如果对应的后缀名较为特殊,比如 .m4a、.mp4 之类的会保留后缀名)。
那这里我们可以观察到,资源的路径经历了一次 hash。在上一篇中,我简单地认为只要直接 hash 就可以了,但事实是并不能。来看代码:
N = { createImagePath: function (e, t, n) { var r = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : ""; return i.default.join(m, e, t, x(e, t, n, r)); }, createMoviePath: function (e, t, n) { var r = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : "", o = !(arguments.length > 4 && void 0 !== arguments[4]) || arguments[4], a = i.default.join(_, e, t, R(e, n, r)); return o ? this.getEncryptedMoviePath(a) : a; }, getEncryptedMoviePath: function (e) { var t = i.default.basename(e, C), n = i.default.join(l.default.env.ASSET_ROOT, e), r = p.default.getQueryString(n); return ( l.default.env.ENABLE_CRYPTO && (e = f.default.encryptPath(n, t) + C), r && (e += r), e ); }, createSpinePath: function (e, t, n) { return i.default.join(y, e, t, O(e, n), T); }, createVoicePath: function (e, t, n) { var r = t || n ? e + "/" + M(e, t, n) : "" + e + M(e, t, n); return i.default.join(v, r); }, createConcertMusicPath: function (e, t) { return i.default.join(E, e, "" + O("unit", t) + w); }, createTipsImagePath: function (e) { return i.default.join(P, "" + e + S); }, createAdminImagePath: function (e, t, n) { return i.default.join(this.createAdminFolderPath(e, t), "" + n + A); }, createAdminFolderPath: function (e, t) { var n = i.default.join(g, e, O(e, t)); return n; },};(这里的行号对应的是 Chrome 格式化后的行号,即 debugger:///VM493 pure-app-57696458079a3b7abba5.js.map:formatted。)
我们不妨分别来看。
图片
图片对应的函数是 createImagePath,可以看到,关键的函数是 x(e, t, n, r)。我们来看:
N = { createImagePath: function (e, t, n) { var r = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : ""; return i.default.join(m, e, t, x(e, t, n, r)); },};
x = function (e, t, n) { var r = arguments.length > 3 && void 0 !== arguments[3] ? arguments[3] : "", o = O(e, n), i = (b[e] && b[e][t]) || A; return "" + k(r) + o + i;};这里 r 对应的是 hash,o 对应的是原名,i 对应的是后缀名,而 k(r) 函数输出的结果实际是 r !== '' ? r + '_' : r。所以我们可以发现,对于图片的路径,在 encryptPath 之前,需要将其加上对应的 hash 值。那 hash 怎么来呢?
经过进一步的挖掘,我们发现,hash 是随着 api 实时传给我们的,但这就出现了不一致了。如果 hash 只有在你有那张卡的时候才能拿到,那么客户端又是怎么缓存资源的呢?于是我们发现了 hashResources 和 getHashPrefixAssets,并得到了如下的结果:

有了这份 hash 表,我们就可以简单地计算出对应的真实路径了。
视频
视频的处理其实同理,虽然函数表示形式不同,但实际上和图片是一样的。这里就不再赘述了。
卡面 ID 的编号方式
我们(或许)知道,偶像是有其对应编号的,现在是从 001 到 023,因此对应的卡面也有编号。我们知道,卡面有 R、SR 和 SSR 三种,其对应的 rarity 在代码中分别是 2、3 和 4。于是某一张卡面的 ID 就会遵循如下的格式:
1 0 3 001 001 0 ^ ^ ^ ^ ^ ^[CardType] [Category] [Rarity] [IdolID] [CardNum] [Unknown]这里的 CardType 代表的是 P 卡还是 S 卡,1 为 P 卡,2 为 S 卡;Category 代表的是分类,目前 0 代表普通卡,9 代表 IDOLROAD;Rarity 就是上面所说的 2~4(但其实是有 1 的,见下文),Idol ID 也是同理,Card Num 从 1 开始,跟随 Rarity 大分类而增加。最后的 0 就意味不明了,至少现在大家的最后都是 0(
(根据 Hash 表,S 卡里有四张以 201 开头的卡,游戏里也找到了。这四张卡相当“至少得有”的 Support 卡(如果你抽到的都是 P,虽然概率很低,但是这样的话你就没 S 卡了(或者说抽到的 S 卡数量不足 4 张:

结语
今天是在上次的基础上进一步的探索,在有了路径加密之后,SC 在此之上又增加了一层保护。然而世上总没有不透风的墙,客户端的出现也使得 hash 值这层保护的意义渐渐模糊了起来。
或者逻辑有可能恰恰是反过来的:SC 在原本没有路径加密的时候使用的就是 Hash,而后来才转移到以 WASM 为基础的路径加密上来,同时保留了过去的保护手段。或许是怕删除之后又出什么岔子,又或者只是单纯的想多一层保护手段,这或许只有 enza 的程序才知道了吧。
嘛,总而言之,现在我们已经可以下载所有的数据文件了。之后如果有空或许会搞一个查卡器什么的,不过这都是后话了。
总之,就到这里,去写操作系统的作业了,溜了溜了(