制作Muse Dash“物理外挂”

Muse Dash是一款画面精美、上手难度较低的音游,但也有一些比较难的歌曲,就连我这种自诩音游玩的比较好的也是拿它们没辙。加上之前看过网上有人制作过“跳一跳”的触摸物理外挂,由此,我也有了这么一个开发一个物理外挂的想法。

本制作纯属研究性质,请勿用于其他用途。

与物理外挂相反的,是所谓的“内存外挂”。内存外挂顾名思义,就是直接对目标程序的内存进行修改的外挂。这种外挂相对比较常见,我们电脑上的各类网游的外挂都是这种形式。但是它属于侵入式外挂,虽然可以实现的功能多,但是更容易被检测出来。与此同时,目标平台也必须要可以运行我们编写的“恶意代码”,我们编写的程序也必须要可以对其他应用程序的内存进行操作。对于当今常见的移动平台,尤其是iOS这种程序都运行在沙盒中的操作系统,这种形式显然是不可能实现的。于是,就出现了我们今天的主角——物理外挂。

物理外挂是一种非侵入式的外挂,它的主要工作原理就是模拟我们玩家的正常操作,对游戏进行控制。之前跳一跳物理外挂中用摄像头获取图像进行计算,并用舵机操作一根电容笔来模拟触摸的操作,就是一个很好的例子。这种方案,不需要游戏平台运行额外代码,也不需要对游戏进行修改,一切本着模拟玩家正常操作的原则,是外挂的一个新的方向,也是我们折腾的新方向。

相对于其他的音乐游戏,Muse Dash适合做物理外挂的原因有二:按键数量少和按键位置固定。这两个特点是我们能够制作物理外挂的物理基础。有些游戏按键特别多,甚至有接近10个之多,这就为我们的硬件设计带来了困难。Muse Dash只有两个按键,这大大简化了我们的程序和硬件设计。同时,制作可以移动的触摸模拟器也存在很大的困难,一些有滑动音符的游戏因此也无法制作物理外挂。

一般的音游自动打击器,主要都是通过图形识别算法,识别音符,然后控制相应的触摸模拟器进行操作的。但是对于Muse Dash,这显然是不是用的。因为Muse Dash的画面过于精美,导致音符、音符特效、背景等混成一片,并且颜色比较偏向一致,给图形识别造成了极大的困难。例如下图就是一个非常明显的例子,游戏下方轨道打击的特效已经覆盖了上方轨道的打击点。因此使用图形识别这一条路就被堵死了。

https://img.yuanze.wang/posts/musedash-player/screenshot2.jpg
游戏界面,可以看到人物前的两个打击点

https://img.yuanze.wang/posts/musedash-player/screenshot1.jpg
画面非常华丽,但是界面元素被特效挡住了

因此,我们必须换一种思路。偶然,我在搜索这款游戏的时候,看到有人说,这款游戏内部使用的谱面文件使用的是一种通用的音游谱面格式。于是,我下载了这款游戏的安卓端数据包,将其解压之后,经过一番探索,就找到了一个满是BMS文件的文件夹。可以看到,每个谱面的名字由歌曲名称和等级构成。名称一般是游戏内歌名的英文名、汉语拼音或者日语假名拼音;等级为1-3,分别代表萌新、高手、大触难度。

https://img.yuanze.wang/posts/musedash-player/bms-files.png
存储谱面的文件夹

 解包出的所有谱面

有了谱面,我们离成功就近了一大步。我们可以使用单片机直接读取谱面,并将其转换为左右手点击、长按以及连打的信号,将其输出至触摸模拟器,即可实现我们想要的效果。使用文本编辑器打开BMS文件,可以看到BMS文件就是一个文本文件。下面是节选自一个BMS文件的片段。

*---------------------- HEADER FIELD
#PLAYER 3
#GENRE scene_05
#TITLE 粉骨砕身カジノゥ
#ARTIST モリモリあつし
#BPM 198
#PLAYLEVEL 10
#RANK 3

#WAV01 小型(左)
#WAV02 小型(左上)
#WAV03 小型(左下)
#WAV04 中型1(左)
#WAV05 中型1(左上)
#WAV06 中型1(左下)
#WAV07 中型2(左)
#WAV08 中型2(左上)
#WAV09 中型2(左下)
#WAV0A 大型1
#WAV0B 大型2
#WAV0C 陷阱(左上)
#WAV0D 摆锤(左下)
#WAV0E 双押
#WAV0F 长按
#WAV0G 连打
#WAV0H 齿轮
......此处省略
*---------------------- MAIN DATA FIELD
......此处省略

#00813:00000000000000000000000004040404
#00814:0404040404000000
#00815:001A
#00854:00000000000F0F00

