API 目前处于 Early Preview 阶段, 可能存在诸多不完善与错误之处,欢迎加群交流反馈:加入官方交流群(1073434398)
osu!
osu! 的 pp 体系、Best 100 加权规则,以及 profile / score 的统一字段与原生字段(native);songId 是 beatmap id、difficulty 是模式名。
osu! 的原生评分叫 pp(performance points):单值浮点、无硬上限;档案总评是加权和,不是算术和。
它也是 10 个游戏里唯一一个 ratingField = null 的:单曲 pp 不在独立列里,而在成绩的原生对象里。
机器可读版本
本页字段表与服务端 GET /v1/games/osu/format 一一对应(profile.identityFields /
profile.fields / profile.nativeFields、score.keyFields / score.compareField /
score.ratingField / score.fields / score.nativeFields / score.buckets)。文档可能滞后,以端点返回为准。
Profile 格式
一张 osu! 档案 = 一个 RankHub 用户在 osu! 上的一张卡,且绑定一个主模式(primaryMode)。
对外主键是 profileId(uuid)。
身份字段(建档用)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
osuUserId | string | 是 | bancho 用户数字 id:正整数字符串(^[1-9][0-9]*$,须是安全整数);对外按字符串透传,避免大整数失真 | "12345678" |
- 缺失/形状不对 ⇒
400(文案见写入格式 §4); player.name必填(展示名,原生username),属于下面的统一字段。
统一字段(player)
| 字段 | 类型 | 说明 |
|---|---|---|
name | string | null | 玩家名(原生 username),建档必填(可来自原生 username) |
rating | number | null | 总评 pp。取主模式那一格的官方统计;那一格为空时由分析层用加权和(Σ pp·0.95ⁿ,不含 bonus)补上 |
主模式陷阱
官方统计是按模式索引的 map(osu / taiko / fruits / mania 各一格)。
player.rating 只取 primaryMode 那一格;别的模式的 pp 在 summary 里,不在这个字段上。
原生字段(player.native)
| 字段 | 类型 | 说明 |
|---|---|---|
osuUserId | number | null | bancho 用户 id(原生 id,数字) |
countryCode | string | null | 国家码(原生 country.code;country.name 丢掉了) |
primaryMode | string | null | 主模式:osu / taiko / fruits / mania |
globalRank | number | null | 主模式的全球排名(原生 global_rank;country_rank 丢掉了) |
accuracy | number | null | 主模式平均准确率,量纲 0~1 |
playCount | number | null | 主模式游玩次数 |
没透出的原生字段:avatar_url、cover_url、join_date、last_visit、level、grade_counts、
hit_counts、play_time、maximum_combo、total_score / ranked_score 等。
Score 格式
成绩键与比较值
| 项 | 值 |
|---|---|
成绩键 keyFields | scoreId(wire 顶层没有能重建写侧键的字段,见下) |
| 写侧存储键(原生) | mode + upstreamId |
比较字段 compareField | rating(原生 payload 的 pp) |
| tie-break | score(原生 payload 的 total_score) |
单曲评分字段 ratingField | null |
mode:osu/taiko/fruits/mania,wire 上落在score.native.mode;upstreamId:上游成绩 id(十进制字符串),不在 v1 响应里 ——scoreId是 RankHub 的行 id(uuid);ratingField = null表示「没有独立的单曲评分列」:pp 存在成绩的原生对象里,由 wire 的rating读出来。
统一字段
| 字段 | 类型 | 说明 |
|---|---|---|
scoreId | string | 成绩行 id(RankHub uuid) |
songId | string | beatmap id(原生 beatmap_id)—— osu 没有「曲目」这一层 |
songName | null | 恒 null:上游成绩不内联曲名(要自己解 beatmap / beatmapset) |
difficulty | string | null | 模式名(osu / taiko / fruits / mania),不是难度 |
achievements | number | null | 准确率,量纲 0~1 |
score | number | null | 本次得分 total_score(v1 / stable 计分,约 0 ~ 1,000,000) |
rating | number | null | 单曲 pp(加权前) |
rank | string | null | 字母评级:XH / X / SH / S / A… |
playTime | string | null | 结束时间(原生 ended_at,RFC3339) |
native | object | null | 原生字段,见下表 |
恒为 null(原生没有或无映射):type、levelIndex、level、dxScore、dxStar、fc、fs。
原生字段(score.native)
从上游成绩对象里挑选透传的一组稳定字段(不是整份上游对象):
| 字段 | 类型 | 说明 |
|---|---|---|
mode | string | null | 模式名(成绩键的一半) |
rulesetId | number | null | 模式的整数 id:0=osu / 1=taiko / 2=fruits / 3=mania |
position | number | null | 该条在榜内的位次(本平台派生,上游不返回) |
maxCombo | number | null | 本次最大连击 |
passed | boolean | null | 是否通过 |
weightPercentage | number | null | 该条在玩家序列里的权重百分比(原生 weight.percentage) |
weightPp | number | null | 该条的加权后 pp(原生 weight.pp) |
weight 只在「best 类型」的响应里出现 —— 也就是本端点这种场景;其它来源拿不到,只能自己算 0.95^(n−1)。
评分语义与量纲
单曲 pp 是两段式:先算 star rating,再按模式各自的公式转 pp。四个模式公式不同:
- osu!standard:
Norm(1.1; aim, speed, accuracy, cognition) × 1.12(NF/SO/Relax 另有修正), 其中Norm(p, values) = (Σ valuesᵢ^p)^(1/p); - taiko:
difficultyValue × 1.08 + accuracyValue; - catch:单链路
value = (5·max(1, SR/0.0049) − 4)² / 100000,再乘长度/连击/AR/Hidden/Flashlight/accuracy 修正; - mania:
8 · max(SR − 0.15, 0.05)^2.2 · max(0, 5·acc − 4) · 长度加成 · mod 乘子—— 唯一不用Norm合成的模式。
档案总评(官方口径):
total pp = pp[1]·0.95⁰ + pp[2]·0.95¹ + … + pp[m]·0.95^(m−1) + bonus
bonus = 416.6667 × ( 1 − 0.995^min(N, 1000) ) // 上限 413.894,N = 已设置成绩的 Ranked 图数pp[n]= 按 pp 降序排第 n 位的成绩;总 pp 不是算术和;- 官方排名页的 total pp 含 bonus;只加权不加 bonus 会系统性偏低(最多 413.894);
- pp 公式每次改版都会全站重算,所以历史成绩的
pp是「当前算法的值」,不是当时的快照。
本平台的 totals.pp 是另一个数:
| 值 | 含义 |
|---|---|
player.rating | 官方主模式统计里的 pp(含 bonus) |
bests.totals.pp | 桶内各条 rating(= pp)的算术和 —— 不做 0.95ⁿ 加权,也不含 bonus |
buckets[].total | 同 totals.pp(单桶,两者相等) |
所以「totals.pp 与官网 total pp 不一致」是预期行为,不是 bug。
精度:四个模式全程 double,官方不做位舍入;展示层四舍五入到整数 pp。
最佳成绩桶
GET /v1/games/osu/profiles/{profileId}/bests 返回一个桶,key 里带模式名:
key | label | 成员规则 | totalsKeys |
|---|---|---|---|
best100-<mode> | Best 100 (<mode>) | 上游「最佳成绩」序列(按上游 position 序)取前 100 条;模式即档案主模式 | pp |
- osu 官方没有 Best N 分桶:加权序列是全量降序,只是
0.95ⁿ让第 100 名之后的权重小于 0.6%; - 上游最佳成绩面的上限就是 100 条,本平台的
/scores与/bests共用同一份数据; computedAt取档案的上次同步时间(不是本次计算时间);- 模式是可变的:换主模式后
key的后缀会变,客户端不要缓存旧 key 的具体模式。
写入(第三方)
请求体形状、idempotencyKey、批量上限、428/403 等通用规则见写入格式;
这里只写 osu 真正特殊的部分。
两种写形状
- RankHub 格式:直接给统一字段(
songId/mode/rating/accuracy/score…); - 游戏原生格式(
native):player.native/scores[].native里放原生形状,服务端按唯一一套换算归一, 与顶层同名字段同时出现时native优先;不合法 ⇒400 common.invalidArgument(message指明键)。
本游戏的写入规则
- 必给:
songId(beatmap id)+mode(强烈建议显式给,缺省osu)+rating(pp)+accuracy(0~100)+score。 ❗mode的取值是fruits,不是catch(传catch⇒400)。 - 比较值是
rating(pp),不是score:pp相等时才用score(total_score)做 tie-break。 这与其他游戏「比score」不同,写错字段会「明明分更高却没覆盖」。 rating缺失不会 400:服务端当null落库,这条成绩就没有 pp、不进入加权,所以 pp 一定要自己给。accuracy是 0~100 百分数,不做 0~1 换算(写0.98会被当成0.98%)。- 唯一键:
mode+ 上游成绩 id(缺省等于 beatmap id),同一 beatmap 在同一模式下重复推送会互相覆盖(按 pp 取优);difficulty字段对 osu 无意义(模式走mode)。 position由服务端重排:不要试图指定榜内位次。- 谁算分:单曲
rating(pp)由调用方给(maimai / arcaea 之外的游戏都是调用方给,只有那两者服务端算); 档案总评与桶合计由服务端按主模式重算。 - 通用上界(写入守卫开启时生效,默认
off):score/achievements越界400,rating/accuracy越界或playTime晚于现在 1 小时422。
原生数据(native)
建档:原生 User 对象里只需要两个键。
native 键 | 类型 | 换算 |
|---|---|---|
id | 正整数(int),必填 | osuUserId(转成字符串) |
username | string,必填 | name |
country / playmode / avatar_url / cover_url … | — | 忽略 |
成绩:原生 Score 记录的键名(snake_case)。
native 键 | 类型 | 换算 |
|---|---|---|
beatmap_id | 正整数,必填 | songId(谱面 id 即曲目 id) |
mode 或 ruleset_id | osu/taiko/fruits/mania,或 0~3 | mode(mode 优先,缺了才用 ruleset_id) |
accuracy | number 0~100 | accuracy:原生口径是 0~1,≤1 会 ×100 转成百分数;>1 视为已是百分数 |
pp | number 0~100000 | rating(单曲 pp) |
total_score / classic_total_score | 整数 | score(total_score 优先) |
rank | string | rank |
max_combo | 整数 0~100000 | combo |
passed | 布尔 | cleared |
ended_at | ISO 字符串 | playTime |
id | 数字或字符串 | 上游成绩 id(成绩键的一半) |
position | 整数 0~1000000 | position |
statistics / maximum_statistics / mods / weight / beatmap / beatmapset | — | 忽略 |
accuracy与顶层方向相反:native.accuracy按原生口径给0~1(自动 ×100),顶层accuracy要给0~100;- 可写字段(
native,除身份与唯一键外全部):profile 侧username(→name)、country_code、avatar_url、primary_mode、statistics.global_rank、statistics.accuracy、statistics.play_count(建档或PATCH /v1/games/osu/profiles/{profileId}的native;统计三项是点路径, 平铺的global_rank不会被读取);score 侧除beatmap_id外的全部键。 - 不可改:身份
osuUserId(原生id)——PATCH的native里给了不同的id⇒400,message=native.osuUserId cannot be changed (identity field);成绩唯一键beatmap_id顶层与 native 都给时必须一致,不一致以 native 为准(会写到另一张谱面上)。
示例(示意数据) —— 一条推成绩请求(原生格式):
curl -sS -X POST https://api.rankhub.cc/v1/games/osu/profiles/$PROFILE_ID/scores \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scores": [
{
"native": {
"beatmap_id": 3921874,
"ruleset_id": 0,
"accuracy": 0.9823,
"pp": 312.47,
"total_score": 998214,
"rank": "SH",
"max_combo": 1240,
"passed": true,
"ended_at": "2026-10-20T18:22:11Z"
}
}
]
}'消费者注意事项
songId是 beatmap id,不是歌曲 id:同一首歌的不同难度是不同的 beatmap,songId也就不同; 要显示曲名得自己解beatmapset。difficulty是模式名(osu/taiko/fruits/mania),不是难度;别拿它做难度过滤(服务端对 osu 的filter.difficulty直接忽略)。type/levelIndex/level无意义(恒null),不要当「难度下标」用。ratingField = null:单曲 pp 不在独立列,而在原生对象里;wire 的rating是它的投影。totals.pp不是官网 total pp:它是桶内 pp 的算术和,不做0.95ⁿ加权、不含 bonus(上限 413.894)。- 成绩面 = Best 100:v0 面只有「某模式的 best100」,没有全量成绩列表,所以
/scores返回的就是这 100 条。 position由服务端重排:推成绩时不要自己指定position。achievements是 0~1,但推成绩的accuracy要 0~100;给achievements时≤1按 0~1 解释、否则按百分数解释 —— 两个方向口径不同。computedAt是同步时间,不是本次计算时间;lastSyncedAt为null时退化为服务端当前时间。- 没有历史表:成绩行是覆盖式的,拿不到「这条成绩以前是多少」。