forked from ACreTeam/ac-decomp
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmsg_tool.py
358 lines (327 loc) · 12 KB
/
msg_tool.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
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import argparse
import struct
import os
import re
CHAR_MAP = [
"¡", "¿", "Ä", "À", "Á", "Â", "Ã", "Å", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î",
"Ï", "Ð", "Ñ", "Ò", "Ó", "Ô", "Õ", "Ö", "Ø", "Ù", "Ú", "Û", "Ü", "ß", "Þ", "à",
" ", "!", "\"", "á", "â", "%", "&", "'", "(", ")", "~", "♥", ",", "-", ".", "♪",
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", "🌢", "<", "=", ">", "?",
"@", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O",
"P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "ã", "💢", "ä", "å", "_",
"ç", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
"p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "è", "é", "ê", "ë", "\u007f",
"�", "ì", "í", "î", "ï", "•", "ð", "ñ", "ò", "ó", "ô", "õ", "ö", "⁰", "ù", "ú",
"ー", "û", "ü", "ý", "ÿ", "þ", "Ý", "¦", "§", "ḏ", "ṉ", "‖", "µ", "³", "²", "¹", # note that a̱ and o̱ had to be changed because they're actually two characters in unicode. a̱ -> ḏ | o̱ -> ṉ
"¯", "¬", "Æ", "æ", "„", "»", "«", "☀", "☁", "☂", "🌬", "☃", "∋", "∈", "/", "∞",
"○", "🗙", "□", "△", "+", "⚡", "♂", "♀", "🍀", "★", "💀", "😮", "😄", "😣", "😠", "😃",
"×", "➗", "🔨", "🎀", "✉", "💰", "🐾", "🐶", "🐱", "🐰", "🐦", "🐮", "🐷", "\n", "🐟", "🐞",
";", "#", "\u00d2", "\u00d3", "⚷", "\u00d5", "\u00d6", "\u00d7", "\u00d8", "\u00d9", "\u00da", "\u00db", "\u00dc", "Ỳ", "ꟓ", "\u00df",
"\u00e0", "\u00e1", "\u00e2", "\u00e3", "\u00e4", "\u00e5", "\u00e6", "\u00e7", "\u00e8", "\u00e9", "\u00ea", "\u00eb", "\u00ec", "\u00ed", "\u00ee", "\u00ef",
"\u00f0", "\u00f1", "\u00f2", "\u00f3", "\u00f4", "\u00f5", "\u00f6", "÷", "\u00f8", "\u00f9", "\u00fa", "\u00fb", "\u00fc", "\u00fd", "\u00fe", "\u00ff"
]
CONT_SIZES = [
2, 2, 2, 3, 2, 5, 2, 2, 5, 5, 5, 5, 5, 2, 4, 4,
4, 4, 4, 6, 8, 10, 6, 8, 10, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
6, 3, 3, 3, 3, 2, 4, 4, 3, 3, 3, 2, 2, 2, 2, 2,
2, 2, 2, 6, 3, 3, 4, 3, 2, 2, 6, 2, 2, 3, 3, 3,
3, 2, 2, 2, 2, 2, 2, 4, 4, 12, 14
]
COMMANDS = [
'MSGEND',
'MSGCONTINUE',
'MSGCLEAR',
'PAUSE',
'BTN',
'TEXTCOLOR',
'ABLECANCEL',
'UNABLECANCEL',
'DEMOPLR',
'DEMONPC0',
'DEMONPC1',
'DEMONPC2',
'DEMONPCQST',
'OPENCHOICE',
'SETFORCEMSG',
'SETNEXTMSG0',
'SETNEXTMSG1',
'SETNEXTMSG2',
'SETNEXTMSG3',
'SETNEXTMSGRND2',
'SETNEXTMSGRND3',
'SETNEXTMSGRND4',
'SETSELSTR2',
'SETSELSTR3',
'SETSELSTR4',
'FORCENEXT',
'STR_PLAYERNAME',
'STR_TALKNAME',
'STR_TAIL',
'STR_YEAR',
'STR_MONTH',
'STR_WEEK',
'STR_DAY',
'STR_HOUR',
'STR_MIN',
'STR_SEC',
'STR_FREE0',
'STR_FREE1',
'STR_FREE2',
'STR_FREE3',
'STR_FREE4',
'STR_FREE5',
'STR_FREE6',
'STR_FREE7',
'STR_FREE8',
'STR_FREE9',
'STR_DETERMINATION',
'STR_COUNTRYNAME',
'STR_RNDNUM',
'STR_ITEM0',
'STR_ITEM1',
'STR_ITEM2',
'STR_ITEM3',
'STR_ITEM4',
'STR_FREE10',
'STR_FREE11',
'STR_FREE12',
'STR_FREE13',
'STR_FREE14',
'STR_FREE15',
'STR_FREE16',
'STR_FREE17',
'STR_FREE18',
'STR_FREE19',
'STR_MAIL',
'LUCK_NEUTRAL',
'LUCK_RELATIONSHIP',
'LUCK_UNPOPULAR',
'LUCK_BAD',
'LUCK_MONEY',
'LUCK_GOODS',
'LUCK_6',
'LUCK_7',
'LUCK_8',
'LUCK_9',
'MSGCONTENTS_NORMAL',
'MSGCONTENTS_ANGRY',
'MSGCONTENTS_SAD',
'MSGCONTENTS_FUN',
'MSGCONTENTS_SLEEPY',
'COLORCHARS',
'SNDCUT',
'LINEOFS',
'LINETYPE',
'CHARSCALE',
'BTN2',
'BGMMAKE',
'BGMDELETE',
'MSGTIMEEND',
'SNDTRGSYS',
'LINESCALE',
'SNDNOPAGE',
'VOICETRUE',
'VOICEFALSE',
'SELNOB',
'GIVEOPEN',
'GIVECLOSE',
'MSGCONTENTS_GLOOMY',
'SELNOBCLOSE',
'SETNEXTMSGRNDSECTION',
'AGBDUMMY0',
'AGBDUMMY1',
'AGBDUMMY2',
'SPACE',
'AGBDUMMY3',
'AGBDUMMY4',
'MALEFEMALECHK',
'AGBDUMMY5',
'AGBDUMMY6',
'AGBDUMMY7',
'AGBDUMMY8',
'AGBDUMMY9',
'AGBDUMMY10',
'STR_ISLANDNAME',
'SETCURSORJUST',
'CLRCUSRORJUST',
'CUTARTICLE',
'CAPTIALIZE',
'STR_AMPM',
'SETNEXTMSG4',
'SETNEXTMSG5',
'SETSELSTR5',
'SETSELSTR6'
]
def decode_control_code(ba: bytearray, idx: int):
if ba[idx] != 0x7F:
raise ValueError("First character must be 0x7F")
cont_type = ba[idx + 1]
if cont_type >= len(CONT_SIZES):
raise ValueError(f"Invalid control code id {cont_type:02X}")
cont_size = CONT_SIZES[cont_type]
if len(ba) < idx + cont_size:
raise ValueError(f"Bytearray is not large enough for control code {cont_type:02X}")
cmd = COMMANDS[cont_type]
if cmd is not None:
if cont_size > 2:
hex_values = ' '.join('{:02X}'.format(b) for b in ba[(idx + 2):(idx + CONT_SIZES[cont_type])])
return f"<<{cmd} [{hex_values}]>>", cont_size
else:
return f"<<{cmd}>>", cont_size
else:
hex_values = ' '.join('{:02X}'.format(b) for b in ba[(idx + 2):(idx + CONT_SIZES[cont_type])])
return f"<<Control Code [{cont_type:02X}] [{hex_values}]>>", cont_size
def decode_entry(ba: bytearray, start: int, end: int, idx: int):
parts = [f'[[ENTRY {idx} START]]\n'] # Use a list to collect string parts
i = start
while i < end:
char = ba[i]
if char == 0x7F:
cont_str, cont_size = decode_control_code(ba, i)
parts.append(cont_str)
i += cont_size
else:
parts.append(CHAR_MAP[ba[i]])
i += 1
parts.append('\n\n')
return ''.join(parts) # Join the parts into a final string at the end
def decode_file(data_path: str, table_path: str, out_path: str):
idx = 0
last_end = 0
output_buffer = []
with open(data_path, 'rb') as df, open(table_path, 'rb') as tf, open(out_path, 'w') as of:
while True:
bytes = tf.read(4)
if not bytes:
break
end = struct.unpack('>I', bytes)[0]
if end != 0:
size = end - last_end
last_end = end
data = bytearray(df.read(size))
decoded_str = decode_entry(data, 0, size, idx)
output_buffer.append(decoded_str)
idx += 1
# Write buffer content to file to reduce write calls
if len(output_buffer) >= 8192:
of.write(''.join(output_buffer))
output_buffer.clear()
# Write remaining buffer content to file
if output_buffer:
of.write(''.join(output_buffer))
# Function to convert a hex string to a list of integers
def convert_hex_string_to_ints(hex_str):
# Remove spaces and convert to upper case
clean_str = hex_str.replace(" ", "").upper()
# Convert every two characters to an integer
return [int(clean_str[i:i+2], 16) for i in range(0, len(clean_str), 2)]
# pre-compiled regex patterns
cmd_pattern = re.compile(r"^([\w\s]+)")
arg_pattern = re.compile(r"\[([0-9A-Fa-f\s]*)\]")
def encode_control_code(cont_code_str: str, start_idx: int = 0, end_idx: int = None):
sliced_str = cont_code_str[start_idx:end_idx] # bad but necessary in python
cmd_match = cmd_pattern.match(sliced_str)
if not cmd_match:
raise ValueError("Missing command in control code!")
cmd = cmd_match.group(1).strip()
args = arg_pattern.findall(sliced_str)
cmd_idx = COMMANDS.index(cmd)
arg_list = [byte for hex_str in args for byte in convert_hex_string_to_ints(hex_str)]
return cmd_idx, arg_list
def encode_entry(entry: str):
ba = bytearray()
i = 0
max = len(entry)
while i < max:
char = entry[i]
if char == '<' and i < max - 1 and entry[i + 1] == '<':
start = i + 2
end = start
found = False
while end < max:
if entry[end] == '>' and end < max - 1 and entry[end + 1] == '>':
found = True
break
end += 1
if found:
cmd_idx, arg_list = encode_control_code(entry, start, end)
cmd_size = CONT_SIZES[cmd_idx]
if len(arg_list) != cmd_size - 2:
raise ValueError(f"Expected args of length {cmd_size - 2} for command {COMMANDS[cmd_idx]}, but got {len(arg_list)}")
ba.append(0x7F)
ba.append(cmd_idx)
ba.extend(arg_list)
i = end + 2
continue
ba.append(CHAR_MAP.index(char))
i += 1
return ba
def encode_file(file_path: str, data_path: str, table_path: str, data_size: int=-1, table_size: int=-1):
entries = {}
current_entry = None
recording = False
with open(file_path, 'r') as tf, open(data_path, 'wb') as df, open(table_path, 'wb') as tabf:
idx = -1
for line in tf:
l = line.strip()
# Check for entry start
if l.startswith("[[ENTRY") and l.endswith("START]]"):
entry_index = l.split()[1] # Assuming the format [[ENTRY X START]]
current_entry = entry_index
entries[current_entry] = []
recording = True
continue
# Check for entry end
if line.find("<<MSGEND>>") != -1 or line.find("<<MSGTIMEEND") or line.find("<<MSGCONTINUE>>"):
entries[current_entry].append(line)
recording = False
continue
# Record lines if within an entry and not empty
if recording and line:
entries[current_entry].append(line)
#end_ofs = 0
for entry in entries:
entries[entry] = encode_entry(''.join(entries[entry]).rstrip())
df.write(entries[entry])
tabf.write(struct.pack('>I', df.tell()))
if data_size > 0:
data_remain = data_size - df.tell()
if data_remain > 0:
df.write(b'\x00' * data_remain)
if table_size > 0:
table_remain = table_size - tabf.tell()
if table_remain > 0:
tabf.write(b'\x00' * table_remain)
return entries
def main():
parser = argparse.ArgumentParser(description='Pack or dump Animal Crossing text files.')
parser.add_argument('-m', help="The mode to run. Valid arguments are un[pack].")
parser.add_argument('path', help='The path of the source file.')
parser.add_argument('out', help='The path of the destination file.')
parser.add_argument('--data_size', help='Optional hexadecimal padded size for the data file.', required=False)
parser.add_argument('--table_size', help='Optional hexadecimal padded size for the table file.', required=False)
args = parser.parse_args()
if args.m.lower() == "pack":
# Create *_table.bin path
dir_name, file_name = os.path.split(args.out)
name, ext = os.path.splitext(file_name)
new_file_name = f"{name}_table{ext}"
# encode
encode_file(args.path, args.out, os.path.join(dir_name, new_file_name), int(args.data_size, 16) if args.data_size != None else -1, int(args.table_size, 16) if args.table_size != None else -1)
elif args.m.lower() == "unpack":
# Search for *_table.bin
dir_name, file_name = os.path.split(args.path)
name, ext = os.path.splitext(file_name)
new_file_name = f"{name}_table{ext}"
table_path = os.path.join(dir_name, new_file_name)
if not os.path.exists(table_path):
raise Exception(f'Couldn\'t find a valid table path. Please ensure {new_file_name} exists!')
# decode
decode_file(args.path, table_path, args.out)
else:
raise Exception(f'Invalid mode! Please use -m un[pack]')
if __name__ == '__main__':
main()