_manager.py
1 # Copyright (C) 2025 Armin "Era" Ramezani <e@4d2.org> 2 # 3 # This file is a part of patchman. 4 # 5 # patchman is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public 6 # License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later 7 # version. 8 # 9 # patchman is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied 10 # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 11 # details. 12 # 13 # You should have received a copy of the GNU Lesser General Public License along with patchman. If not, see 14 # <https://www.gnu.org/licenses/>. 15 # 16 """Main portion of Patchman.""" 17 18 import os 19 import json 20 import zlib 21 import errno 22 import base64 23 import shutil 24 import inspect 25 import cryolock 26 27 try: 28 import cpickle as pickle 29 except ImportError: 30 import pickle 31 32 try: 33 from urllib.request import urlopen 34 from urllib.error import HTTPError, URLError 35 except ImportError: 36 from urllib2 import HTTPError, URLError, urlopen 37 38 if hasattr(__import__('inspect'), 'getfullargspec'): 39 from inspect import getfullargspec 40 else: 41 from inspect import getargspec as getfullargspec 42 43 if 'exist_ok' in getfullargspec(os.makedirs).args: 44 from os import makedirs 45 else: 46 from patchman._counterfeit import makedirs 47 48 if hasattr(os, 'scandir'): 49 from os import scandir 50 else: 51 from patchman._counterfeit import scandir 52 53 from patchman import _solver 54 from patchman._kernel import Engine, VERSION_DICT 55 56 try: 57 from typing import TYPE_CHECKING 58 except ImportError: 59 TYPE_CHECKING = False 60 61 if TYPE_CHECKING: 62 from typing import Dict, List, Literal, Optional, Union 63 64 FORCE_APPLY = False 65 66 67 def _get_files_recursively(directory): # type: (str) -> List[str] 68 files = [] 69 if os.path.isdir(directory): 70 for fil in scandir(directory): 71 if fil.is_file(): 72 files.append(fil.path) 73 elif fil.is_dir(): 74 files.extend(_get_files_recursively(fil.path)) 75 return files 76 77 78 def apply_patch(path): # type: (str) -> None 79 """Given a valid path to a patch directory, apply the patch to the current environment patchman is running from.""" 80 81 with cryolock.Lock('patchman.cryolock'): 82 engine = Engine.get() 83 84 engine.screenmessage('Applying patch. Please wait...') 85 with open(os.path.join(path, 'patch.json')) as f: 86 patch_data = json.loads(f.read()) 87 if patch_data['schema'] > 2: 88 engine.screenmessage( 89 'Couldn\'t apply patch due to it using an unsupported schema. Have you tried updating patchman?', 90 (1.0, 0.0, 0.0), 91 ) 92 return None 93 base_build = min(engine.build_number, patch_data['engine_build_number']) 94 base_version = None 95 for k, v in VERSION_DICT.items(): 96 if k <= base_build: 97 base_version = v 98 break 99 if not (base_version or FORCE_APPLY): 100 engine.screenmessage( 101 'Couldn\'t apply patch due to either you or the patch using an unsupported version of Ballistica.', 102 (1.0, 0.0, 1.0) 103 ) 104 return None 105 try: 106 to_write = {} 107 for directory in ('python', 'ba_data', 'mods'): 108 pdir = os.path.join(path, directory) 109 files = _get_files_recursively(pdir) 110 edir = engine.get_directory_path(directory) 111 for fil in files: 112 dest = os.path.join(edir, os.path.relpath(fil, pdir)) 113 # FIXME: Here we always rely on source code and the availability of `build_number`s for diff-based 114 # operations, which isn't ideal for generated files (assets, binaries, etc.). We should set up a 115 # repository at some point which includes a hash of every file of every stable build of the game 116 # and use that to extend our diff-based functionality to all files. 117 try: 118 if os.path.exists(dest) and directory == 'python': 119 with open(dest, 'rb') as d: 120 our = d.read() 121 with open(fil, 'rb') as f: 122 their = f.read() 123 with urlopen( 124 'https://raw.githubusercontent.com/efroemling/ballistica/refs/tags/v' 125 + base_version 126 + '/src/assets/ba_data/python/' 127 + os.path.relpath(fil, pdir), 128 ) as r: 129 base = r.read() 130 try: 131 to_write[dest] = _solver.byte_patch( 132 base, 133 _solver.unify_diffs( 134 _solver.byte_diff(base, our, b'\n', 'quick'), 135 _solver.byte_diff(base, their, b'\n', 'quick'), 136 ), 137 b'\n' 138 ) 139 except _solver.ConflictError: 140 engine.screenmessage( 141 'Attempting to resolve a conflict. This may take a while longer than usual...', 142 (1.0, 1.0, 0.0), 143 ) 144 to_write[dest] = _solver.byte_patch( 145 base, 146 _solver.unify_diffs(_solver.byte_diff(base, our), _solver.byte_diff(base, their)) 147 ) 148 else: 149 with open(fil, 'rb') as f: 150 to_write[dest] = f.read() 151 except HTTPError as e: 152 if e.code != 404: 153 raise e 154 with open(fil, 'rb') as f: 155 to_write[dest] = f.read() 156 except (URLError, _solver.ConflictError) as e: 157 if not FORCE_APPLY: 158 raise e 159 with open(fil, 'rb') as f: 160 to_write[dest] = f.read() 161 except URLError: 162 engine.screenmessage( 163 'Couldn\'t apply patch due to a network error whilst attempting to retrieve files from the Ballistica ' 164 'repository.', 165 (1.0, 0.0, 0.0), 166 ) 167 except _solver.ConflictError: 168 engine.screenmessage( 169 'Couldn\'t apply patch due to conflicts between your files and the patch\'s.', (1.0, 0.0, 0.0) 170 ) 171 else: 172 engine.screenmessage('Overwriting data. Please don\'t suspend/close the software.', (1.0, 0.0, 0.9)) 173 for dest, new in to_write.items(): 174 if os.path.exists(dest): 175 try: 176 with open(dest, 'wb') as d: 177 d.write(new) 178 except (IOError, OSError) as e: 179 if e.errno != errno.EACCES: 180 raise e 181 dest_perm = int(oct(os.stat(dest).st_mode)[-3:], 8) 182 os.chmod(dest, 0o0600) 183 with open(dest, 'wb') as d: 184 d.write(new) 185 os.chmod(dest, dest_perm) 186 else: 187 makedirs(os.path.normpath(os.path.join(dest, '..')), exist_ok=True) 188 with open(dest, 'wb') as d: 189 d.write(new) 190 engine.blow_away_pycache() 191 engine.screenmessage( 192 'Patch applied successfully. Restart to ensure all changes have taken effect.', (0.0, 1.0, 0.0) 193 ) 194 engine.screenmessage( 195 'Although the patch was applied successfully, patchman gives no warranty that the patch actually works ' 196 'as advertised.\nContact the author/publisher of the patch in case of issues with the patch or ' 197 'complications caused after applying the patch.', 198 (1.0, 1.0, 0.0), 199 ) 200 201 202 def generate_patch(name, version, python = None, ba_data = None, mods = None, dependencies = None): 203 # type: (str, int, Optional[List[str]], Optional[List[str]], Optional[List[str]], Optional[List[str]]) -> str 204 """Assuming a proper environment; generate a patch directory and return its path. 205 206 Parameters: 207 `name` will be the name of the patch; it should be unique and descriptive. 208 209 `version` should be a natural number; it should start from 1 and always increase for newer versions of the patch. 210 211 `python` is a list of paths for files (mostly python scripts) that should be included in the patch. These paths 212 must be in the python directory that the running instance of Ballistica is using (which is either the user 213 system scripts, dist/ba_data/python, or ba_data/python). Diff operations will usually be carried out for these 214 files when a user tries to apply the generated patch; so it is unlikely for patches to override each other's 215 `python` files even if they edit the same `python` files. 216 217 `ba_data` is a list of paths for files (mostly asset files) that should be included in the patch. These paths 218 must be in the ba_data directory that the running instance of Ballistica is utilizing (which is either 219 dist/ba_data or ba_data). These files will explicitly overwrite existing files of the same path when a user 220 tries to apply the generated patch, so some patches may override each other's `ba_data` files. 221 222 `mods` is a list of paths for files that should be included in the patch. Same principles as `ba_data` but for 223 files located in the mods folder. 224 225 `dependencies` should not be utilized for now. 226 """ 227 228 engine = Engine.get() 229 230 engine.screenmessage('Generating patch...') 231 proot = os.path.join(engine.get_directory_path('mods'), 'patches', name) 232 if os.path.exists(proot): 233 shutil.rmtree(proot, True) 234 makedirs(proot) 235 with open(os.path.join(proot, 'patch.json'), 'w') as p: 236 p.write( 237 json.dumps( 238 { 239 'version': version, 240 'dependencies': dependencies or [], 241 "engine_build_number": engine.build_number, 242 'schema': 2, 243 }, 244 ), 245 ) 246 247 def pull_over(directory, *args): # type: (Literal['python', 'ba_data', 'mods'], *str) -> None 248 eroot = engine.get_directory_path(directory) 249 for fil in args: 250 if os.path.normcase(os.path.abspath(fil)).startswith(os.path.normcase(os.path.abspath(eroot))): 251 makedirs( 252 os.path.normpath(os.path.join(proot, directory, os.path.relpath(fil, eroot), '..')), exist_ok=True 253 ) 254 with open(os.path.join(proot, directory, os.path.relpath(fil, eroot)), 'wb') as d: 255 with open(fil, 'rb') as f: 256 d.write(f.read()) 257 else: 258 engine.screenmessage( 259 'Excluding "' + fil + '" as a \'' + directory + '\' file as it is not within "' + eroot + '"', 260 (1.0, 1.0, 0.0), 261 ) 262 263 if python: 264 pull_over('python', *python) 265 if ba_data: 266 pull_over('ba_data', *ba_data) 267 if mods: 268 pull_over('mods', *mods) 269 engine.screenmessage('Patch generated successfully. Path: ' + proot, (0.0, 1.0, 0.0)) 270 return proot 271 272 273 def _build_tree(path): # type: (str) -> Dict[str, Union[dict, bytes]] 274 tree = {} 275 for fil in scandir(path): 276 if fil.name.endswith('.patchmeta'): 277 continue 278 if fil.is_dir(): 279 tree[fil.name] = _build_tree(fil.path) 280 elif fil.is_file(): 281 with open(fil.path, 'rb') as f: 282 tree[fil.name] = f.read() 283 return tree 284 285 286 def generate_injectors(name, path = None, include_patchman = True, api_of_injector_text_to_copy = None): 287 # type: (str, Optional[str], bool, Optional[int]) -> None 288 """Generate standalone single-file plugins that can inject/install a patch (and patchman). 289 290 Generated injectors are typically written to your mods folder. 291 292 `api_of_injector_text_to_copy` can be assigned to an integer. if an injector for the corresponding api version is 293 generated, patchman will attempt to copy its content to your clipboard. This is useful for generating injectors on 294 locked-down systems like Android. 295 """ 296 297 engine = Engine.get() 298 299 engine.screenmessage('Generating injector plugins...') 300 to_embed = {} 301 with open(inspect.getfile(Engine), 'rb') as f: 302 to_embed['_patchman_kernel_code'] = '\'' + base64.b64encode(f.read()).decode('utf-8') + '\'' 303 if include_patchman: 304 to_embed['_patchman_structure_dumps'] = ( 305 '\'' 306 + base64.b64encode( 307 zlib.compress( 308 pickle.dumps( 309 { 310 'patchman': _build_tree(os.path.normpath(os.path.join(__file__, '..'))), 311 'cryolock': _build_tree(os.path.normpath(os.path.join(__file__, '..', '..', 'cryolock'))), 312 }, 313 2, 314 ), 315 ), 316 ).decode('utf-8') 317 + '\'' 318 ) 319 with open(os.path.normpath(os.path.join(__file__, '..', '__init__.py'))) as f: 320 text = f.read() 321 for line in text.splitlines(): 322 if line.startswith('# patchmeta: commit '): 323 to_embed['_patchman_commit'] = line[len('# patchmeta: commit '):] 324 if path: 325 to_embed['_patch_structure_dumps'] = ( 326 '\'' + base64.b64encode(zlib.compress(pickle.dumps({name: _build_tree(path)}, 2))).decode('utf-8') + '\'' 327 ) 328 with open(os.path.join(path, 'patch.json')) as f: 329 to_embed['_patch_version'] = str(json.loads(f.read())['version']) 330 331 base_injector = '' 332 with open(os.path.normpath(os.path.join(__file__, '..', '_injector_template.py'))) as f: 333 text = f.read() 334 for line in text.splitlines(): 335 processed = False 336 for k, v in to_embed.items(): 337 if line.endswith('# patchmeta: mount injector.' + k): 338 base_injector += k + ' = ' + v + '\n' 339 processed = True 340 break 341 if not (processed or line.endswith('# patchmeta: dependent')): 342 base_injector += line + '\n' 343 base_injector = base_injector[:-1] 344 injector_count = 0 345 for api in (8, 9): 346 injector = '' 347 for line in base_injector.splitlines(): 348 if line == '# patchmeta: insert ba_meta api': 349 injector += '# ba_meta require api ' + str(api) + '\n' 350 elif line == '# patchmeta: insert ba_meta plugin': 351 injector += '# ba_meta export babase.Plugin\n' 352 else: 353 injector += line + '\n' 354 injector = injector[:-1] 355 bruh_name = '' 356 for char in name: 357 if ord(char) in list(range(48, 58)) + list(range(65, 91)) + list(range(97, 123)): 358 bruh_name += char 359 with open(os.path.join(engine.get_directory_path('mods'), bruh_name + 'injector' + str(api) + '.py'), 'w') as f: 360 f.write(injector) 361 if api == api_of_injector_text_to_copy: 362 if engine.clipboard_is_supported(): 363 engine.clipboard_set_text(injector) 364 engine.screenmessage('API ' + str(api) + ' injector code copied to clipboard.', (0.0, 1.0, 0.0)) 365 else: 366 engine.screenmessage('Failed to copy injector code to clipboard.', (1.0, 1.0, 0.0)) 367 injector_count += 1 368 if injector_count == 1: 369 engine.screenmessage( 370 'Generated an injector for 1 API successfully.\nThis injector should be in the mods folder now.', 371 (0.0, 1.0, 0.0), 372 ) 373 else: 374 engine.screenmessage( 375 'Generated injectors for ' 376 + str(injector_count) 377 + ' different APIs successfully.\nAll these injectors should be in the mods folder now.', 378 (0.0, 1.0, 0.0), 379 )