/ src / patchman / _manager.py
_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          )