unirebuild 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unirebuild-0.0.1/PKG-INFO +12 -0
- unirebuild-0.0.1/README.md +1 -0
- unirebuild-0.0.1/pyproject.toml +25 -0
- unirebuild-0.0.1/src/unirebuild/__init__.py +1 -0
- unirebuild-0.0.1/src/unirebuild/constants.py +10 -0
- unirebuild-0.0.1/src/unirebuild/context.py +97 -0
- unirebuild-0.0.1/src/unirebuild/core.py +123 -0
- unirebuild-0.0.1/src/unirebuild/ios/cgbi.py +99 -0
- unirebuild-0.0.1/src/unirebuild/ios/plist.py +48 -0
- unirebuild-0.0.1/src/unirebuild/py.typed +0 -0
- unirebuild-0.0.1/src/unirebuild/steps/__init__.py +20 -0
- unirebuild-0.0.1/src/unirebuild/steps/apply_patches.py +28 -0
- unirebuild-0.0.1/src/unirebuild/steps/base.py +19 -0
- unirebuild-0.0.1/src/unirebuild/steps/copy_bundles.py +49 -0
- unirebuild-0.0.1/src/unirebuild/steps/copy_gitignore.py +17 -0
- unirebuild-0.0.1/src/unirebuild/steps/copy_overrides.py +20 -0
- unirebuild-0.0.1/src/unirebuild/steps/custom_action.py +16 -0
- unirebuild-0.0.1/src/unirebuild/steps/decode_fsb_audio.py +81 -0
- unirebuild-0.0.1/src/unirebuild/steps/deduplicate_assets.py +155 -0
- unirebuild-0.0.1/src/unirebuild/steps/delete_assets.py +30 -0
- unirebuild-0.0.1/src/unirebuild/steps/extract_app.py +25 -0
- unirebuild-0.0.1/src/unirebuild/steps/extract_app_icon.py +112 -0
- unirebuild-0.0.1/src/unirebuild/steps/generate_deterministic_guids.py +135 -0
- unirebuild-0.0.1/src/unirebuild/steps/git_commit.py +36 -0
- unirebuild-0.0.1/src/unirebuild/steps/git_init.py +20 -0
- unirebuild-0.0.1/src/unirebuild/steps/populate_texture_settings.py +122 -0
- unirebuild-0.0.1/src/unirebuild/steps/rebuild_patches.py +104 -0
- unirebuild-0.0.1/src/unirebuild/steps/reencode_wavs.py +30 -0
- unirebuild-0.0.1/src/unirebuild/steps/run_assetripper.py +107 -0
- unirebuild-0.0.1/src/unirebuild/steps/swap_files.py +27 -0
- unirebuild-0.0.1/src/unirebuild/steps/unity_upgrade.py +57 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: unirebuild
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A modular framework for patching Unity games.
|
|
5
|
+
Author: Nuutti
|
|
6
|
+
Author-email: Nuutti <snuutti@protonmail.com>
|
|
7
|
+
Requires-Dist: colorlog>=6.10.1
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# UniRebuild
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# UniRebuild
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unirebuild"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A modular framework for patching Unity games."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Nuutti", email = "snuutti@protonmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"colorlog>=6.10.1",
|
|
12
|
+
"pyyaml>=6.0.3",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.15,<0.12.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"ruff>=0.15.16",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.ruff.lint.per-file-ignores]
|
|
25
|
+
"__init__.py" = ["F401"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .core import UniRebuild
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PatcherContext:
|
|
9
|
+
def __init__(self, game_name: str, workspace_dir: str, temp_dir: str):
|
|
10
|
+
self.game_name = game_name
|
|
11
|
+
self.workspace_dir = os.path.abspath(workspace_dir)
|
|
12
|
+
self.temp_dir = os.path.abspath(temp_dir)
|
|
13
|
+
self.platform = sys.platform
|
|
14
|
+
self.is_ci = "CI" in os.environ
|
|
15
|
+
self.args = None
|
|
16
|
+
self.setup_steps = []
|
|
17
|
+
self.rebuild_steps = []
|
|
18
|
+
self.__executable_cache = {}
|
|
19
|
+
|
|
20
|
+
def get_temp_path(self, *paths: str) -> str:
|
|
21
|
+
"""Resolves a path within the temporary directory."""
|
|
22
|
+
return os.path.join(self.temp_dir, *paths)
|
|
23
|
+
|
|
24
|
+
def get_extracted_path(self) -> str:
|
|
25
|
+
"""Returns the path where the app was extracted."""
|
|
26
|
+
return self.get_temp_path("extracted_app")
|
|
27
|
+
|
|
28
|
+
def find_executable(self, name: str) -> str | None:
|
|
29
|
+
"""Finds the path of an executable on the system path or in the current directory."""
|
|
30
|
+
if name in self.__executable_cache:
|
|
31
|
+
return self.__executable_cache[name]
|
|
32
|
+
|
|
33
|
+
search_name = name
|
|
34
|
+
if self.platform == "win32":
|
|
35
|
+
search_name += ".exe"
|
|
36
|
+
|
|
37
|
+
local_path = os.path.abspath(os.path.join(os.getcwd(), search_name))
|
|
38
|
+
if os.path.isfile(local_path) and os.access(local_path, os.X_OK):
|
|
39
|
+
self.__executable_cache[name] = local_path
|
|
40
|
+
return local_path
|
|
41
|
+
|
|
42
|
+
system_path = shutil.which(search_name)
|
|
43
|
+
if system_path:
|
|
44
|
+
abs_system_path = os.path.abspath(system_path)
|
|
45
|
+
self.__executable_cache[name] = abs_system_path
|
|
46
|
+
return abs_system_path
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def find_unity(self, version: str) -> list[str] | None:
|
|
51
|
+
"""Finds the Unity executable for a given version."""
|
|
52
|
+
cache_key = f"unity_{version}"
|
|
53
|
+
if cache_key in self.__executable_cache:
|
|
54
|
+
return self.__executable_cache[cache_key]
|
|
55
|
+
|
|
56
|
+
if self.is_ci:
|
|
57
|
+
docker_cmd = [
|
|
58
|
+
"docker",
|
|
59
|
+
"run",
|
|
60
|
+
"--rm",
|
|
61
|
+
"-v",
|
|
62
|
+
f"{os.getcwd()}:{os.getcwd()}",
|
|
63
|
+
"-w",
|
|
64
|
+
os.getcwd(),
|
|
65
|
+
f"unityci/editor:ubuntu-{version}-linux-il2cpp-3",
|
|
66
|
+
"unity-editor",
|
|
67
|
+
]
|
|
68
|
+
self.__executable_cache[cache_key] = docker_cmd
|
|
69
|
+
return docker_cmd
|
|
70
|
+
|
|
71
|
+
unity_path = ""
|
|
72
|
+
if self.platform == "linux":
|
|
73
|
+
unity_path = os.path.expanduser(
|
|
74
|
+
f"~/Unity/Hub/Editor/{version}/Editor/Unity"
|
|
75
|
+
)
|
|
76
|
+
elif self.platform == "darwin":
|
|
77
|
+
unity_path = f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"
|
|
78
|
+
elif self.platform == "win32":
|
|
79
|
+
unity_path = f"C:/Program Files/Unity/Hub/Editor/{version}/Editor/Unity.exe"
|
|
80
|
+
|
|
81
|
+
if unity_path and os.path.isfile(unity_path):
|
|
82
|
+
cmd = [unity_path]
|
|
83
|
+
self.__executable_cache[cache_key] = cmd
|
|
84
|
+
return cmd
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def run_cmd(
|
|
89
|
+
self, cmd: list[str], cwd: str | None = None, ignore_errors: bool = False
|
|
90
|
+
) -> int:
|
|
91
|
+
"""Runs a command and returns the exit code."""
|
|
92
|
+
logging.info("Running command: %s", " ".join(cmd))
|
|
93
|
+
result = subprocess.run(cmd, cwd=cwd)
|
|
94
|
+
if result.returncode != 0 and not ignore_errors:
|
|
95
|
+
raise RuntimeError(f"Command failed with exit code {result.returncode}")
|
|
96
|
+
|
|
97
|
+
return result.returncode
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import colorlog
|
|
8
|
+
|
|
9
|
+
from . import steps
|
|
10
|
+
from .steps import PatcherStep
|
|
11
|
+
from .context import PatcherContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UniRebuild:
|
|
15
|
+
def __init__(self, game_name: str, workspace_dir: str, temp_dir: str = "Temp"):
|
|
16
|
+
self.context = PatcherContext(game_name, workspace_dir, temp_dir)
|
|
17
|
+
self.setup_steps = []
|
|
18
|
+
self.rebuild_steps = []
|
|
19
|
+
|
|
20
|
+
def add_setup_steps(self, steps: list):
|
|
21
|
+
self.setup_steps.extend(steps)
|
|
22
|
+
|
|
23
|
+
def add_rebuild_steps(self, steps: list):
|
|
24
|
+
self.rebuild_steps.extend(steps)
|
|
25
|
+
|
|
26
|
+
def execute(self):
|
|
27
|
+
colorlog.basicConfig(
|
|
28
|
+
level=logging.INFO, format="%(log_color)s%(levelname)s: %(message)s"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
32
|
+
|
|
33
|
+
if not self.rebuild_steps:
|
|
34
|
+
self.rebuild_steps.append(steps.RebuildPatches())
|
|
35
|
+
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
description=f"UniRebuild - {self.context.game_name} Patcher"
|
|
38
|
+
)
|
|
39
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
parser_setup = subparsers.add_parser("setup", help="Set up the workspace")
|
|
42
|
+
parser_rebuild = subparsers.add_parser("rebuild", help="Rebuild all patches")
|
|
43
|
+
|
|
44
|
+
for step in self.setup_steps:
|
|
45
|
+
step.register_arguments(parser_setup)
|
|
46
|
+
|
|
47
|
+
for step in self.rebuild_steps:
|
|
48
|
+
step.register_arguments(parser_rebuild)
|
|
49
|
+
|
|
50
|
+
args = parser.parse_args()
|
|
51
|
+
self.context.args = args
|
|
52
|
+
|
|
53
|
+
self.context.setup_steps = self.setup_steps
|
|
54
|
+
self.context.rebuild_steps = self.rebuild_steps
|
|
55
|
+
|
|
56
|
+
if args.command == "setup":
|
|
57
|
+
if os.path.exists(self.context.workspace_dir):
|
|
58
|
+
logging.error(
|
|
59
|
+
"Workspace directory '%s' already exists. Please remove it before running setup.",
|
|
60
|
+
self.context.workspace_dir,
|
|
61
|
+
)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
if os.path.exists(self.context.temp_dir):
|
|
65
|
+
shutil.rmtree(self.context.temp_dir)
|
|
66
|
+
|
|
67
|
+
self.run_pipeline(self.setup_steps, True)
|
|
68
|
+
|
|
69
|
+
logging.info(
|
|
70
|
+
"Setup completed successfully! You can now open '%s' in Unity.",
|
|
71
|
+
self.context.workspace_dir,
|
|
72
|
+
)
|
|
73
|
+
elif args.command == "rebuild":
|
|
74
|
+
if not os.path.exists(self.context.workspace_dir):
|
|
75
|
+
logging.error(
|
|
76
|
+
"Workspace directory '%s' does not exist. Please run 'setup' first.",
|
|
77
|
+
self.context.workspace_dir,
|
|
78
|
+
)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
self.run_pipeline(self.rebuild_steps, False)
|
|
82
|
+
|
|
83
|
+
def run_pipeline(self, steps: list[PatcherStep], cleanup_temp: bool):
|
|
84
|
+
dependencies = set()
|
|
85
|
+
for step in steps:
|
|
86
|
+
for dependency in step.get_dependencies():
|
|
87
|
+
dependencies.add(dependency)
|
|
88
|
+
|
|
89
|
+
missing_tools = []
|
|
90
|
+
for dependency in dependencies:
|
|
91
|
+
if dependency.startswith("unity:"):
|
|
92
|
+
version = dependency.split(":")[1]
|
|
93
|
+
if not self.context.find_unity(version):
|
|
94
|
+
missing_tools.append(f"Unity {version}")
|
|
95
|
+
else:
|
|
96
|
+
if not self.context.find_executable(dependency):
|
|
97
|
+
missing_tools.append(dependency)
|
|
98
|
+
|
|
99
|
+
if missing_tools:
|
|
100
|
+
logging.error("The following required tools were not found:")
|
|
101
|
+
for tool in missing_tools:
|
|
102
|
+
logging.error(" - %s", tool)
|
|
103
|
+
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
if cleanup_temp:
|
|
107
|
+
shutil.rmtree(self.context.temp_dir, ignore_errors=True)
|
|
108
|
+
os.makedirs(self.context.temp_dir, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
for step in steps:
|
|
112
|
+
step.execute(self.context)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logging.error("Patching failed!")
|
|
115
|
+
logging.exception(e)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
finally:
|
|
118
|
+
if cleanup_temp:
|
|
119
|
+
logging.info("Cleaning up temporary files...")
|
|
120
|
+
shutil.rmtree(self.context.temp_dir, ignore_errors=True)
|
|
121
|
+
# TODO: move this somewhere else
|
|
122
|
+
# AssetRipper
|
|
123
|
+
shutil.rmtree("temp", ignore_errors=True)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from struct import unpack, pack
|
|
2
|
+
from zlib import decompress, compress, crc32
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Copied from https://gist.github.com/urielka/3609051
|
|
6
|
+
def cgbi_to_png(cgbi_path: str) -> bytes | None:
|
|
7
|
+
png_header = b"\x89PNG\r\n\x1a\n"
|
|
8
|
+
|
|
9
|
+
with open(cgbi_path, "rb") as file:
|
|
10
|
+
cgbi_file = file.read()
|
|
11
|
+
|
|
12
|
+
if cgbi_file[:8] != png_header:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
png_file = cgbi_file[:8]
|
|
16
|
+
chunk_pos = len(png_file)
|
|
17
|
+
idat_acc = b""
|
|
18
|
+
break_loop = False
|
|
19
|
+
|
|
20
|
+
# For each chunk in the PNG file
|
|
21
|
+
while chunk_pos < len(cgbi_file):
|
|
22
|
+
skip = False
|
|
23
|
+
|
|
24
|
+
# Reading chunk
|
|
25
|
+
chunk_length = cgbi_file[chunk_pos : chunk_pos + 4]
|
|
26
|
+
chunk_length = unpack(">L", chunk_length)[0]
|
|
27
|
+
chunk_type = cgbi_file[chunk_pos + 4 : chunk_pos + 8]
|
|
28
|
+
chunk_data = cgbi_file[chunk_pos + 8 : chunk_pos + 8 + chunk_length]
|
|
29
|
+
chunk_crc = cgbi_file[
|
|
30
|
+
chunk_pos + chunk_length + 8 : chunk_pos + chunk_length + 12
|
|
31
|
+
]
|
|
32
|
+
chunk_crc = unpack(">L", chunk_crc)[0]
|
|
33
|
+
chunk_pos += chunk_length + 12
|
|
34
|
+
|
|
35
|
+
# Parsing the header chunk
|
|
36
|
+
if chunk_type == b"IHDR":
|
|
37
|
+
width = unpack(">L", chunk_data[0:4])[0]
|
|
38
|
+
height = unpack(">L", chunk_data[4:8])[0]
|
|
39
|
+
|
|
40
|
+
# Parsing the image chunk
|
|
41
|
+
if chunk_type == b"IDAT":
|
|
42
|
+
idat_acc += chunk_data
|
|
43
|
+
skip = True
|
|
44
|
+
|
|
45
|
+
# Remove CgBI chunk
|
|
46
|
+
if chunk_type == b"CgBI":
|
|
47
|
+
skip = True
|
|
48
|
+
|
|
49
|
+
# When reaching the end chunk, process the accumulated IDAT data
|
|
50
|
+
if chunk_type == b"IEND":
|
|
51
|
+
try:
|
|
52
|
+
buf_size = width * height * 4 + height
|
|
53
|
+
chunk_data = decompress(idat_acc, -15, buf_size)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
# The PNG image is already normalized
|
|
56
|
+
print(e)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Prepare new IDAT chunk
|
|
60
|
+
chunk_type = b"IDAT"
|
|
61
|
+
|
|
62
|
+
# Swap red & blue for each pixel
|
|
63
|
+
new_data = bytearray()
|
|
64
|
+
pos = 0
|
|
65
|
+
for y in range(height):
|
|
66
|
+
# Copy the filter byte for the scanline
|
|
67
|
+
new_data.append(chunk_data[pos])
|
|
68
|
+
pos += 1
|
|
69
|
+
for x in range(width):
|
|
70
|
+
# Original order: R, G, B, A
|
|
71
|
+
r = chunk_data[pos]
|
|
72
|
+
g = chunk_data[pos + 1]
|
|
73
|
+
b = chunk_data[pos + 2]
|
|
74
|
+
a = chunk_data[pos + 3]
|
|
75
|
+
# New order: B, G, R, A
|
|
76
|
+
new_data.append(b)
|
|
77
|
+
new_data.append(g)
|
|
78
|
+
new_data.append(r)
|
|
79
|
+
new_data.append(a)
|
|
80
|
+
pos += 4
|
|
81
|
+
|
|
82
|
+
# Compress the modified image data
|
|
83
|
+
chunk_data = compress(bytes(new_data))
|
|
84
|
+
chunk_length = len(chunk_data)
|
|
85
|
+
chunk_crc = crc32(chunk_type)
|
|
86
|
+
chunk_crc = crc32(chunk_data, chunk_crc)
|
|
87
|
+
chunk_crc = (chunk_crc + 0x100000000) % 0x100000000
|
|
88
|
+
break_loop = True
|
|
89
|
+
|
|
90
|
+
if not skip:
|
|
91
|
+
png_file += pack(">L", chunk_length)
|
|
92
|
+
png_file += chunk_type
|
|
93
|
+
if chunk_length > 0:
|
|
94
|
+
png_file += chunk_data
|
|
95
|
+
png_file += pack(">L", chunk_crc)
|
|
96
|
+
if break_loop:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
return png_file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import plistlib
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def extract_plist_icon_filenames(plist_path: str) -> list[str]:
|
|
7
|
+
if not os.path.exists(plist_path):
|
|
8
|
+
raise FileNotFoundError(f"The .plist file '{plist_path}' does not exist.")
|
|
9
|
+
|
|
10
|
+
with open(plist_path, "rb") as fp:
|
|
11
|
+
try:
|
|
12
|
+
plist_data = plistlib.load(fp)
|
|
13
|
+
except Exception as e:
|
|
14
|
+
raise ValueError(f"Failed to parse .plist file: {e}")
|
|
15
|
+
|
|
16
|
+
found_icons = []
|
|
17
|
+
|
|
18
|
+
def walk_plist(obj: Any, key: str | None = None):
|
|
19
|
+
if isinstance(obj, dict):
|
|
20
|
+
for k, v in obj.items():
|
|
21
|
+
walk_plist(v, k)
|
|
22
|
+
elif isinstance(obj, list):
|
|
23
|
+
if key and "CFBundleIconFiles" in key:
|
|
24
|
+
for item in obj:
|
|
25
|
+
if isinstance(item, str):
|
|
26
|
+
found_icons.append(item)
|
|
27
|
+
else:
|
|
28
|
+
for item in obj:
|
|
29
|
+
walk_plist(item, key)
|
|
30
|
+
elif isinstance(obj, str):
|
|
31
|
+
if key and ("CFBundleIconFile" in key or "CFBundleIconName" in key):
|
|
32
|
+
found_icons.append(obj)
|
|
33
|
+
|
|
34
|
+
walk_plist(plist_data)
|
|
35
|
+
|
|
36
|
+
valid_icons = []
|
|
37
|
+
for icon in found_icons:
|
|
38
|
+
icon_cleaned = icon.strip()
|
|
39
|
+
if not icon_cleaned:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
if not icon_cleaned.lower().endswith(".png"):
|
|
43
|
+
icon_cleaned += ".png"
|
|
44
|
+
|
|
45
|
+
if icon_cleaned not in valid_icons:
|
|
46
|
+
valid_icons.append(icon_cleaned)
|
|
47
|
+
|
|
48
|
+
return valid_icons
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .base import PatcherStep
|
|
2
|
+
from .apply_patches import ApplyPatches
|
|
3
|
+
from .copy_bundles import CopyBundles
|
|
4
|
+
from .copy_gitignore import CopyGitignore
|
|
5
|
+
from .copy_overrides import CopyOverrides
|
|
6
|
+
from .custom_action import CustomAction
|
|
7
|
+
from .decode_fsb_audio import DecodeFsbAudio
|
|
8
|
+
from .deduplicate_assets import DeduplicateAssets
|
|
9
|
+
from .delete_assets import DeleteAssets
|
|
10
|
+
from .extract_app import ExtractApp
|
|
11
|
+
from .extract_app_icon import ExtractAppIcon
|
|
12
|
+
from .generate_deterministic_guids import GenerateDeterministicGuids
|
|
13
|
+
from .git_commit import GitCommit
|
|
14
|
+
from .git_init import GitInit
|
|
15
|
+
from .populate_texture_settings import PopulateTextureSettings
|
|
16
|
+
from .rebuild_patches import RebuildPatches
|
|
17
|
+
from .reencode_wavs import ReencodeWavs
|
|
18
|
+
from .run_assetripper import RunAssetRipper
|
|
19
|
+
from .swap_files import SwapFiles
|
|
20
|
+
from .unity_upgrade import UnityUpgrade
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import logging
|
|
3
|
+
import os.path
|
|
4
|
+
|
|
5
|
+
from unirebuild.context import PatcherContext
|
|
6
|
+
from unirebuild.steps import PatcherStep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApplyPatches(PatcherStep):
|
|
10
|
+
def __init__(self, patches_dir: str):
|
|
11
|
+
self.patches_dir = patches_dir
|
|
12
|
+
|
|
13
|
+
def get_dependencies(self) -> list[str]:
|
|
14
|
+
return ["git"]
|
|
15
|
+
|
|
16
|
+
def execute(self, context: PatcherContext):
|
|
17
|
+
git_path = context.find_executable("git")
|
|
18
|
+
patches = sorted(
|
|
19
|
+
glob.glob(os.path.abspath(os.path.join(self.patches_dir, "*.patch")))
|
|
20
|
+
)
|
|
21
|
+
if patches:
|
|
22
|
+
logging.info("Applying %d patches...", len(patches))
|
|
23
|
+
context.run_cmd(
|
|
24
|
+
[git_path, "am", "--3way", "--ignore-whitespace"] + patches,
|
|
25
|
+
cwd=context.workspace_dir,
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
logging.info("No patches found to apply.")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
|
|
3
|
+
from unirebuild.context import PatcherContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PatcherStep:
|
|
7
|
+
"""Base class for all patcher steps."""
|
|
8
|
+
|
|
9
|
+
def register_arguments(self, parser: ArgumentParser):
|
|
10
|
+
"""Registers command-line arguments for this step."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
def get_dependencies(self) -> list[str]:
|
|
14
|
+
"""Returns a list of tool dependencies required by this step."""
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
def execute(self, context: PatcherContext):
|
|
18
|
+
"""Executes the step using the provided context."""
|
|
19
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os.path
|
|
3
|
+
import shutil
|
|
4
|
+
from argparse import ArgumentParser
|
|
5
|
+
|
|
6
|
+
from unirebuild.context import PatcherContext
|
|
7
|
+
from unirebuild.steps import PatcherStep
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CopyBundles(PatcherStep):
|
|
11
|
+
def __init__(
|
|
12
|
+
self, bundles_path_arg: str = "bundles", delete_existing: bool = False
|
|
13
|
+
):
|
|
14
|
+
self.bundles_path_arg = bundles_path_arg
|
|
15
|
+
self.delete_existing = delete_existing
|
|
16
|
+
|
|
17
|
+
def register_arguments(self, parser: ArgumentParser):
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
self.bundles_path_arg,
|
|
20
|
+
help="Path to a folder containing the game asset bundles",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def execute(self, context: PatcherContext):
|
|
24
|
+
bundles_src = getattr(context.args, self.bundles_path_arg)
|
|
25
|
+
bundles_dst = os.path.join(context.get_extracted_path(), "bundles")
|
|
26
|
+
|
|
27
|
+
if not os.path.exists(bundles_src):
|
|
28
|
+
raise FileNotFoundError(
|
|
29
|
+
f"Bundles directory '{bundles_src}' does not exist."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logging.info("Copying bundles from '%s' to '%s'...", bundles_src, bundles_dst)
|
|
33
|
+
shutil.copytree(bundles_src, bundles_dst)
|
|
34
|
+
|
|
35
|
+
if not self.delete_existing:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
bundles = set()
|
|
39
|
+
for root, dirs, files in os.walk(bundles_dst):
|
|
40
|
+
for file in files:
|
|
41
|
+
bundles.add(file)
|
|
42
|
+
|
|
43
|
+
app_path = os.path.join(context.get_extracted_path(), "app")
|
|
44
|
+
for root, dirs, files in os.walk(app_path):
|
|
45
|
+
for file in files:
|
|
46
|
+
if file in bundles:
|
|
47
|
+
file_path = os.path.join(root, file)
|
|
48
|
+
logging.info("Deleting existing bundle '%s'...", file_path)
|
|
49
|
+
os.remove(file_path)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from unirebuild.context import PatcherContext
|
|
6
|
+
from unirebuild.steps import PatcherStep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CopyGitignore(PatcherStep):
|
|
10
|
+
def __init__(self, source: str):
|
|
11
|
+
self.source = source
|
|
12
|
+
|
|
13
|
+
def execute(self, context: PatcherContext):
|
|
14
|
+
logging.info("Copy .gitignore...")
|
|
15
|
+
gitignore_src = os.path.abspath(self.source)
|
|
16
|
+
gitignore_dst = os.path.join(context.workspace_dir, ".gitignore")
|
|
17
|
+
shutil.copyfile(gitignore_src, gitignore_dst)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os.path
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from unirebuild.context import PatcherContext
|
|
6
|
+
from unirebuild.steps import PatcherStep
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CopyOverrides(PatcherStep):
|
|
10
|
+
def __init__(self, overrides_dir: str):
|
|
11
|
+
self.overrides_dir = overrides_dir
|
|
12
|
+
|
|
13
|
+
def execute(self, context: PatcherContext):
|
|
14
|
+
if not os.path.exists(self.overrides_dir):
|
|
15
|
+
raise FileNotFoundError(
|
|
16
|
+
f"Overrides directory '{self.overrides_dir}' does not exist."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logging.info(f"Copying overrides from '{self.overrides_dir}'...")
|
|
20
|
+
shutil.copytree(self.overrides_dir, context.workspace_dir, dirs_exist_ok=True)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from unirebuild.context import PatcherContext
|
|
5
|
+
from unirebuild.steps import PatcherStep
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomAction(PatcherStep):
|
|
9
|
+
"""A patcher step for executing a custom user-defined action."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, action_func: Callable[[PatcherContext], None]):
|
|
12
|
+
self.action_func = action_func
|
|
13
|
+
|
|
14
|
+
def execute(self, context: PatcherContext):
|
|
15
|
+
logging.info("Running custom action '%s'...", self.action_func.__name__)
|
|
16
|
+
self.action_func(context)
|