-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpatcher.py
308 lines (252 loc) · 10.3 KB
/
patcher.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
import shutil
import struct
from os.path import getsize
from pathlib import Path
from typing import cast
from bitstring import BitArray
from args import args
from enums.patches import Patch
from helpers.files import new_file
from helpers.files import BackupFile
from patches.parser import PatchData, PatchParser
from structures.item import Item
from logger import iris
from helpers.addresses import address_from_lorom
from structures.zone import Zone
def apply_patch(patch: Patch) -> Path:
"""
Applies a given patch to the file.
Parameters
-----------
:param:`patch`: :class:`Patch`
The patch to apply
Returns
-------
:class:`Path`
The path to the patched file.
:class:`bool`
True if the patch was applied, False if not.
Raises
------
:class:`NotImplementedError`
Error when a given patch is not implemented.
"""
# Vanilla > Frue > Spekkio, Kureji
iris.info(f"Applying patch {patch.name}")
if patch == Patch.VANILLA:
return new_file
patch_dir = Path(__file__).parent/"patches"
if patch == Patch.FRUE:
patch_dir = patch_dir/"Lufia2_-_Frue_Lufia_v7"
patch_file = patch_dir/"Frue_Lufia_v7.ips"
elif patch == Patch.SPEKKIO:
patch_dir = patch_dir/"Lufia2_-_Spekkio_Lufia_v7"
patch_file = patch_dir/"Spekkio_Lufia_v7.ips"
elif patch == Patch.KUREJI:
patch_dir = patch_dir/"Lufia2_-_Kureji_Lufia_v7"
patch_file = patch_dir/"Kureji_Lufia_difficult_with_normal_enemy_buff_v7.ips"
else:
raise NotImplementedError(f"Patch {patch.name} not implemented.")
return patch_files(Path(args.file), patch_file)
def patch_files(rom: Path, patch: Path):
# Backup original ROM
original = rom
suffix = rom.suffix
header = False # We remove the header immediately after converting the rom.
rom = original.with_suffix(".tmp")
shutil.copy(original, rom)
iris.debug(f"Applying patch {patch=} to {rom=}.")
iris.debug(f"{original=}")
iris.debug(f"{patch=}")
iris.debug(f"{rom=}")
with patch.open("rb") as pf, rom.open("r+b") as rf:
patch_size = getsize(patch)
if pf.read(5) != b"PATCH":
raise Exception("Invalid patch header.")
# Read First Record
r = pf.read(3)
while pf.tell() not in [patch_size, patch_size - 3]:
# Unpack 3-byte pointers.
offset = unpack_int(r)
if not header:
offset -= 512
# Read size of data chunk
r = pf.read(2)
size = unpack_int(r)
if size == 0: # RLE Record
r = pf.read(2)
rle_size = unpack_int(r)
data = pf.read(1) * rle_size
else:
data = pf.read(size)
if offset >= 0:
# Write to file
rf.seek(offset)
rf.write(data)
# Read Next Record
r = pf.read(3)
if patch_size - 3 == pf.tell():
trim_size = unpack_int(pf.read(3))
rf.truncate(trim_size)
# Remove backup
new = rom.with_stem(f"{rom.stem}-{args.seed}").with_suffix(suffix)
shutil.copy(rom, new)
rom.unlink()
iris.info("Patch applied.")
return new
def unpack_int(string: bytes):
"""Read an n-byte big-endian integer from a byte string."""
(ret,) = struct.unpack_from('>I', b'\x00' * (4 - len(string)) + string)
return ret
# Mapping table for SNES Game Genie characters to hexadecimal values
genie_translation_table = {
"D": 0x0, "F": 0x1, "4": 0x2, "7": 0x3, "0": 0x4,
"9": 0x5, "1": 0x6, "5": 0x7, "6": 0x8, "B": 0x9,
"C": 0xA, "8": 0xB, "A": 0xC, "2": 0xD, "3": 0xE, "E": 0xF,
}
genie_address_encrypted = "ijklqrstopabcduvwxefghmn"
genie_address_decrypted = "abcdefghijklmnopqrstuvwx"
def validate_genie_code(code: str):
for char in code:
if char not in genie_translation_table:
raise ValueError(f"Invalid Game Genie character {char}")
def translate_genie_code_chars(code: str) -> list[int]:
translated_code = [
genie_translation_table[char]
for char in code
if char in genie_translation_table
]
return translated_code
def translate_game_genie_code_snes(code: str) -> tuple[int, int]:
"""Translate a 8 sized SNES Game Genie code to a patch."""
iris.debug(f"Translating Game Genie code {code}")
validate_genie_code(code)
n0, n1, n2, n3, n4, n5, n6, n7 = translate_genie_code_chars(code)
data = (n0 << 4) + n1
h1 = (n2 << 4) + n3
h2 = (n4 << 4) + n5
h3 = (n6 << 4) + n7
_b = BitArray(bytes((h1, h2, h3))).bin
encoded: dict[str, str] = {}
decoded: list[str] = []
address: list[BitArray] = []
for i, v in enumerate(_b):
encoded[genie_address_encrypted[i]] = v
for i, v in enumerate(genie_address_decrypted):
decoded.append(encoded[v])
binary_address = decoded[0:8], decoded[8:16], decoded[16:24]
for i in binary_address:
t = "".join(i)
address.append(BitArray(bin=t))
ret_address = address[0] + address[1] + address[2]
return ret_address.uint, data
def apply_game_genie_codes(*codes: str):
"""Apply any game genie code to the rom.
https://gamefaqs.gamespot.com/boards/588451-lufia-ii-rise-of-the-sinistrals/80223211
Contains a lot of codes to use. (Needs a LOT of testing, and confirmation)
"""
if codes == ("",):
return
for code in codes:
code = code.replace("-", "").upper()
address, data = translate_game_genie_code_snes(code)
address = address_from_lorom(address)
# Empty validation, Unable to validate arbitrary Game Genie codes.
validation = {(address, None): bytearray([])}
patch = {
(address, None): bytearray([data]),
}
verify_patch(patch, validation) # type: ignore[reportArgumentType]
write_patch(patch, validation, no_verify=True) # type: ignore[reportArgumentType]
verify_after_patch(patch) # type: ignore[reportArgumentType]
# TODO: Write a function that can apply SRAM patches.
# TODO: get a event patch's bytecode diff and apply it to the rom.
# def max_world_clock():
# file = Path(__file__).parent/"patches/eventpatch_max_world_clock.txt"
# _patch, _validation = event_parser(file)
# patch, validation = parser(file)
# def open_world_base() -> None:
# file = Path(__file__).parent/"patches/eventpatch_open_world_base.txt"
# _patch, _validation = event_parser(file)
# patch, validation = parser(file)
# def skip_tutorial():
# file = Path(__file__).parent/"patches/eventpatch_skip_tutorial.txt"
# _patch, _validation = event_parser(file)
# patch, validation = parser(file)
# def treadool_warp():
# file = Path(__file__).parent/"patches/eventpatch_treadool_warp.txt"
# _patch, _validation = event_parser(file)
# patch, validation = parser(file)
def start_engine():
item = Item.from_index(449)
assert item.name_pointer.name.startswith("Engine"), f"Expected Engine, got {item.name_pointer}"
# TODO: add that to starting inventory.
def set_spawn_location(location: Zone, entrance_cutscene: int = 0x2):
# entrance_cutscene
# Unused/unknown values (by the game) crash the game.
# 01 Game ending cutscene. (for every zone?)
# 02 (first time entry for every zone?) > Forfeit Island starts a battle.
# Cutscene flag/index
# 0x01adab: 0xa9 0xa0
# 0x01adb3: 0xa9 0x0f
# VALIDATION
# 0x01adab: 0xa9 0x03
# 0x01adb3: 0xa9 0x02
iris.debug(f"Setting spawn location to {location.name=}, with {entrance_cutscene=}")
patch = {
(0x01adab, None): bytearray(b'\xa9') + bytearray(location.index.to_bytes()),
(0x01adb3, None): bytearray(b'\xa9') + bytearray(entrance_cutscene.to_bytes())
}
validation = {
(0x01adab, None): bytearray(b'\xa9\x03'),
(0x01adb3, None): bytearray(b'\xa9\x02')
}
verify_patch(patch, validation)
write_patch(patch, validation)
verify_after_patch(patch)
# event_parser = EventPatchParser() # Script parser for event patches.
parser = PatchParser() # Script parser for patches.
def apply_absynnonym_patch(name: str):
file = Path(__file__).parent/f"patches/absynnonym/patch_{name}.txt"
iris.debug(f"Patching {file.name}")
patch, validation = parser(file)
verify_patch(patch, validation)
write_patch(patch, validation, no_verify=True)
verify_after_patch(patch)
def verify_patch(patch: PatchData, validation: PatchData):
# Check if Validation is same as expected data. (before patching)
iris.debug(f"Verifying patch. {patch=}, {validation=}")
with BackupFile(new_file) as backup, backup.open("rb") as file:
for (address, _), code in sorted(validation.items()):
file.seek(address)
written = file.read(len(code))
if code != written:
raise Exception(f"Validation {address:x} conflicts with unmodified data.")
def verify_after_patch(patch: PatchData):
# Apply patch, then check if it is the same as the expected data.
iris.debug(f"Verifying after patch. {patch=}")
with BackupFile(new_file) as backup, backup.open("rb") as file:
for (address, _), code in sorted(patch.items()):
file.seek(address)
written = file.read(len(code))
if code != written:
raise Exception(f"Patch {address:x} conflicts with modified data.")
def write_patch(patch: PatchData, validation: PatchData, no_verify: bool = False):
iris.debug(f"Writing patch. {patch=}, {validation=}")
with BackupFile(new_file) as backup, backup.open("rb+") as f:
for patch_dict in (validation, patch):
for (address, _), code in sorted(patch_dict.items()):
code = cast(bytearray, code)
f.seek(address)
if patch_dict is validation:
validate = f.read(len(code))
if validate != code[:len(validate)]:
error = f'Patch {patch:s}-{address:x} did not pass validation.'
if no_verify:
print(f'WARNING: {error:s}')
else:
raise Exception(error)
else:
assert patch_dict is patch
iris.debug(f"Writing {code=} to {address=}")
f.write(code)