声明:本项目为个人学习与技术研究用途,请勿用于商业目的,不得将本项目中任何内容用于违反国家/地区/组织等的法律法规或相关规定的其他用途。

分析目标: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 → 享受加成),然后 ExecUpdate Case 2 先判断 timer > 0 → 通过 → 执行 timer -= deltaTime。共递减 420 次后 timer ≈ 0。
  • 结束帧(1 帧):timer ≈ 0 的那一帧。NoteManager.Update 先判定——此时 currentPlayingSkillData 仍非 null(前一帧只递减未调 FinishSkill)→ 最后一次加成。随后 ExecUpdate Case 2 检测 timer ≤ 0FinishSkillcurrentPlayingSkillData = 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->absolutePos

CONSTANT 的身份:

  • 所属类: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;
    }
}

核心指令:

地址指令说明
0x2FC1020FSUB S0,S9,S0MusicPos - notePos
0x2FC1024FCMP S0,#0.0
0x2FC1028B.GE loc_2FC1038大于等于即触发(非严格大于)
0x2FC1044STR S0,[X19,#0x14C]accumulatedTime 在偏移 0x14C
0x2FC1050BL sub_3320E88IsAutoPlay
0x2FC1054TBNZ W0,#0,loc_2FC1078自动模式直接 forcePerfect

自动打歌时,MusicPos 大于等于 notePos 即判定 Perfect,不经 GetResult/JudgeNote 等手动判定流程。


3. 技能系统

3.1 SituationSkillManager 结构

偏移字段类型
0x70situationSkillDataPlayListList<SituationSkillData>
0x80currentPlayingSkillDataSituationSkillData*
0x88skillTimerfloat
0x8CskillFinishingTimerfloat
0x90skillPlayStateint(0=Idle, 1=Begin, 2=Playing, 3=Finishing)

SituationSkillMaster.duration 位于偏移 0x30,float 类型,来源为服务器下发的 MasterSkill.duration,在 GetMasterSkillData0x343263C)中通过 *(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.75f

Case 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细节

  • 0x3351E94BL sub_5D351DCUnityEngine.Time::get_deltaTime()游戏运行的每帧间隔
  • 0x3351E7C-80:timer 判断(FCMP; B.LE)在递减(FSUB0x3351E98)**之前
  • 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) → Begin0x33523700x3352520
技能激活(case 1)从 playList 弹出并赋值0x3351DB4
技能终止(FinishSkill)置为 null0x335302C*(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 每帧递进

GetBarSecondsGetSecWithDistance 的关系推导:

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 / 14400

4.3 帧内调用顺序

updatePlayState0x33E4F70)中:

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_MusicPosabsolutePos 使用同一值,判定帧号不受 CONSTANT 取值影响。

4.5 推演(BPM=137&60FPS示例,忽略因为设备导致的偏移)

使用 ceil(beat * 3600 / BPM)

beat帧(float)判定帧
16420.44421
441156.201157
601576.641577
761997.081998
922417.522418
1483889.053890
1644309.494310
1804729.934730
1965150.365151
2125570.805571
2285991.245992

以 7.0s(420 帧)duration 为例(实际值由玩家卡牌决定),技能时序:

#技能beat判定帧生效帧首次递减最后加成帧终止帧边界beat边界判定加成
116421422423842843------
24411571158115915781579601577
37619981999200024192420922418
4148389038913892431143121644310
5180473047314732515151521965151
6212557155725573599259932285992

边界 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)置为 null0x335302C 中 offset 0x128
结束动画结束(case 3 到 Idle)置为 null(无排队技能时)0x3351FAC

技能加成不是通过显式比较 skillPlayState == Playing 来实现,而是依靠 currentPlayingSkillData 指针的 null 语义:非 null 即技能有效。case 2 的 timer 判断在递减之前,确保了 FinishSkill(指针清空)被推迟到下一帧,当前帧内所有 note 均能携带有效技能数据。


5. 总结

技能覆盖窗口由以下规则决定:

  1. 音符在 MusicPos >= absolutePos 的第一帧被判定,帧号由 ceil(beat × 3600 / BPM) 给出。
  2. 技能 note 被判定时并不立即激活,而是进入 playList 队列,下一帧才由 ExecUpdate 的 Case 1 分支弹出并切换到 Playing 状态。激活帧不递减计时器。
  3. 此后每一帧,ExecUpdate 的 Case 2 先检查 skillTimer > 0,通过后递减,切换 Playing 状态保持,该帧内被判定的 note 均能获得技能加成。
  4. 当某一帧的 Case 2 入口处 skillTimer <= 0 时,FinishSkill 被调用,currentPlayingSkillData 置 null,此后 note 不再有加成。
  5. 技能加成通过 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]       ; 写回 skillTimer

sub_5D351DC 反编译:

return ((__int64 (*)(void))off_67B1F50)();  // Time.get_deltaTime()

6.2 技能区间

6.2.1 技能区间固定长度

由上面的分析可以得到,timer 在递减 duration * fps 次后 ≈ 0,但结束帧的判定发生在 FinishSkill 之前,pointer 仍非 null,多给了一帧加成。因此实际加成帧数 = 递减次数 + 1。

则理论上技能覆盖的帧数和宽度为:

帧率技能生效帧(递减帧+结束帧)覆盖时长
60FPS420+1=421帧7.01667s
120FPS840+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..ctor0x2B24694absolutePos 计算
GetBarSeconds0x2C013ECreturn 240.0 / bpm
GetSecWithDistance0x2C00ED8位置差转时间差
MoveState0x2FC0F78判定主逻辑
get_MusicPos0x332E600MusicPos 计算
UpdateMusicScoreProgress0x332E9FC每帧 MusicPos 递推
NoteManager.Update0x2B2A410子帧分割逻辑(n4 机制)
ExecUpdate0x3351C64技能逐帧状态机
Time.deltaTime0x5D351DCUnityEngine.Time::get_deltaTime()
processOfSkillTriggered0x3352C50激活技能,设置 timer
sub_33523700x3352370forcePerfect → changeState(1) 的桥接函数
FinishSkill0x335302C终止技能,清空 currentPlayingSkillData
updatePlayState0x33E4F70主更新循环(含 NoteManager/ExecUpdate 顺序)
SetExecuteFrame0x332DF7CexecuteFrame = value(覆盖赋值)
get_CurrentPlayingSkillData0x3351718return *(this + 0x80)

SituationSkillManager 字段

偏移字段类型
0x70situationSkillDataPlayListList<SituationSkillData>
0x80currentPlayingSkillDataSituationSkillData*
0x88skillTimerfloat
0x8CskillFinishingTimerfloat
0x90skillPlayStateint(0=Idle, 1=Begin, 2=Playing, 3=Finishing)

SituationSkillMaster 字段

偏移字段来源
0x30durationfloatMasterSkill.duration(服务器下发)

8. 静态分析的边界

两项数值超出静态分析范围,但不影响前述结论:

  • MUSIC_BAR_DIVISION_COUNT(CONSTANT):BSS 区域未初始化,元数据中无 FieldDefaultValue,NoteManager 无静态构造函数。该值在所有判定帧公式中被约掉。
  • MasterSkill.duration:服务器下发的卡牌数据,不属于客户端逆向范畴。本文以 7s 为例是为了构造 BPM=137 下 beat16 刚好超出 7s 的临界边界,推演逻辑本身与具体 duration 取值无关。
  • 实际丢帧概率:60Hz vs 120Hz 的丢帧发生率无法从静态分析得出,依赖设备和系统环境。
最后修改:2026 年 07 月 11 日
如果觉得我的文章对你有用,请随意赞赏