#00913:0000000101000000
#00914:010001010100000000000D000D000000

通过简单的浏览,我们可以看到BMS文件主要分两部分:前面的注释部分和后面的谱面部分。前面的注释部分主要包含歌曲名、难度、等级、初始BPM等信息,这其中对我们有用的就是初始BPM,这一部分我们后面还会细说。除了谱子的信息之外,注释部分还包含了一个表,这个表格里面列出来了两位字母或数字组合所代表的游戏内的音符种类,对此分析应该就能对谱子进行解析。剩下的部分,就是我们需要的谱面信息,每一行都有由冒号分隔开的左右两部分,左边的五位数字代表的是轨道信息,右边的字母串代表了谱子部分。通过前面的注释部分,我们不难发现每一行谱子的格式均为#AAABC:1122334455667788。右边的谱子部分每两位表示一个音符;每一行代表一个轨道,每一组表示一个小节。经过分析,得出了每一行谱子中各个元素所代表的意义如下:

  • AAA:小节编号,从001一直递增。
  • B:小节类型,0为特殊小节(BPM变更),1为普通小节,3为隐藏小节,5为长按小节。
  • C:轨道编号,3为左手,4为右手,5为BOSS轨道,一般用不到。
  • 11-88:每两位为一个音符,具体所代表的音符可以从文件头中对照得知。

另外,我们还可以发现,每一个小节的音符数最多为16个,其余小节的音符数都是16除以2的幂次之后,向前对齐。例如,若一个小节有4个音符,则这四个音符得时间分别对应16个音符的0、4、8、12。这样,哦们即可通过程序对谱子进行解析。从我们模拟触摸的角度上讲,除了不点击,我们需要的点击状态,总共分为六种:左右手分别的单击、长按与连打。因此,我们可以写出如下的解析程序:

void BMS_ParseSection(char* sector_content)
{
  BMS_Key_Track sector_track;
  BMS_NOTE note;
  uint32_t sector_index;
  uint32_t sector_type;
  uint32_t bpm;
  uint8_t sector_len;
  uint8_t note_margin;
  uint8_t i;
  
  if(sector_content[0] != '#')
    return;
  
  /*解析小节头*/
  sscanf(sector_content,"#%3d%1d%1d",&sector_index,&sector_type,(int*)&sector_track);//获取小节号 小节类型 小节轨道
  sector_content += 7;//跳过小节头及':'
  
  if(sector_index == 0)
    return;
  
  if(sector_type == 0)//0 BPM变更小节
  {
    sscanf(sector_content,"%2x",&bpm);
    BMS_BPM_Table[BMS_Info.bpm_tab_len].bpm_val = bpm;
    BMS_BPM_Table[BMS_Info.bpm_tab_len].start_sector = sector_index;
    BMS_Info.bpm_tab_len ++;
  }
  else//1普通小节 3隐藏小节 5长按小节
  {
    sector_len = strlen(sector_content) / 2;//计算剩余小节长度
    note_margin = BMS_NOTES_PER_SECTOR / sector_len;
    
    for(i = 0;i < sector_len;i ++)
    {
      note = BMS_Get_NoteType(sector_content,sector_track);
      
      BMS_Buff[sector_index][i*note_margin] |= note;         
      sector_content += 2;
    }
  }
  BMS_Info.sectors = sector_index;
}

输入上述函数的参数,为指向一行(一轨)谱子的字符串指针。在此函数内部,分别对以小节头以及小节内容进行解析。小节头为固定长度,可以直接读取;小节内容长度不固定,程序中先计算小节长度,然后通过循环对小节内容进行解析。值得一提的是,除了解析每一个音符都使用函数BMS_Get_NoteType()函数进行,此函数根据前面注释中的解释,将所有音符归结为上面提到的单击、长按、连打三类,其他与触摸无关的音符(例如BOSS相关的)可以被我们忽略掉。函数节选如下:

