VRChat 跨实例世界排行榜实现技术解析
VRChat ワールド制作でインスタンスをまたぐことができる「世界ランキング」を作った話
前言
你好,我是沉迷于VRChat开发的VRC老玩家,在VRC中的名字是hichi2025,在Github上则是haru452。
我从大约一年前开始沉迷Unity,并从事应用开发等工作。
这次我制作了VRChat(简称VRC)的世界,并打算在这里说明其中使用的世界排行榜是如何制作的。
Hello! If you came to this article from my world in VRChat: this article is written in Japanese, so please use Google Translate, or send me a message on Instagram (homaredazeyolov8). Thank you!!
你好!如果你是從我在 VRChat 製作的世界來到這篇文章的,請注意這篇文章是用日文寫成的。因此,請使用 Google 翻譯,或透過 Instagram 傳訊息給我(homaredazeyolov8)。謝謝!!
關於我自己製作的世界
我正在VRChat中制作名为[WIP]佩拉戈斯群岛(Pelagos Islands)的世界。这是一个从潜水艇出动战斗机进行空战的世界。
我在这个世界中创建了一个跨实例、竞争最高分的世界排行榜。在构建这个排行榜时,VRChat中存在“实例关闭后信息会消失”以及“无法显示未登录玩家的信息(虽然可以为每个玩家保存该世界中的分数,但一旦玩家退出就无法保留数据)”等问题,因此需要解决这些限制。为此,我通过将数据发送到外部服务器进行保存的机制来实现这个排行榜。
在这篇文章中,我想说明我是如何实施这种排行榜的。
关于世界排行榜
首先,详细介绍我自己制作的世界排行榜。
- 是否参与排行榜由自己选择(只有开启开关的人才会发送)。初始状态为关闭。
- 仅发送最高分和所选称号编号。不发送名称(VRChat 显示名)。
- 不保存IP地址。为了“将同一人的记录汇总为一条”,我们会将其转换为不可逆的形式(加盐哈希)后使用。
- 显示的仅有分数、日期、国家/地区、称号。
整体架构
VRChat(ワールド) サーバー(Cloudflare Workers) GitHub Gist
─────────────── ────────────────────── ─────────
最高点が上がった ── /s/<点数> ───────→ 記録を受け取る
称号を選んだ ── /b/<番号> ───────→ (上位 50 件だけ覚える) ── 上位 5 件 ──→ scores.json
│
ランキングを表示 ←────────────────────── 読む ────────────────────────────┘
- 从世界(World)发送的仅是
https://〜/s/123这样的单次打开URL(VRChat中的VRCStringDownloader)。URL中仅包含分数(或称号编号)。 - 世界读取排行榜的方式并非通过服务器,而是GitHub Gist。Gist 中仅列出了前5名的“分数、日期、国家/地区、称号”。
- 在VRChat中,除了指定位置(如GitHub等)之外的URL,只有开启了设置中的“允许不受信任的URL”的人才能访问。因此,能够发送记录的仅限开启了此设置的用户(任何人都可以查看排行榜)。
首先,什么是IP地址
IP地址是分配给连接到互联网的设备上的类似“地址”的数字(例如:203.0.113.45)。在浏览网站或使用VRChat时,对方的服务器必须接收这个号码,以便知道“应该将回复发送到哪里”。也就是说,任何服务器在通信到达时都能看到IP地址。
然而,从IP地址中通常只能了解到以下信息。
- 来自哪个国家、哪个地区,以及使用哪家提供商(线路公司)连接
- 姓名、住址或VRChat账号等无法从IP地址得知
尽管如此,如果长期保存IP地址并将其与其他信息结合使用,就可能成为追踪“同一个人何时做了什么”的线索。因此,在本排行榜中,我们决定不保存IP地址,仅在必要时以不可逆的形式使用。
为什么要使用 IP 地址
在制作排行榜时,我遇到了两个问题。
-
同一用户的记录会连续排列
由于不发送姓名,服务器无法知道“这是谁的分数”。如果不做任何处理,当一个人将分数从 100 分、120 分、150 分……逐步提高时,每次记录都会作为单独的一行排列。 -
无法应对捣乱行为
如果反复打开 URL,就可以用恶作剧填满排行榜。
为了解决这两个问题,我只想区分“发送方是否为相同的连接源”。为此使用连接源的 IP 地址。不过,不会保存 IP 地址本身。
不保存 IP 地址的机制:加盐哈希
在服务器中,接收到的 IP 地址会按以下方式转换后使用。
// サーバーの中だけにある、ランダムな文字列(塩)。最初に 1 回だけ作って、サーバーの中に保存する
let salt = await s.get("salt");
if (!salt) { salt = crypto.randomUUID(); await s.put("salt", salt); }
// 接続元の IP アドレス + 塩 → SHA-256 → 先頭の 16 文字だけ使う
const ip = request.headers.get("cf-connecting-ip") || "unknown";
const h = await hash(salt + ip);
async function hash(text) {
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(d)].slice(0, 8).map(b => b.toString(16).padStart(2, "0")).join("");
}
接下来,我们将依次说明此代码中所做的“加盐哈希”。
什么是哈希
哈希(此处指 SHA-256 这种计算方法)是一种将任意长度的字符串转换为固定长度、看似随机的字符序列的计算方法。它具有以下特性:
- 相同的输入始终产生相同的结果
- 只要有一个字符不同,结果就会完全不同
- 从结果中无法还原原始字符(计算是单向的)
例如,IP 地址最后一位数字仅相差 1,结果就会完全不同(两者均为示例用 IP 地址。在此世界中,仅使用结果的开头 16 个字符)。
| 原始字符 | SHA-256 的结果(前 16 个字符) |
|---|---|
203.0.113.45 |
eb4b2dd34d5ba9af |
203.0.113.46 |
679c14e80e44a670 |
得益于性质 1,只需比较哈希值,就能判断“这些记录是否来自同一个 IP 地址”。得益于性质 3,即使查看哈希值,也无法得知 IP 地址。
仅靠哈希不够的原因
你可能会想:“既然无法还原,那直接做成哈希不就行了吗?”然而,IPv4地址总共只有约43亿种可能。对于如今的电脑来说,进行43亿次计算只需几分钟到几十分钟即可完成。
也就是说,获得哈希 eb4b2dd34d5ba9af 的人可以执行以下操作:
“将 0.0.0.0 到 255.255.255.255 的所有地址全部进行哈希计算,寻找能生成
eb4b2dd34d5ba9af的结果”
这样就能找到原始的IP地址(这被称为 暴力破解)。这不是“还原”,而是“尝试所有可能性以匹配”的方法。
盐(Salt)是指
因此,在哈希处理之前,会将仅服务器知道的随机字符串(盐)附加到IP地址上。这就像在料理中加入一小撮秘密调味料一样。
| 原始字符 | SHA-256的结果(前16个字符) |
|---|---|
203.0.113.45 |
eb4b2dd34d5ba9af |
salt-example-7f3a + 203.0.113.45
|
175fa93aaae80674 |
(salt-example-7f3a 是用于说明的盐。真正的盐是一个更长的随机字符串)
加入盐后,即使 IP 地址相同,结果也会完全不同。即使尝试暴力破解,如果不知道盐,就“不知道该附加什么进行计算”,因此即使尝试全部 43 亿种组合,也无法生成相同的哈希值。
- 盐是服务器在最初使用
crypto.randomUUID()一次性生成的随机字符串,仅保存在服务器内部(代码和 Gist 中均未公开)。 - 来自同一用户(同一 IP 地址)的记录每次都会使用相同的盐,因此会生成 每次相同的哈希值。所以可以“汇总同一用户的记录”。
- 对于不知晓盐的外部人员来说,没有任何方法可以从哈希值中推断出 IP 地址。
总结来说,加盐哈希是一种用于实现「能够识别是否为同一人,但无法得知其 IP 地址」状态的机制。
服务器仅保存该哈希值以及分数与称号的组合。
// 覚えている記録(上位 50 件だけ)
{ h: "a3f09c1e7b2d4458", s: 150, d: "2026-11-08", c: "JP" }
// 選んだ称号(ハッシュごとに番号を 1 つ。最大 500 人分)
{ "a3f09c1e7b2d4458": 3 }
| 項目 | 中身 |
|---|---|
h |
来自连接端的加盐哈希(无法还原为 IP 地址) |
s |
点数 |
d |
输出该分数的日期 |
c |
国家代码(下文说明) |
| 称号 | 对于每个哈希值,选择称号的编号(称号会单独于记录发送,因此通过相同的哈希值将称号与记录关联起来) |
如果从同一个哈希值收到新的记录,则不增加行,仅覆盖分数。这样可避免同一人的多条记录重复排列。
关于国家显示
排名中会显示国家名称(如“日本”等)。这是因为服务器运行的 Cloudflare 直接使用从连接来源判断出的国家代码(JP 等)。
const c = (request.cf && request.cf.country) ? String(request.cf.country).slice(0, 2) : "";
- 能知道的是 仅为国家 。都道府县或市町村等详细位置,既未保存也未显示。(其实我自己有时在被问到“你住在哪个县”时会撒谎,所以担心一旦被发现就不好了——不过理论上是可以知道都道府县的)
- 使用 VPN 的人将显示为该 VPN 所在的国家。
公开的数据
World 读取的 Gist(任何人都可查看)中,仅记录前 5 名的以下信息。
{"top":[{"s":150,"d":"2026-11-08","c":"JP","b":3}]}
- 仅有分数、日期、国家代码和称号编号。并未导出哈希值。
- 在世界中显示名称的情况仅限于「当前处于同一实例的人的最高分与排名分数相同时」。这仅在世界内部进行计算,不会将名称发送至服务器。
防破坏措施
使用哈希值限制来自相同连接源的发送次数。
- 最多每 10 秒一次
- 每天最多 100 次
- 分数范围为 0 至 3000 分(不接受无法在世界中获得的分数)
这个计数表仅存在于服务器内存中,日期变更时会被清除。
无法做到的事・需要注意的事项
老实说明一下。
- 发送记录之后无法撤销。 世界的开关可以随时关闭,但一旦关闭就会变成“今后不再发送”。
- 哈希值的生成仅在服务器内部进行。服务器所有者(我)持有盐值,理论上可以通过暴力破解来查找 IP 地址。我不会这么做(即使知道了也没什么用处……)。我也没有为此构建任何机制。只是需要说明一点:这并非“在数学上绝对无法逆向还原”。
- 由于服务器运行在 Cloudflare 之上,通信本身会经过 Cloudflare(与浏览普通网站时相同)。
- 由于同一家庭或同一网络的用户可能拥有相同的IP地址,因此可能会被合并为同一行。
汇总
| 发送 | 保存 | 公开 | |
|---|---|---|---|
| 名前(表示名) | ✕ | ✕ | ✕(仅对同一实例的用户在世界中显示) |
| IP 地址 | (因为是通信,所以会收到) | ✕(仅盐值哈希) | ✕ |
| 点数・日期 | ○ | ○ | ○ |
| 国 | (由 Cloudflare 判断) | ○ | ○ |
| 称号 | ○ | ○ | ○ |
我们仅将 IP 地址转换为不可逆的形式使用,目的是“汇总同一用户的记录”和“防止骚扰”。如果感到不安,保持开关关闭就不会发送任何内容。
如果有问题,请通过 VRChat 的 hichi2025,或者本文的评论区或 VRC 中我自己的私信(homaredazeyolov8)来询问。
后记
我是一名信息学部的学生,当 Claude 提出诸如盐值哈希之类的概念时,我产生了“这是在课堂上学过的!”和“这是进研ゼミ里做过的!”这样的感觉,觉得很有趣。
来源:Qiita · Unity 开发社区 · qiita.com