azurpaint 1.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Fernando
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.1
2
+ Name: azurpaint
3
+ Version: 1.0.0
4
+ Summary: Azur Lane painting reconstructor/extractor.
5
+ Author-email: Fernando <81207411+Fernando2603@users.noreply.github.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2024 Fernando
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/Fernando2603/azurpaint
29
+ Project-URL: Bug Tracker, https://github.com/Fernando2603/azurpaint/issues
30
+ Keywords: python,unity,azurlane,azur-lane,extractor
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: Programming Language :: Python
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Games/Entertainment
39
+ Classifier: Topic :: Multimedia :: Graphics
40
+ Requires-Python: >=3.9
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: Pillow
44
+ Requires-Dist: UnityPy==1.10.6
45
+
46
+ # azurpaint
47
+
48
+ [![PyPI supported Python versions](https://img.shields.io/pypi/pyversions/azurpaint.svg)](https://pypi.python.org/pypi/azurpaint)
49
+ [![MIT](https://img.shields.io/github/license/Fernando2603/azurpaint)](https://github.com/Fernando2603/azurpaint/blob/main/LICENSE)
50
+
51
+
52
+ Azur Lane painting reconstructor/extractor.
53
+
54
+ ## Install
55
+ **Python 3.9.0 or higher is required**
56
+ ```cmd
57
+ pip install azurpaint
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ Simple example to extract painting
63
+
64
+ - create new folder
65
+ - get and extract `/Android/obb/com.YoStarEN.AzurLane/*.obb` to new folder
66
+ - copy `AssetBundles` from `/Android/data/com.YoStarEN.AzurLane/files/AssetBundles`
67
+
68
+ > [!NOTE]
69
+ > only `painting`, `paintings` and `paintingface` folder are required from `AssetBundles`.
70
+
71
+
72
+ ```Python
73
+ from pathlib import Path
74
+ from azurpaint import Azurpaint
75
+ from azurpaint.exception import PrefabNotFound
76
+
77
+ # extract painting with default expression/face
78
+ def extract(asset_bundle_path, prefab_path):
79
+ try:
80
+ azurpaint = Azurpaint(asset_bundle_path, prefab_path)
81
+
82
+ # search dependencies automatically within AssetBundles (root)
83
+ # this is still in experimental mode so far on testing the result is good
84
+ # load_dependencies only searching in local file and it doesn't know
85
+ # if there any missing dependency it doesn't know what file it is
86
+ # so you should provide complete asset include extracted OBB before running this
87
+ azurpaint.load_dependencies()
88
+
89
+ # PIL.Image.Image
90
+ return azurpaint.create()
91
+ except PrefabNotFound:
92
+ print(f"{prefab_path} is not an prefab.")
93
+
94
+
95
+ # extract painting with all face/expression into output_dir
96
+ def extract_all_face(asset_bundle_path, prefab_path, output_dir):
97
+ try:
98
+ azurpaint = Azurpaint(asset_bundle_path, prefab_path)
99
+ azurpaint.load_dependencies()
100
+
101
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
102
+ azurpaint.create().save(Path(output_dir, f"{azurpaint.prefab.name}-default.png"))
103
+
104
+ for face in azurpaint.face_list:
105
+ azurpaint.change_face(face)
106
+ azurpaint.create().save(Path(output_dir, f"{azurpaint.prefab.name}-{face}.png"))
107
+
108
+ except PrefabNotFound:
109
+ print(f"{prefab_path} is not an prefab.")
110
+
111
+
112
+ if __name__ == '__main__':
113
+ # azurpaint require asset that have .prefab
114
+ # will raise PrefabNotFound if file is not an prefab
115
+ extract('path_to/AssetBundles', 'painting/tashigan')
116
+ ```
@@ -0,0 +1,71 @@
1
+ # azurpaint
2
+
3
+ [![PyPI supported Python versions](https://img.shields.io/pypi/pyversions/azurpaint.svg)](https://pypi.python.org/pypi/azurpaint)
4
+ [![MIT](https://img.shields.io/github/license/Fernando2603/azurpaint)](https://github.com/Fernando2603/azurpaint/blob/main/LICENSE)
5
+
6
+
7
+ Azur Lane painting reconstructor/extractor.
8
+
9
+ ## Install
10
+ **Python 3.9.0 or higher is required**
11
+ ```cmd
12
+ pip install azurpaint
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Simple example to extract painting
18
+
19
+ - create new folder
20
+ - get and extract `/Android/obb/com.YoStarEN.AzurLane/*.obb` to new folder
21
+ - copy `AssetBundles` from `/Android/data/com.YoStarEN.AzurLane/files/AssetBundles`
22
+
23
+ > [!NOTE]
24
+ > only `painting`, `paintings` and `paintingface` folder are required from `AssetBundles`.
25
+
26
+
27
+ ```Python
28
+ from pathlib import Path
29
+ from azurpaint import Azurpaint
30
+ from azurpaint.exception import PrefabNotFound
31
+
32
+ # extract painting with default expression/face
33
+ def extract(asset_bundle_path, prefab_path):
34
+ try:
35
+ azurpaint = Azurpaint(asset_bundle_path, prefab_path)
36
+
37
+ # search dependencies automatically within AssetBundles (root)
38
+ # this is still in experimental mode so far on testing the result is good
39
+ # load_dependencies only searching in local file and it doesn't know
40
+ # if there any missing dependency it doesn't know what file it is
41
+ # so you should provide complete asset include extracted OBB before running this
42
+ azurpaint.load_dependencies()
43
+
44
+ # PIL.Image.Image
45
+ return azurpaint.create()
46
+ except PrefabNotFound:
47
+ print(f"{prefab_path} is not an prefab.")
48
+
49
+
50
+ # extract painting with all face/expression into output_dir
51
+ def extract_all_face(asset_bundle_path, prefab_path, output_dir):
52
+ try:
53
+ azurpaint = Azurpaint(asset_bundle_path, prefab_path)
54
+ azurpaint.load_dependencies()
55
+
56
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
57
+ azurpaint.create().save(Path(output_dir, f"{azurpaint.prefab.name}-default.png"))
58
+
59
+ for face in azurpaint.face_list:
60
+ azurpaint.change_face(face)
61
+ azurpaint.create().save(Path(output_dir, f"{azurpaint.prefab.name}-{face}.png"))
62
+
63
+ except PrefabNotFound:
64
+ print(f"{prefab_path} is not an prefab.")
65
+
66
+
67
+ if __name__ == '__main__':
68
+ # azurpaint require asset that have .prefab
69
+ # will raise PrefabNotFound if file is not an prefab
70
+ extract('path_to/AssetBundles', 'painting/tashigan')
71
+ ```
@@ -0,0 +1,6 @@
1
+ __version__ = '1.0.0'
2
+
3
+ from .main import Azurpaint
4
+ from .exception import *
5
+ from .types import *
6
+ from .lib import Inspector, Tree, Node
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from .Vector2 import Vector2
4
+
5
+
6
+ class AABB:
7
+ center: Vector2
8
+ extend: Vector2
9
+
10
+ def __init__(self, center: Vector2, extend: Vector2) -> None:
11
+ self.center = center
12
+ self.extend = extend
13
+
14
+
15
+ def __repr__(self) -> str:
16
+ return f"<{self.__class__.__name__} center={self.center.values()} extend={self.extend.values()}>"
17
+
18
+
19
+ @property
20
+ def padding(self) -> Vector2:
21
+ return self.center - self.extend
22
+
23
+
24
+ @property
25
+ def size(self) -> Vector2:
26
+ return self.center + self.extend
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
4
+ from pathlib import Path
5
+ from UnityPy import Environment, classes
6
+ from UnityPy.enums import ClassIDType
7
+ from UnityPy.files import ObjectReader
8
+
9
+ from ..types import PathLike
10
+ from ..exception import PrefabNotFound
11
+
12
+
13
+ class AssetReader:
14
+ """
15
+ Read UnityAsset using UnityPy.
16
+ """
17
+ path: Path
18
+ prefab: Path
19
+ environment: Environment
20
+ files: List[str] = []
21
+ face: Dict[str, int]
22
+
23
+ def __init__(self, path: PathLike, prefab: PathLike) -> None:
24
+ self.face = {}
25
+ self.path = Path(path)
26
+ self.prefab = Path(prefab)
27
+
28
+ if not self.filepath.exists():
29
+ raise FileNotFoundError(f"File {self.filepath.as_posix()!r} not exists.")
30
+
31
+ self.environment = Environment(self.filepath.as_posix())
32
+ self.files.append(self.prefab.as_posix())
33
+
34
+ if not self.has_prefab:
35
+ raise PrefabNotFound(f"Prefab {self.prefab.as_posix()!r} not exists.")
36
+
37
+
38
+ def __repr__(self) -> str:
39
+ return f"<{self.__class__.__name__} prefab={self.prefab.as_posix()}>"
40
+
41
+
42
+ @property
43
+ def root(self) -> classes.GameObject:
44
+ return self.get_object_by_path_id(next(iter(self.environment.container.values())).path_id)
45
+
46
+
47
+ @property
48
+ def filepath(self) -> Path:
49
+ return Path(self.path, self.prefab)
50
+
51
+
52
+ @property
53
+ def has_prefab(self) -> bool:
54
+ try:
55
+ return any(key.endswith('.prefab') for key in self.environment.container.keys())
56
+ except:
57
+ return False
58
+
59
+
60
+ @property
61
+ def cabs(self) -> List[str]:
62
+ return list(k for k in self.environment.cabs.keys() if not k.endswith('.ress'))
63
+
64
+
65
+ @property
66
+ def dependencies(self) -> List[str]:
67
+ assets: classes.AssetBundle = self.get_object_by_path_id(1)
68
+
69
+ if not hasattr(assets, 'm_Dependencies'):
70
+ raise ValueError(f"Prefab {self.prefab.as_posix()} is missing 'AssetBundle.m_Dependencies'.")
71
+
72
+ return cast(List[str], assets.m_Dependencies)
73
+
74
+
75
+ def get_cabs(self, name: PathLike) -> List[str]:
76
+ environment = Environment(Path(name).as_posix())
77
+ return list(k for k in environment.cabs.keys() if not k.endswith('.ress'))
78
+
79
+
80
+ def load(self, path: PathLike, is_face: bool = False) -> Path:
81
+ path = Path(path)
82
+
83
+ if not path.is_relative_to(self.path):
84
+ path = Path(self.path, path)
85
+
86
+ # is_dependencies is not used, it's a bit complicated with azurlane assets
87
+ # since some asset like paintingface is not an direct dependency for a lot ship
88
+ self.environment.load_file(path.as_posix())
89
+ self.files.append(path.relative_to(self.path).as_posix())
90
+
91
+ if is_face or path.is_relative_to(Path(self.path, 'paintingface')):
92
+ self.face = self._get_face(path)
93
+
94
+ return path
95
+
96
+
97
+ def loads(self, path: Union[PathLike, Iterable[PathLike]]) -> List[Path]:
98
+ if not isinstance(path, Iterable) or isinstance(path, str):
99
+ path = [path]
100
+
101
+ _loaded = []
102
+
103
+ for link in path:
104
+ _loaded.append(self.load(link))
105
+
106
+ return _loaded
107
+
108
+
109
+ def _gather_files(self, folders: List[str] = ['painting', 'paintings', 'paintingface']) -> List[str]:
110
+ """
111
+ Get all files list that have same name as prefab in painting, paintings and paintingface
112
+ """
113
+ files: List[str] = []
114
+
115
+ for folder in folders:
116
+ glob = Path(self.path, folder).glob(f"{self.prefab.name.split('_')[0]}*")
117
+ files.extend([file.relative_to(self.path).as_posix() for file in glob])
118
+
119
+ return files
120
+
121
+
122
+ def _get_face(self, path: Path) -> Dict[str, int]:
123
+ face = {}
124
+
125
+ for obj in cast(List[ObjectReader], self.environment.files[path.as_posix()].get_objects()):
126
+ if obj.type != ClassIDType.Sprite:
127
+ continue
128
+
129
+ obj = cast(classes.Sprite, obj.read())
130
+ face[obj.name] = obj.path_id
131
+
132
+ return {key: face[key] for key in sorted(face.keys())}
133
+
134
+
135
+
136
+ def _load_face(self, force: bool = True) -> None:
137
+ if len(self.face):
138
+ return
139
+
140
+ files = self._gather_files(['paintingface'])
141
+ prefab_face = Path('paintingface', self.prefab.name).as_posix()
142
+
143
+ if prefab_face in files:
144
+ self.load(prefab_face, is_face=True)
145
+ return
146
+
147
+ if not force:
148
+ return
149
+
150
+ # face not found or not same as prefab name
151
+ # sometimes face have different name with underscore extension
152
+ # but still have same basename eg like _hx extension
153
+ # this is not perfect it may load wrong face
154
+ possible_name: List[str] = [prefab_face]
155
+
156
+ while '_' in prefab_face:
157
+ prefab_face = prefab_face.rsplit('_', 1)[0]
158
+ possible_name.append(prefab_face)
159
+
160
+ matches: List[str] = [file for file in files if file in possible_name]
161
+
162
+ if len(matches) == 0:
163
+ return
164
+
165
+ # when matches is more than one. probably the longest is more desired one
166
+ # since it splitted with underscore so we're prefer longer string
167
+ self.load(max(matches, key=len), is_face=True)
168
+
169
+
170
+ def find_dependencies(self) -> Tuple[Set[str], Set[str]]:
171
+ """
172
+ Find all dependencies in loaded path
173
+
174
+ :returns: tuple[`result`, `missing_dependencies`]
175
+ """
176
+ result: Set[str] = set()
177
+ dependencies: Set[str] = set(self.dependencies)
178
+
179
+ if not len(dependencies):
180
+ return result, dependencies
181
+
182
+ files: List[str] = self._gather_files()
183
+
184
+ # blank image used by asset to create canvas
185
+ files.append('painting/touming_tex')
186
+
187
+ for file in files:
188
+ if not len(dependencies):
189
+ break
190
+
191
+ for cab in self.get_cabs(Path(self.path, file)):
192
+ if cab in dependencies:
193
+ result.add(file)
194
+ dependencies.remove(cab)
195
+
196
+ return result, dependencies
197
+
198
+
199
+ def load_dependencies(self, force_face_load: bool = False) -> None:
200
+ """
201
+ Automatically search for dependencies by name.
202
+
203
+ :params force_face_load: force load face with matching name
204
+
205
+ Warning
206
+ -------
207
+ This is still in experimental state, undefined behaviour may occurs.
208
+ """
209
+ dependencies, _ = self.find_dependencies()
210
+
211
+ if not len(self.face):
212
+ self._load_face(force=force_face_load)
213
+
214
+ self.loads(dependencies)
215
+
216
+
217
+ def get_object_by_path_id(self, path_id: int) -> Any:
218
+ for obj in self.environment.objects:
219
+ if obj.path_id == path_id:
220
+ return cast(Any, obj.read(return_typetree_on_error=False))
221
+
222
+
223
+ def get_object_by_name(self, name: str, type: ClassIDType) -> Any:
224
+ for obj in self.environment.objects:
225
+ try:
226
+ if not hasattr(obj, 'name') or (obj.type != type):
227
+ continue
228
+
229
+ obj = cast(Any, obj.read(return_typetree_on_error=False))
230
+
231
+ if hasattr(obj, 'name') and (str(obj.name) == str(name)):
232
+ return obj
233
+
234
+ except:
235
+ continue
236
+
237
+
238
+ def get_component_from_object(
239
+ self,
240
+ gameobject: classes.GameObject,
241
+ types: Optional[Iterable[ClassIDType]] = None,
242
+ names: Optional[Set[str]] = None,
243
+ attributes: Optional[Set[str]] = None
244
+ ) -> Any:
245
+ for component_pptr in cast(List[classes.PPtr], gameobject.m_Components):
246
+ if types and (not component_pptr.type in types):
247
+ continue
248
+
249
+ component = self.get_object_by_path_id(component_pptr.path_id)
250
+
251
+ if names and (not component.name in names):
252
+ continue
253
+
254
+ if attributes:
255
+ for attribute in attributes:
256
+ if hasattr(component, attribute):
257
+ return component
258
+
259
+ return component