if(track == key_left)
{
      if(strncmp(note,"00",2) == 0)   return note_empty;//00
  else if(strncmp(note,"01",2) == 0)   return note_left_press;//01 小型(左)
  else if(strncmp(note,"02",2) == 0)   return note_left_press;//02 小型(左上)
  else if(strncmp(note,"03",2) == 0)   return note_left_press;//03 小型(左下)
  else if(strncmp(note,"04",2) == 0)   return note_left_press;//04 中型1(左)
  else if(strncmp(note,"05",2) == 0)   return note_left_press;//05 中型1(左上)
  else if(strncmp(note,"06",2) == 0)   return note_left_press;//06 中型1(左下)
  else if(strncmp(note,"07",2) == 0)   return note_left_press;//07 中型2(左)
  else if(strncmp(note,"08",2) == 0)   return note_left_press;//08 中型2(左上)
  else if(strncmp(note,"09",2) == 0)   return note_left_press;//09 中型2(左下)
  else if(strncmp(note,"0A",2) == 0)   return note_left_press;//0A 大型1
  else if(strncmp(note,"0B",2) == 0)   return note_left_press;//0B 大型2
  else if(strncmp(note,"0C",2) == 0)   return note_left_press;//0C 陷阱(左上)
  else if(strncmp(note,"0D",2) == 0)   return note_left_press;//0D 摆锤(左下)
  else if(strncmp(note,"0E",2) == 0)   return note_left_press;//0E 双押
  else if(strncmp(note,"0F",2) == 0)   return note_left_longpress;//0F 长按
  else if(strncmp(note,"0G",2) == 0)   return note_left_continuouspress;//0G 连打
  else if(strncmp(note,"0H",2) == 0)   return note_right_press;//0H 齿轮
  
  else if(strncmp(note,"11",2) == 0)   return note_left_press;//11 BOSS近战1
  else if(strncmp(note,"12",2) == 0)   return note_left_press;//12 BOSS近战2
  else if(strncmp(note,"13",2) == 0)   return note_left_press;//13 BOSS远程1
  else if(strncmp(note,"14",2) == 0)   return note_left_press;//14 BOSS远程2-1
  else if(strncmp(note,"15",2) == 0)   return note_left_press;//15 BOSS远程2-2
  else if(strncmp(note,"16",2) == 0)   return note_left_continuouspress;//16 BOSS连打1
  else if(strncmp(note,"17",2) == 0)   return note_left_continuouspress;//17 BOSS连打2
  else if(strncmp(note,"18",2) == 0)   return note_right_press;//18 BOSS齿轮
  else if(strncmp(note,"1A",2) == 0)   return note_empty;//1A BOSS入场
  else if(strncmp(note,"1B",2) == 0)   return note_empty;//1B BOSS退场
  else if(strncmp(note,"1C",2) == 0)   return note_empty;//1C BOSS远程1开始
  else if(strncmp(note,"1D",2) == 0)   return note_empty;//1D BOSS远程1结束
  else if(strncmp(note,"1E",2) == 0)   return note_empty;//1E BOSS远程2开始
  else if(strncmp(note,"1F",2) == 0)   return note_empty;//1F BOSS远程2结束
  else if(strncmp(note,"1G",2) == 0)   return note_empty;//1G BOSS远程1→远程2
  else if(strncmp(note,"1H",2) == 0)   return note_empty;//1H BOSS远程2→远程1
  
  else if(strncmp(note,"21",2) == 0)   return note_left_press;//21 隐藏
  else if(strncmp(note,"22",2) == 0)   return note_left_press;//22 红心
  else if(strncmp(note,"23",2) == 0)   return note_left_press;//23 音符
}

通过上述函数,我们即可将一个BMS文件内包含的所有音符变为一个大数组,存储在BMS_Buff中。BMS_Buff是一个BMS_NOTE类型的数组。BMS_NOTE是一个枚举类型,其中包含了6种音符的定义:

typedef enum
{
  note_empty = 0,//短按
  note_left_press = 0x01,//短按
  note_left_longpress = 0x02,//长按
  note_left_continuouspress = 0x04,//连打
  
  note_right_press = 0x01<<4,//短按
  note_right_longpress = 0x02<<4,//长按
  note_right_continuouspress = 0x04<<4,//连打
}BMS_NOTE;

有了可以被程序识别的谱面之后,接下来我们需要完成的就是将其变为触摸模拟器的驱动电平信号,就可以完成我们所需要的效果了。由于按照谱子将音符打出来是一个对时间准确度要求非常高的任务,因此我选择将其放到定时器中断中执行。定时器根据当前BPM产生中断,程序中我将定时器中断频率设置为BPM的32倍,因为BPM是每分钟的节拍数,每一个节拍,在游戏中对应的就是一个小节,并且一个小节最多包含16个音符以及为了正确控制触摸模拟器的按下与松开,还需要将频率加倍,于是就得出来32这个数字了。以下是设置BPM的代码,实际上就是根据BPM对定时器进行初始化。

void Player_SetBPM(uint32_t bpm)
{
  float tmp;
  
  tmp = ((float)bpm/60) * (BMS_NOTES_PER_SECTOR/2);
  tmp = 72000/tmp;
  TIM3->ARR = tmp-1;
  TIM3->PSC = 999;
  
  BMS_Info.curr_bpm = bpm;
}

