接上回的Deemo提取MIDI,这次发现DeemoⅡ提取MIDI的操作是(几乎)一样的,但是提取音频的难度会增加不少(终于不是明文存储了- -),于是就记录一下。

PS:没看过Deemo提取MIDI的应该先看一下,这里会省略一些AssetStudio的用法,以及MIDIUtil的修Bug方法。

提取资源·

音频和MIDI的资源都是进入游戏后才下载的(就是首次进入游戏时安装的2G+的东西),会放在手机(这里默认安卓)的Android/com.rayark.deemo2/目录,所以首先要进入游戏把那2G+下载了,然后手机插电脑,复制Android/com.rayark.deemo2/files/的所有文件(当然你知道哪些是资源文件的话可以不用全复制:),然后用AssetStudio打开,提取所有的TextAsset(对应MIDI资源)和MonoBehaviour(对应音频资源)。

提取MIDI·

TextAsset里面对应的***.easy/normal/hard.json的文件就是游戏中的“钢琴音”相关的文件,可以转化为MIDI,做法其实跟上次一样,但是这次的sounds里面不知道为啥会有null的,所以要加个if过滤一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import json
from midiutil.MidiFile import MIDIFile
import numpy as np
import os
import pandas as pd

RESULT_DIR = 'path\\to\\your\\result_dir'
JSON_DIR = 'path\\to\\your\\asset_exported_dir\\TextAsset\\'

# get file name
tmp = []
for _,_,f in os.walk(JSON_DIR):
tmp = f
files = []
for t in tmp:
if 'hard' in t:
files.append(t)

#print(files)

# run
werrors = []
jerrors = []
for name in files:
print('starting -> '+name)
# get notes in file
try:
with open(JSON_DIR+name,'r') as f:
datas = json.loads(f.read())
except:
jerrors.append(name)
print('get json error -> '+name)
print('------------------')
continue
speed = datas['speed']
#print(speed)
#print(list(datas))

notes = datas['notes']
#print(list(notes[0]))

sounds = []
for n in notes:
try:
try:
t = n['_time']
except:
t = 0
if n['sounds'] != None:
for s in n['sounds']:
sounds.append({'d':s['d'],'p':s['p'],'v':s['v'],'t':t})
except KeyError:
#print('!')
None
#print(len(sounds))
#print(list(notes[0]['sounds'][0]))
try:
start = sounds[0]['t']
for s in sounds:
s['t'] -= start
except:
None

# get bpm
times = []
for s in sounds:
#if s['p']<50:
times.append(s['t'])
#times = [s['t'] for s in sounds]
if(len(times)<2):
bpm = 60
else:
dt = []
for i in range(len(times))[1:]:
r = times[i]-times[i-1]
if r != 0:
dt.append(r)
gm = pd.Series(data=dt)
bpm = 60/gm.mode()[0]
while(bpm>200):
bpm /= 2
while(bpm<60):
bpm *= 2
bpm = round(bpm)
#bpm = int(bpm)
print('bpm -> '+str(bpm))

# write midi
MyMIDI = MIDIFile(1)
track = 0
time = 0
bias = float(bpm/60)
# Add track name and tempo.
MyMIDI.addTrackName(track,time,name+'_deemo')
MyMIDI.addTempo(track,time,bpm)

for s in sounds:
MyMIDI.addNote(0,0,s['p'],s['t']*bias,s['d']*bias,s['v'])
# And write it to disk.
try:
binfile = open(RESULT_DIR + name.split('.')[0]+'.mid', 'wb')
MyMIDI.writeFile(binfile)
binfile.close()
except:
werrors.append(name)
print('write file error -> '+name)
#continue

#print('finished -> '+name)
print('------------------')

print('json_errors:')
for e in jerrors:
print(e)

print('write_error:')
for e in werrors:
print(e)

PS:记得改RESULT_DIRJSON_DIR的路径,懒得优化了,逃

提取音频·

资源里soundbanks_android开头的一堆文件是游戏的资源文件,提取出来后是MonoBehaviour里的那堆Json文件,这次雷亚用了个叫SoundBanks(或者叫Wwise?)的东西,所以提取音频会比较复杂。

