0%

《河洛群侠传》存档格式转换脚本

只要你等待的足够久,你想要的功能就会从开源社区自己长出来。 —— tg大佬·但忘了·是谁。
跳转:messagepack_csharp_unpacker.py

起因

在20-21年我尝试写了一个《河洛群侠传》的存档修改器,但因为当时水平太差,连格式都分析不出来,最后只能将河洛存读档的代码复制出来直接用,最后也只写出一个解包器,修改器不了了之。

时隔多年重新搜索,竟然发现有大佬写出了转换工具,而且最后更新时间在一个月前
令人感慨,果然只要等待的时间够久,就一定有人能将你的想法实现(笑
于是拿大佬的代码改改,写(zu)出(zhuang)来(chu)了给河洛群侠传用的存档转换器。

存档格式

河洛群侠传有两种存档格式:

  • 纯文本格式的存档,header为”TitleSave”,是早期使用的存档格式
  • 使用MessagePack-CSharp打包的存档,header为”HELUO_1_0”或”HELUO_1_1”,是新版本的存档格式
    第一种格式是明文存储,修改起来很方便,网络上也已经有了很成熟的修改器
    但并没有第二种格式的修改器,通常的解决办法是修改System.String Heluo.GameConfig::SaveDataVersion为”TitleSave”,使新版游戏以旧版格式存档,以便修改。这种方法需要反编译Assembly-CSharp.dll,难度不大。

新版本存档转换为旧版本格式

我希望有不破坏游戏代码本身的方法,所以在寻求这种方式。

格式

旧版本格式的存档格式非常简单

1
2
3
4
5
6
7
第一行: TitleSave,[difficulty]
第二行:playedTime
第三行:playedDays
第四行:trackedQuestId,modName,modId
第五行:ScreenShotSize.x,ScreenShotSize.y
第六行:一个base64编码的图片
第七行:GameData

新版本格式则是
“HELUO_1_1” + HELUO_1_1 + GameData
其中后两个数据是序列化后的自定义类,而GameData中就包含角色属性、物品等数据。只要将它反序列化,就能修改了。
序列化的方法为

1
2
LZ4MessagePackSerializer.Serialize<Header_HELUO_1_1>(memoryStream2, header_HELUO_1_2, HeluoResolver.Instance);                
LZ4MessagePackSerializer.Serialize<GameData>(memoryStream2, data, HeluoResolver.Instance);

原来卡住的地方

我希望用python的msgpack和lz4包对游戏存档进行解码,再转换为旧版本格式。
但MessagePack-CSharp的LZ4MessagePackSerializer算法看不懂,再加上逆向不熟练,代码追踪时总是跟丢,多次尝试后还是放弃了。最后解决的办法是将从游戏剥离出的存读档代码和1.x版本的MessagePack-CSharp一起编译成了dll。

messagepack_csharp_unpacker.py

在多年之后,终于看到了LZ4MessagePackSerializer.Deserialize的python实现,泪目
大佬的代码:https://gist.github.com/t-wy/778123fedd15513e4626ad27f07cb690

看完代码之后才发现原来结构这么简单(汗

于是改了一下,河群的格式转换就成了:

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
import sys
import shutil
import json
from datetime import datetime
from msgpack import Unpacker
from lz4.block import decompress as lz4_decompress
def decode_savedata(data):
save_loader = Unpacker(None, max_buffer_size=0,strict_map_key=False)
save_loader.feed(data)
for _ in range(2):
ext_code, data = save_loader.unpack()
assert ext_code == 99

unpacker = Unpacker(None, max_buffer_size=0,strict_map_key=False)
unpacker.feed(data)
original_size = unpacker.unpack()
start_pos = unpacker.tell()
print("original_size",original_size,"start_pos",start_pos)
data1 = data[start_pos:]
raw = lz4_decompress(data1, uncompressed_size=original_size)
assert len(raw) == original_size

unpacker = Unpacker(None, max_buffer_size=0,strict_map_key=False)
unpacker.feed(raw)
d = unpacker.unpack()
if 'screenShotData' in d:
d['screenShotData'] = ''
yield d

def time_format(playedTime):
millisec = playedTime % 10000000
ss = int(playedTime / 10000000 % 60)
mm = int(playedTime / 600000000 % 60)
hh = int(playedTime / 36000000000)
return f"{hh:02}:{mm:02}:{ss:02}.{millisec:07}"

def generate_titlesave(header,data) -> bytes:
s = ''
s += f"TitleSave,{header['difficulty']}\n"
s += time_format(header['playedTime']) + '\n'
s += str(header['playedDays']) + '\n'
s += f"{header['trackedQuestId']},{header['modName']},{header['modId']}\n"
s += f"{int(data['ScreenShotSize'][0])}, {int(data['ScreenShotSize'][1])}, 0\n"
s += data['ScreenShot'] + "\n"
data['ScreenShot'] = ''
# 特殊的格式转换
data['TotalTime'] = time_format(data['TotalTime'])
data['GameWorldTime'] = datetime.fromtimestamp((data['GameWorldTime'] - 621355968000000000) / 10000000).strftime("%Y-%m-%dT%H:%M:%S") # https://github.com/EduBic/CSharp-Ticks-Date-Time-In-Javascript
data['PlayerPostioion'] = [f"{round(i,5):.5f}" for i in data['PlayerPostioion']]
data['PlayerDirection'] = round(data['PlayerDirection'],6)
data['RelativeCameraPosition'] = [f"{round(i,5):.5f}" for i in data['RelativeCameraPosition']]
data['TaggedPosition'] = [f"{round(i,5):.5f}" for i in data['TaggedPosition']]
def recursive_modify(d: dict):
for k in d:
v = d[k]
if k in ['position', 'forward'] and isinstance(v,list):
d[k] = [f"{round(j,5):.5f}" for j in d[k]]
elif isinstance(v,dict):
recursive_modify(d[k])
elif isinstance(v,list):
for it in v:
if isinstance(it,dict):
recursive_modify(it)
recursive_modify(data)
data = json.dumps(data,separators=(",",":"),ensure_ascii=False) # https://stackoverflow.com/a/18337754
s += data
s = s.encode("utf8")
return s

if __name__ == '__main__':
if len(sys.argv) < 2:
print("usage: python heluo_save_converter.py savefile [output_name]")
exit()
elif len(sys.argv) == 2:
save1 = save2 = sys.argv[1]
else:
save1, save2, *_ = sys.argv[1:]
with open(save1,"rb") as f:
f.read(9)
binary = f.read()
if save1 == save2:
shutil.copy(save1,save1+".bak")
header,gamedata = list(decode_savedata(binary))
save = generate_titlesave(header,gamedata)
with open(save2,"wb") as f:
f.write(save)

说点别的,没有逻辑

没想到三年前没做成的事情会在这个时候重新跳出来。回想一下三年前,才发现这几年过去了,自己竟然一点长进也没有(叹气
有人说过,和平庸的自己和解,是绝大多数人必须经历的事。对我来说,虽然很早就意识到了,但直到现在也不愿承认。或许今后我还是会一直挣扎吧。
从以前,到现在这个已不再青春的年纪,直到本就不强的能力越来越衰弱,最终被这个世界抛弃的未来,或许和自己闹别扭,想要在一地废品中找到属于自己独有的、能拿来吹牛逼的优势这件事终究还是会一直缠着我吧。
什么眼高手低的goushi人生。