声明:本项目为个人学习与技术研究用途,请勿用于商业目的,不得将本项目中任何内容用于违反国家/地区/组织等的法律法规或相关规定的其他用途。
分析目标:libil2cpp.so(BanG Dream! v9.4.1,IL2CPP v31,ARM64)
方法:IDA Pro 反编译,global-metadata.dat元数据解析,Il2CppDumper 交叉验证
日期:2026-07-09
简要结论
游戏逻辑跑在 Unity 的 Update() 回调中。Update() 的调用频率跟随 VSync,实际判定频率取决于屏幕刷新率。
游戏进度和技能 timer 都在 Update() 里跑,和渲染共用一线程,因此游戏判定帧率即为设置设定的fps值。deltaTime 在60FPS时为1/60,在120FPS时为1/120,deltaTime最大值为1/60
游戏每帧检查哪些 note 的 absolutePos 已被 MusicPos 越过。技能的行为按时间顺序分为四个阶段(以auto为例):
- 触发帧(1 帧):本帧
NoteManager.Update(先执行)中,技能 note 过判定线 →forcePerfect将技能数据写入playList并触发状态切换为 Begin。随后ExecUpdate(后执行)Case 1 从playList弹出数据,设置skillTimer = duration,状态切换为 Playing。判定时技能指针仍为 null,因此本帧内所有 note 均无技能加成,不递减。 - 递减帧(帧数由
timer > 0逐帧决定):触发帧的下一帧开始。每帧NoteManager.Update先判定(此时currentPlayingSkillData非 null → 享受加成),然后ExecUpdateCase 2 先判断timer > 0→ 通过 → 执行timer -= deltaTime。共递减 420 次后 timer ≈ 0。 - 结束帧(1 帧):timer ≈ 0 的那一帧。
NoteManager.Update先判定——此时currentPlayingSkillData仍非 null(前一帧只递减未调FinishSkill)→ 最后一次加成。随后ExecUpdateCase 2 检测timer ≤ 0→FinishSkill→currentPlayingSkillData = null,状态切换为 Finishing。本帧case2进入结束技能分支,不进行递减。 - 普通帧(此后所有帧):
currentPlayingSkillData == null,note 不再携带技能倍率。
1. 位置系统
1.1 absolutePos
NoteInformation(TypeDefIndex: 844)的实例字段 absolutePos 位于偏移 0x40。
构造函数 NoteInformation..ctor() 在 0x2B24694,计算逻辑:
// a3[0]=barIndex, a3[1]=numerator, a3[2]=denominator
// CONSTANT = NoteManager.MUSIC_BAR_DIVISION_COUNT,通过 off_64EA430[23] 读取
absolutePos = numerator * CONSTANT / denominator + CONSTANT * barIndex;对应汇编(0x2B24810):
LDR X8, [X0,#0xB8] ; off_64EA430 基址
LDR W8, [X8] ; CONSTANT (int32)
MUL W15, W12, W8 ; numerator * CONSTANT
SDIV W15, W15, W13 ; / denominator
MADD W15, W8, W11, W15 ; + CONSTANT * barIndex
STR W15, [X1,#0x40] ; this->absolutePosCONSTANT 的身份:
- 所属类:
NoteManager(TypeDefIndex: 851) - 字段名:
MUSIC_BAR_DIVISION_COUNT - 声明:
public static readonly int MUSIC_BAR_DIVISION_COUNT; - 访问路径:
off_64EA430[23],即 Il2CppClass 偏移0xB8处的static_fields指针解引用
CONSTANT 的具体数值无法从静态分析读出——BSS 区域在 IDB 中未初始化,元数据中无 FieldDefaultValue,NoteManager 无静态构造函数。不过这不影响后续结论,因为 CONSTANT 在所有判定帧公式中会被约掉(见第 4.3 节)。
1.2 GetBarSeconds
NoteUtility.GetBarSeconds(),0x2C013EC:
float GetBarSeconds(float bpm) {
return 240.0f / bpm;
}汇编即 0x2C01440 处的 FDIV S0, 240.0, S0。
1.3 GetSecWithDistance
NoteUtility.GetSecWithDistance(),0x2C00ED8:
float GetSecWithDistance(float distance, float bpm) {
float barSec = GetBarSeconds(bpm);
return (barSec * distance) / (float)CONSTANT;
}CONSTANT 同样来自 off_64EA430[23]。
2. 音符判定
2.1 MoveState
NoteSingleBase.MoveState(),0x2FC0F78:
void MoveState(NoteSingleBase* this, float deltaTime) {
UpdateBase(this, deltaTime);
float MusicPos = GetAdjustMusicPos(noteManager);
float notePos = (float)noteInfo->absolutePos;
if (MusicPos >= notePos) {
this->accumulatedTime += deltaTime;
if (IsAutoPlay())
forcePerfect(); // vtable[89]
else if (gameState == 14)
forcePerfect();
else if (this->accumulatedTime > TIMEOUT)
ChangeState(Wait/Stop); // vtable[87]
} else {
this->accumulatedTime = 0;
}
}核心指令:
| 地址 | 指令 | 说明 |
|---|---|---|
0x2FC1020 | FSUB S0,S9,S0 | MusicPos - notePos |
0x2FC1024 | FCMP S0,#0.0 | |
0x2FC1028 | B.GE loc_2FC1038 | 大于等于即触发(非严格大于) |
0x2FC1044 | STR S0,[X19,#0x14C] | accumulatedTime 在偏移 0x14C |
0x2FC1050 | BL sub_3320E88 | IsAutoPlay |
0x2FC1054 | TBNZ W0,#0,loc_2FC1078 | 自动模式直接 forcePerfect |
自动打歌时,MusicPos 大于等于 notePos 即判定 Perfect,不经 GetResult/JudgeNote 等手动判定流程。
3. 技能系统
3.1 SituationSkillManager 结构
| 偏移 | 字段 | 类型 |
|---|---|---|
0x70 | situationSkillDataPlayList | List<SituationSkillData> |
0x80 | currentPlayingSkillData | SituationSkillData* |
0x88 | skillTimer | float |
0x8C | skillFinishingTimer | float |
0x90 | skillPlayState | int(0=Idle, 1=Begin, 2=Playing, 3=Finishing) |
SituationSkillMaster.duration 位于偏移 0x30,float 类型,来源为服务器下发的 MasterSkill.duration,在 GetMasterSkillData(0x343263C)中通过 *(v14 + 0x30) = *(v11 + 0x18) 拷贝。
3.2 processOfSkillTriggered
0x3352C50:
void processOfSkillTriggered(SituationSkillManager* this, uint frameCounter) {
SituationSkillData* data = this->currentPlayingSkillData; // offset 0x80
SituationSkillMaster* skill = data->skill; // offset 0x18
this->skillTimer = skill->duration; // offset 0x88 = skill+0x30
}技能从 playList 弹出后调用,将 master 数据中的 duration 写入 skillTimer。
3.3 ExecUpdate
0x3351C64,每帧调用一次。Switch 分发(0x3351CB4):
LDR W8, [X20,#0x90] ; skillPlayState
CMP W8, #3
B.EQ case3 ; 3 = Finishing
CMP W8, #2
B.EQ case2 ; 2 = Playing
CMP W8, #1
B.NE default_ret ; 0 = Idle, 直接返回
; fall-through 到 case 1 (Begin)对应 C 伪代码:
// void ExecUpdate(SituationSkillManager* this, uint frameCounter, int gameState)
switch (this->skillPlayState) {
// Case 1: Begin(由 forcePerfect 触发:forcePerfect → sub_3352370 → changeState(1))
case 1: {
if (this->playList.Count > 0) { // [this + 0x70]
// 从队列弹出
this->currentPlayingSkillData = DequeueFromPlayList(); // [this + 0x80]
processOfSkillTriggered(frameCounter); // 设置 skillTimer = duration
changeState(2); // → Playing
} else {
// 队列为空(不应发生)
this->currentPlayingSkillData = null;
changeState(0); // → Idle
}
return;
}
// Case 2: Playing(递减帧 / 结束帧)
case 2: {
float timer = this->skillTimer; // [this + 0x88]
if (timer <= 0.0f) {
// --- 结束帧: timer 耗尽,FinishSkill ---
FinishSkill(); // currentPlayingSkillData = null
changeState(3); // → Finishing
this->finishingTimer = 0.75f; // [this + 0x8C]
return;
}
// timer > 0: 游戏暂停时不递减
if (gameState == 7 || gameState == 8)
return;
// --- 递减帧: 执行一次递减 ---
this->skillTimer -= Time.deltaTime;
UpdateSkillEffectiveTimer();
return;
}
// Case 3: Finishing(结束动画)
case 3: {
float t = this->finishingTimer; // [this + 0x8C]
if (t > 0.0f) {
this->finishingTimer -= Time.deltaTime; // 动画倒计时
return;
}
// 动画结束
changeState(0); // → Idle
// 如果有排队中的技能,直接进入 Begin
if (this->playList != null && this->playList.Count >= 1) {
changeState(1); // → Begin(下一帧处理)
} else {
this->currentPlayingSkillData = null; // 确保清空
}
return;
}
// Case 0: Idle(默认)
default:
return; // 无事发生
}Case 2 相关汇编:
; --- Case 2 Playing ---
3351e78: LDR S8, [X19,#0x88] ; S8 = skillTimer
3351e7c: FCMP S8, #0.0 ; timer <= 0 ?
3351e80: B.LE finish_branch ; 是 → FinishSkill
; timer > 0
3351e84: SUB W8, W20, #7 ; gameState - 7
3351e88: CMP W8, #2
3351e8c: B.CC paused_return ; gameState 7/8 → 跳过递减
3351e90: MOV X0, XZR
3351e94: BL sub_5D351DC ; ← Time.get_deltaTime()
3351e98: FSUB S0, S8, S0 ; timer -= deltaTime
3351e9c: STR S0, [X19,#0x88] ; 写回 skillTimer
3351eac: B loc_334E81C ; → UpdateSkillEffectiveTimer
; finish_branch
3351eb0: MOV X0, X19
3351eb4: BL sub_335302C ; FinishSkill()
3351ebc: MOV X0, X19
3351ec0: BL sub_3352980 ; changeState(3) = Finishing
3351ec4: MOV W8, #0x3F400000 ; 0.75f
3351ec8: STR W8, [X19,#0x8C] ; finishingTimer = 0.75fCase 3 (Finishing)
0x3351EE0:
3351f48: LDR S8, [X19,#0x8C] ; finishingTimer
3351f4c: FCMP S8, #0.0
3351f50: B.LE state_change
3351f58: BL sub_5D351DC ; deltaTime
3351f5c: FSUB S0, S8, S0 ; finishingTimer -= dt
3351f60: STR S0, [X19,#0x8C]
3351f70: RET
; state_change
3351f7c: BL sub_3352980 ; changeState(0) = Idle
3351f80: LDR X8, [X19,#0x70] ; 检查 playList
3351f90: B.LT null_current ; 无排队则清空 currentPlayingSkillData
3351fa8: B sub_3352980 ; 有排队则 restart: changeState(1)case2细节:
0x3351E94:BL sub_5D351DC→UnityEngine.Time::get_deltaTime()— 游戏运行的每帧间隔0x3351E7C-80:timer 判断(FCMP; B.LE)在递减(FSUB,0x3351E98)**之前- gameState 为 7 或 8 时跳过递减
3.4 判定阶段的技能状态读取
sub_2FC09C8 中通过 get_CurrentPlayingSkillData()(0x3351718)获取技能数据:
// 0x3351750: 简单返回 *(this + 0x80),不做状态判断
SituationSkillData* data = *(SituationSkillData**)(situationSkillManager + 0x80);技能加成通过 currentPlayingSkillData 是否非 null 来判断。
currentPlayingSkillData 的生命周期:
| 事件 | 操作 | 地址 |
|---|---|---|
forcePerfect 触发状态切换 | 写入 playList 并调 changeState(1) → Begin | 0x3352370 → 0x3352520 |
| 技能激活(case 1) | 从 playList 弹出并赋值 | 0x3351DB4 |
| 技能终止(FinishSkill) | 置为 null | 0x335302C 中 *(this + 0x80) = 0 |
| 结束动画结束 | 置为 null(无排队时) | 0x3351FAC |
4. MusicPos 与帧进推演
4.1 get_MusicPos
InGameMusicScoreController.get_MusicPos(),0x332E600:
float get_MusicPos(InGameMusicScoreController* this) {
int beatProgress = *(int*)(this + 0x44);
float basePos = *(float*)(this + 0x48);
return basePos + (float)(CONSTANT * beatProgress);
}4.2 每帧递进
由 GetBarSeconds 和 GetSecWithDistance 的关系推导:
1 小节 = CONSTANT 单位
1 小节 = 240 / BPM 秒
1 秒 = BPM * CONSTANT / 240 单位
deltaTime ≈ 1/60 时:
MusicPos 增量 = deltaTime * BPM * CONSTANT / 240 ≈ BPM * CONSTANT / 14400
F 帧后:
MusicPos(F) = F * BPM * CONSTANT / 144004.3 帧内调用顺序
updatePlayState(0x33E4F70)中:
void updatePlayState(float deltaTime) {
IncrementGameFrameCounter(); // 0x33E5024
updatePlayingSound(); // 0x33E5058
sub_33E8FF8(); // 0x33E5098
if (canThroughInputInspection) { // 0x33E50C4
NoteManager.Update(deltaTime); // 0x33E50DC
// → UpdateMusicScoreProgress → MoveState → 判定
// ↑ 此时 currentPlayingSkillData 来自上一帧 ExecUpdate
ExecUpdate(frameCounter, gameState); // 0x33E512C
// → Case 2 → timer 检查 → 递减或 FinishSkill
// ↑ 本帧判定已做完,这里才更新 timer
}
}NoteManager.Update 在 ExecUpdate 之前运行。 note 判定使用的是上一帧 ExecUpdate 结束时的技能状态。
4.4 CONSTANT 消去
判定条件:MusicPos >= absolutePos
F * BPM * CONSTANT / 14400 >= bar * CONSTANT + num * CONSTANT / den
F * BPM >= (bar + num/den) * 14400
F >= beat * 3600 / BPM
判定帧 = ceil(beat * 3600 / BPM)CONSTANT 不出现在结果中。只要 get_MusicPos 和 absolutePos 使用同一值,判定帧号不受 CONSTANT 取值影响。
4.5 推演(BPM=137&60FPS示例,忽略因为设备导致的偏移)
使用 ceil(beat * 3600 / BPM):
| beat | 帧(float) | 判定帧 |
|---|---|---|
| 16 | 420.44 | 421 |
| 44 | 1156.20 | 1157 |
| 60 | 1576.64 | 1577 |
| 76 | 1997.08 | 1998 |
| 92 | 2417.52 | 2418 |
| 148 | 3889.05 | 3890 |
| 164 | 4309.49 | 4310 |
| 180 | 4729.93 | 4730 |
| 196 | 5150.36 | 5151 |
| 212 | 5570.80 | 5571 |
| 228 | 5991.24 | 5992 |
以 7.0s(420 帧)duration 为例(实际值由玩家卡牌决定),技能时序:
| # | 技能beat | 判定帧 | 生效帧 | 首次递减 | 最后加成帧 | 终止帧 | 边界beat | 边界判定 | 加成 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 16 | 421 | 422 | 423 | 842 | 843 | -- | -- | -- |
| 2 | 44 | 1157 | 1158 | 1159 | 1578 | 1579 | 60 | 1577 | 有 |
| 3 | 76 | 1998 | 1999 | 2000 | 2419 | 2420 | 92 | 2418 | 有 |
| 4 | 148 | 3890 | 3891 | 3892 | 4311 | 4312 | 164 | 4310 | 有 |
| 5 | 180 | 4730 | 4731 | 4732 | 5151 | 5152 | 196 | 5151 | 有 |
| 6 | 212 | 5571 | 5572 | 5573 | 5992 | 5993 | 228 | 5992 | 有 |
边界 note(#5 的 5151、#6 的 5992)的判定帧恰好为结束帧——NoteManager.Update 先判定(currentPlayingSkillData 仍非 null → 吃加成),随后 ExecUpdate 才检测 timer ≤ 0 并执行 FinishSkill 置 null。判定在前,置 null 在后,因此结束帧内判定的 note 仍享受加成。
4.6 判定阶段的技能状态读取
sub_2FC09C8 中,自动判定后的计分流程通过 get_CurrentPlayingSkillData() 获取技能数据(0x2FC0D3C):
SituationSkillData* data = get_CurrentPlayingSkillData(situationSkillManager);
int skillData = data ? *(data + 0x30) : 0;
// 打包进判定结构体 (sub_33237CC)
packedStruct.skillData = skillData; // offset 0x40
// 传递给计分回调
callback(packedStruct, ...);get_CurrentPlayingSkillData()(0x3351718)只是简单返回 *(this + 0x80),不做状态判断,由外层根据返回指针是否为 null 决定 skillData。
currentPlayingSkillData 的生命周期:
| 事件 | 操作 | 地址 |
|---|---|---|
| 技能激活(case 1) | 从 playList 弹出并赋值 | 0x3351DB4 |
| 技能终止(FinishSkill) | 置为 null | 0x335302C 中 offset 0x128 |
| 结束动画结束(case 3 到 Idle) | 置为 null(无排队技能时) | 0x3351FAC |
技能加成不是通过显式比较 skillPlayState == Playing 来实现,而是依靠 currentPlayingSkillData 指针的 null 语义:非 null 即技能有效。case 2 的 timer 判断在递减之前,确保了 FinishSkill(指针清空)被推迟到下一帧,当前帧内所有 note 均能携带有效技能数据。
5. 总结
技能覆盖窗口由以下规则决定:
- 音符在
MusicPos >= absolutePos的第一帧被判定,帧号由ceil(beat × 3600 / BPM)给出。 - 技能 note 被判定时并不立即激活,而是进入
playList队列,下一帧才由ExecUpdate的 Case 1 分支弹出并切换到 Playing 状态。激活帧不递减计时器。 - 此后每一帧,
ExecUpdate的 Case 2 先检查skillTimer > 0,通过后递减,切换 Playing 状态保持,该帧内被判定的 note 均能获得技能加成。 - 当某一帧的 Case 2 入口处
skillTimer <= 0时,FinishSkill被调用,currentPlayingSkillData置 null,此后 note 不再有加成。 - 技能加成通过
currentPlayingSkillData是否非 null 来判断,而非显式比较skillPlayState。Case 2 的「先判断后递减」意味着即使递减后 timer 降到零,该帧的判定仍在判断通过之后、FinishSkill之前发生,因此仍能获得加成。覆盖窗口的最后一帧就是这种情形。
6. 屏幕刷新率对技能区间的影响
6.1 timer 递减用的是 Time.deltaTime
3351e94: BL sub_5D351DC ; ← UnityEngine.Time::get_deltaTime()
3351e98: FSUB S0, S8, S0 ; timer -= deltaTime
3351e9c: STR S0, [X19,#0x88] ; 写回 skillTimersub_5D351DC 反编译:
return ((__int64 (*)(void))off_67B1F50)(); // Time.get_deltaTime()6.2 技能区间
6.2.1 技能区间固定长度
由上面的分析可以得到,timer 在递减 duration * fps 次后 ≈ 0,但结束帧的判定发生在 FinishSkill 之前,pointer 仍非 null,多给了一帧加成。因此实际加成帧数 = 递减次数 + 1。
则理论上技能覆盖的帧数和宽度为:
| 帧率 | 技能生效帧(递减帧+结束帧) | 覆盖时长 |
|---|---|---|
| 60FPS | 420+1=421帧 | 7.01667s |
| 120FPS | 840+1=841帧 | 7.00833s |
6.2.2 不稳定区间(最长技能距离)
本节内容为作者对SOS 7s技能测试的个人猜想,尚未进行充分性验证
技能的实际生效窗口不是由"timer = 7.0s"这一个数字决定的,而是由两个独立因素共同塑造。
因素一:offset_skill。 技能 note 的 ceil 判定引入了 offset_skill ∈ [0, 1/fps)——即 note 实际过线时刻到判定帧之间的余量。offset 越大,判定越"晚",整个技能周期随之右移。
因素二:结束帧的额外加成。 timer 在递减 420 次后 ≈ 0,但结束帧的判定发生在 FinishSkill 之前,pointer 仍非 null,多给了一帧加成。因此实际加成帧数 = 递减次数 + 1。
结合两者,技能覆盖窗口的结束边界为:
结束边界 = offset_skill + (递减帧数 + 1) / fps
= offset_skill + 421 / fps (60fps 时)
= offset_skill + 841 / fps (120fps 时)offset_skill 理论上对特定的键而言为一固定值,但由于 deltaTime 跑在Unity事件循环中,其帧率会受到设备等的影响而不稳定,导致在 [0, 1/fps) 内浮动,意味着覆盖终点不是定值,而是一个开区间:
| 帧率 | offset 范围 | 递减总时间 | 覆盖终点可能区间 |
|---|---|---|---|
| 60fps | [0, 16.67ms) | 7.0s | [7.01667s, 7.03333s) |
| 120fps | [0, 8.33ms) | 7.0s | [7.00833s, 7.01667s) |
这个区间就是不稳定区间:距离技能 note 时间差落在此范围内的 note,其是否能吃到加成取决于 offset_skill 的实际取值,无法从静态参数确定,只能在运行时观测。
帧率越高,不稳定区间越窄、越偏左。 120fps 的 offset 上界只有 8.33ms,区间长度为 8.33ms;60fps 的 offset 上界为 16.67ms,区间长度为 16.67ms。对于紧贴在窗口右边缘的 note,60fps 有更大余量将它包括,120fps 则更早截断。
7. 地址速查
| 函数/字段 | 地址 | 说明 |
|---|---|---|
NoteInformation..ctor | 0x2B24694 | absolutePos 计算 |
GetBarSeconds | 0x2C013EC | return 240.0 / bpm |
GetSecWithDistance | 0x2C00ED8 | 位置差转时间差 |
MoveState | 0x2FC0F78 | 判定主逻辑 |
get_MusicPos | 0x332E600 | MusicPos 计算 |
UpdateMusicScoreProgress | 0x332E9FC | 每帧 MusicPos 递推 |
NoteManager.Update | 0x2B2A410 | 子帧分割逻辑(n4 机制) |
ExecUpdate | 0x3351C64 | 技能逐帧状态机 |
Time.deltaTime | 0x5D351DC | UnityEngine.Time::get_deltaTime() |
processOfSkillTriggered | 0x3352C50 | 激活技能,设置 timer |
sub_3352370 | 0x3352370 | forcePerfect → changeState(1) 的桥接函数 |
FinishSkill | 0x335302C | 终止技能,清空 currentPlayingSkillData |
updatePlayState | 0x33E4F70 | 主更新循环(含 NoteManager/ExecUpdate 顺序) |
SetExecuteFrame | 0x332DF7C | executeFrame = value(覆盖赋值) |
get_CurrentPlayingSkillData | 0x3351718 | return *(this + 0x80) |
SituationSkillManager 字段
| 偏移 | 字段 | 类型 |
|---|---|---|
0x70 | situationSkillDataPlayList | List<SituationSkillData> |
0x80 | currentPlayingSkillData | SituationSkillData* |
0x88 | skillTimer | float |
0x8C | skillFinishingTimer | float |
0x90 | skillPlayState | int(0=Idle, 1=Begin, 2=Playing, 3=Finishing) |
SituationSkillMaster 字段
| 偏移 | 字段 | 来源 |
|---|---|---|
0x30 | duration | float,MasterSkill.duration(服务器下发) |
8. 静态分析的边界
两项数值超出静态分析范围,但不影响前述结论:
MUSIC_BAR_DIVISION_COUNT(CONSTANT):BSS 区域未初始化,元数据中无 FieldDefaultValue,NoteManager无静态构造函数。该值在所有判定帧公式中被约掉。MasterSkill.duration:服务器下发的卡牌数据,不属于客户端逆向范畴。本文以 7s 为例是为了构造 BPM=137 下 beat16 刚好超出 7s 的临界边界,推演逻辑本身与具体 duration 取值无关。- 实际丢帧概率:60Hz vs 120Hz 的丢帧发生率无法从静态分析得出,依赖设备和系统环境。