值得注意的是,有些歌曲中间存在BPM的切换。因此在解析BMS文件的时候,将所有BPM变化的记号也存储在一个数组里,在每一次切换小节的时候,都判断当前小节是否存在BPM的变化,如果有,则重新初始化定时器。下面是播放的函数,也是定时器的中断服务函数。

void TIM3_IRQHandler(void)
{
  uint8_t new_bpm;
  if(TIM_GetITStatus(TIM3,TIM_IT_Update)) {//如果产生的是更新中断
    if(current_sector < (BMS_Info.sectors+1) && player_status) {
      if(continous_stat) {//如果在连打模式
        if(current_note % 2 == 0) {//根据节拍奇偶轮流左右手
          TOUCH_L1 = 1;TOUCH_R1 = 0;
        }
        else {
          TOUCH_L1 = 0;TOUCH_R1 = 1;
        }
      }
      if((BMS_Buff[current_sector][current_note/2] & 0x0F) == note_left_longpress) {//长按
        if(current_note % 2 == 0) {//在偶数拍松开与按下
          if(left_longpress_stat == 0) {
            left_longpress_stat = 1;TOUCH_L1 = 1;
          }
          else {
            left_longpress_stat = 0;TOUCH_L1 = 0;
          }
        }
      }
      else if((BMS_Buff[current_sector][current_note/2] & 0x0F) == note_left_press) {//短按
        if(current_note % 2 == 0) {//偶数拍按下
          TOUCH_L1 = 1;
        }
        else {//接下来的奇数拍松开
          TOUCH_L1 = 0;
        }
      }
      /*右手轨道的程序与上述左手轨道思路相同,版面起见请直接参考文末完整程序*/
      if(BMS_Buff[current_sector][current_note/2] == note_left_continuouspress || BMS_Buff[current_sector][current_note/2] == note_right_continuouspress) {//连按
        if(current_note % 2 == 0) {//只在偶数拍激活一次
          if(continous_stat == 0) {
            continous_stat = 1;//激活
          }
          else {
            continous_stat = 0;//退出并回复按键状态
            TOUCH_R1 = 0;TOUCH_L1 = 0;
          }
        }
      }
      current_note ++;
      if(current_note >= BMS_NOTES_PER_SECTOR * 2)//拍子数为每小节音符数的二倍
      {
        current_note = 0;
        current_sector ++;//进入下一小节
        //切换BPM
        new_bpm = Player_CheckBPMUpdate(current_sector);
        
        if(new_bpm != 0)
        {
          Player_SetBPM(new_bpm);
        }
      }
    }
    else {//current_sector < BMS_Info.sectors
      TIM_Cmd(TIM3,DISABLE);player_status = 0;
      TOUCH_R1 = 0;TOUCH_L1 = 0;
    }
    TIM_ClearITPendingBit(TIM3,TIM_IT_Update);
  }
}

通过以上步骤,我们就完成了读取BMS文件,并将其转换为触摸模拟器电平信号输出的过程。接下来,我们就需要考虑如何搭建我们的硬件电路了。通过在万能的淘宝上的搜索,我发现了这一款触摸头。图片中的触摸模拟器已经包括了我另外购买的夹具,可以牢固的夹在iPad上,可以防止触摸头在高速点击的过程中出现位移以及触摸力度不到位导致断触。

https://img.yuanze.wang/posts/musedash-player/hitter1.jpg
触摸模拟器

它的基本原理是依靠内部一块牵拉式电磁铁,若电磁铁得电,就伸出内部铁条,使一块金属编织网触及屏幕,进而实现触摸的模拟。询问卖家后,我得知这款电磁铁最高的频率能达到30Hz,理论上用于打音游应该还是没有很大的问题的,遂购入。

https://img.yuanze.wang/posts/musedash-player/hitter2.jpg
触摸头通电与断电的效果图

这种触摸头本质就是一块3-6V供电的电磁铁。在驱动上,我们完全可以将其视为一个5V继电器的线圈来对待。因此,驱动电路在这里就不多赘述了。在整体电路设计上,主要的部分包括单片机最小系统、触摸头及其驱动电路、SD卡、按键以及显示屏。由于电路比较简单,大多都是能买到的现成模块,因此直接使用万能板焊接就能够满足我们的需求。

https://img.yuanze.wang/posts/musedash-player/board.jpg
实物照片

其中,SD卡用于存储BMS文件,按键用于选择谱面并与游戏中的第一个音符进行对齐来标志播放的开始,屏幕就用于选择谱面并显示当前播放状态、当前内存中的谱面。图中照片上总共焊接了四路触摸头的驱动电路,这是为之后祸害别踩白块做准备的。

 STM32工程

光说不练假把式,下面我们就来欣赏一下大触难度无人区与粉骨碎身的全连视频吧。