Json文件里,m_Name的部分对应该音频的名字(比如歌名),content部分的数组是BNK(Wwise SoundBank)文件的二进制(bytes转成了数字),把数组的数字转成bytes后输出到文件,然后用二进制编辑器打开是可以看到BNK文件的格式的,比如开头是BKHD,然后对应的DIDX段、DATA段和HIRC段:

按照BNK格式的说明,DATA段对应的会是一个WEM文件(好像是Wwies的某个音频格式,更像是一个加密/压缩了的WAV文件),所以提取的基本思路就是:

1
ASSET -> BNK -> WEM -> OGG -(看需求)-> WAV

其实网上已经有现成的解密工具 RadicalMusic-Decryptor了,就懒得逆WEM的解密了,直接调用即可,为了方便就搞了个Py代码一次性提取全部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import os
import json
import shutil

RESULT_DIR = '...\\path\\to\\outDIR\\' # create by yourself!
MONO_DIR = '...\\path\\to\\MonoBehaviour\\'
RMD_DIR = '...\\path\\to\\RadicalMusic-Decryptor-master\\'

# get file name
tmp = []
for _,_,f in os.walk(MONO_DIR):
tmp = f
files = []
for t in tmp:
if '.json' in t:
files.append(t[:-5])
#print(files)

songs = []
for fi in files:
with open(MONO_DIR+fi+'.json', 'r', encoding='utf-8') as f:
data = json.loads(f.read())
try:
content = data['content']
content[0] + 1
except:
continue

with open(RESULT_DIR+fi+'.bnk', 'wb') as f:
f.write(bytes(content))
songs.append(fi)
print(fi + ' - done bnk')
print('')

serror = []
for s in songs:
# API
os.system(RMD_DIR + 'bnk2wem\\bnkextr.exe ' + RESULT_DIR + s +'.bnk')
# change dir
for _,_,f in os.walk(RESULT_DIR + s):
sname = f

for i in range(len(sname)):
try:
name = s + '_%d'%i
if i == 0:
name = s
shutil.move(RESULT_DIR + s + '\\' + sname[i], RESULT_DIR + name + '.wem')
print(name + ' - done wem')

# API again...
os.system(RMD_DIR + '\\wem2ogg\\ww2ogg024\\ww2ogg.exe ' + RESULT_DIR + name + '.wem --pcb "' + RMD_DIR + '\\wem2ogg\\ww2ogg024\\packed_codebooks_aoTuV_603.bin"')
os.system(RMD_DIR + '\\wem2ogg\\revorb.exe ' + RESULT_DIR + name + '.ogg')
print(name + ' - done ogg')
except:
serror.append(s)
continue

# delete old dir
try:
os.rmdir(RESULT_DIR + s)
os.remove(RESULT_DIR + s + '.bnk')
for i in range(len(sname)):
name = s + '_%d'%i
if i == 0:
name = s
os.remove(RESULT_DIR + name + '.wem')
except:
serror.append(s + ' - dir')
pass

print(serror)

用法:把三个DIR填好后运行就行了;RESULT_DIR是最后的OGG文件放的目录,需要自己手动创建,过程中会产生大量的中间文件(当然最后会自动删掉),所以运行的时候不要用文件管理器打开这个目录;MONO_DIR是用AssetStudio提取出来的那堆Json的目录;RMD_DIR是把RadicalMusic-Decryptor Clone下来后的目录(我是Windows里直接下zip的,所以带个master?)

如无意外的话运行完后RESULT_DIR里就是所有音频的OGG文件了(已经可以播放了,大小大概1G)。然后还可以按需转成WAV(比如我有的软件不能打开OGG,转后大小大概25G - -);这里会调用ffmpeg,所以转之前应该先保证ffmpeg已经安装好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import os

IN_DIR ='...\\path\\to\\your\\RESULT_DIR\\'
OUT_DIR ='...\\path\\to\\outWav\\' # create by yourself

for _,_,f in os.walk(IN_DIR):
tmp = f
files = []
for t in tmp:
if '.ogg' in t:
files.append(t[:-4])

for f in files:
os.system('ffmpeg -i %s%s.ogg %s%s.wav' % (IN_DIR, f, OUT_DIR, f))

(同懒得优化了,逃)