PyREUser3 0.1.0__py3-none-any.whl
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.
- pyreuser3/__init__.py +65 -0
- pyreuser3/__main__.py +7 -0
- pyreuser3/api.py +410 -0
- pyreuser3/cli.py +207 -0
- pyreuser3/core.py +358 -0
- pyreuser3/export/__init__.py +11 -0
- pyreuser3/export/base.py +245 -0
- pyreuser3/export/enums.py +171 -0
- pyreuser3/export/fields.py +231 -0
- pyreuser3/export/metadata.py +304 -0
- pyreuser3/export/postprocess.py +346 -0
- pyreuser3/export/tree.py +289 -0
- pyreuser3/export/user3.py +415 -0
- pyreuser3/pack/__init__.py +10 -0
- pyreuser3/pack/base.py +281 -0
- pyreuser3/pack/models.py +140 -0
- pyreuser3/pack/plan.py +566 -0
- pyreuser3/pack/writer.py +313 -0
- pyreuser3/rich_ui.py +126 -0
- pyreuser3/schema.py +193 -0
- pyreuser3/web/__init__.py +6 -0
- pyreuser3/web/__main__.py +6 -0
- pyreuser3/web/handler.py +178 -0
- pyreuser3/web/jobs.py +243 -0
- pyreuser3/web/page.py +221 -0
- pyreuser3/web/picker.py +92 -0
- pyreuser3/web/runners.py +238 -0
- pyreuser3/web/server.py +104 -0
- pyreuser3/web/settings.py +42 -0
- pyreuser3-0.1.0.dist-info/METADATA +165 -0
- pyreuser3-0.1.0.dist-info/RECORD +35 -0
- pyreuser3-0.1.0.dist-info/WHEEL +5 -0
- pyreuser3-0.1.0.dist-info/entry_points.txt +3 -0
- pyreuser3-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyreuser3-0.1.0.dist-info/top_level.txt +1 -0
pyreuser3/core.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""`.user.3` 解析与封包共享的基础设施。
|
|
2
|
+
|
|
3
|
+
这里放置不依赖具体导出器/封包器的通用能力:magic 默认值、二进制读取、
|
|
4
|
+
字段与类型定义、RE_RSZ 模板加载、字符串/GUID 规范化等。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import struct
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# `.user.3` 文件最外层 USR 头使用的默认 magic(小端 "USR\0")。
|
|
16
|
+
USR_MAGIC = 5395285
|
|
17
|
+
# 内嵌 RSZ 数据块使用的默认 magic(小端 "RSZ\0")。
|
|
18
|
+
RSZ_MAGIC = 5919570
|
|
19
|
+
# 完整实例表封包 JSON 的格式标识,用于识别可稳定回封的文档。
|
|
20
|
+
PACK_JSON_FORMAT = "re_user3_pack_v1"
|
|
21
|
+
# 匹配不含分隔符的 32 位十六进制字符串(用于识别 GUID 文本)。
|
|
22
|
+
HEX32_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
23
|
+
# REFramework dump 中枚举类型里需要忽略的占位字段名。
|
|
24
|
+
ENUM_UNUSED_KEY = "value__"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ParseError(RuntimeError):
|
|
28
|
+
"""解析或封包过程中发现二进制结构不符合预期时抛出的异常。
|
|
29
|
+
|
|
30
|
+
继承自 :class:`RuntimeError`,用于把“数据格式不对”这类可预期的错误
|
|
31
|
+
与程序自身的逻辑错误区分开,便于上层批处理捕获并单独统计失败文件。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def align(value: int, alignment: int) -> int:
|
|
38
|
+
"""把整数偏移向上对齐到指定边界。
|
|
39
|
+
|
|
40
|
+
参数:
|
|
41
|
+
value (int): 当前偏移。
|
|
42
|
+
alignment (int): 对齐粒度;小于等于 1 时不做处理。
|
|
43
|
+
|
|
44
|
+
返回:
|
|
45
|
+
int: 对齐后的偏移(大于等于 ``value`` 的最小满足边界的值)。
|
|
46
|
+
"""
|
|
47
|
+
if alignment <= 1:
|
|
48
|
+
return value
|
|
49
|
+
# 经典的二进制向上取整:先加 (alignment-1),再用位与清除低位。
|
|
50
|
+
return (value + (alignment - 1)) & ~(alignment - 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_guid_text_from_hex32(hex32: str) -> str:
|
|
54
|
+
"""把 32 位十六进制文本格式化为标准 GUID 文本。
|
|
55
|
+
|
|
56
|
+
参数:
|
|
57
|
+
hex32 (str): 不带分隔符的 32 位十六进制字符串。
|
|
58
|
+
|
|
59
|
+
返回:
|
|
60
|
+
str: 形如 ``xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`` 的 GUID 文本。
|
|
61
|
+
"""
|
|
62
|
+
h = hex32.lower()
|
|
63
|
+
# 按 8-4-4-4-12 的标准分组插入连字符。
|
|
64
|
+
return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_guid_candidate_text(text: str) -> str:
|
|
68
|
+
"""在字符串看起来像 GUID 时进行规范化。
|
|
69
|
+
|
|
70
|
+
参数:
|
|
71
|
+
text (str): 原始字符串,可能包含 ``{}`` 包裹或 ``-`` 分隔符。
|
|
72
|
+
|
|
73
|
+
返回:
|
|
74
|
+
str: 可识别为 GUID 时返回标准 GUID 文本,否则原样返回输入。
|
|
75
|
+
"""
|
|
76
|
+
# 去掉首尾空白与花括号,再剥离连字符,得到纯十六进制候选。
|
|
77
|
+
stripped = text.strip().strip("{}")
|
|
78
|
+
compact = stripped.replace("-", "")
|
|
79
|
+
if HEX32_RE.fullmatch(compact):
|
|
80
|
+
return format_guid_text_from_hex32(compact)
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_schema_path(schema_path_or_dir: str | Path) -> Path:
|
|
85
|
+
"""校验并返回用户显式提供的 RE_RSZ 模板文件路径。
|
|
86
|
+
|
|
87
|
+
新逻辑要求依赖文件全部显式传入,因此这里故意拒绝目录路径,
|
|
88
|
+
避免在多个游戏模板共存时自动匹配到错误文件。
|
|
89
|
+
|
|
90
|
+
参数:
|
|
91
|
+
schema_path_or_dir (str | Path): 期望指向具体模板 JSON 文件的路径。
|
|
92
|
+
|
|
93
|
+
返回:
|
|
94
|
+
Path: 校验通过的模板文件路径。
|
|
95
|
+
|
|
96
|
+
异常:
|
|
97
|
+
FileNotFoundError: 当路径是目录或不存在时抛出。
|
|
98
|
+
"""
|
|
99
|
+
path = Path(schema_path_or_dir)
|
|
100
|
+
if path.is_file():
|
|
101
|
+
return path
|
|
102
|
+
if path.is_dir():
|
|
103
|
+
raise FileNotFoundError(
|
|
104
|
+
f"schema must be an explicit RE RSZ json file, not a directory: {path}"
|
|
105
|
+
)
|
|
106
|
+
raise FileNotFoundError(f"schema file not found: {path}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class BinaryReader:
|
|
110
|
+
"""带边界检查的小端二进制读取器。
|
|
111
|
+
|
|
112
|
+
封装一个只读字节缓冲区和读取游标,提供各种定宽整数/浮点数以及
|
|
113
|
+
字符串的读取方法,并在越界时抛出 :class:`ParseError`,避免错误模板
|
|
114
|
+
导致越界访问破坏后续解析。
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, data: bytes):
|
|
118
|
+
"""初始化读取器。
|
|
119
|
+
|
|
120
|
+
参数:
|
|
121
|
+
data (bytes): 源字节缓冲区,读取过程中不会被修改。
|
|
122
|
+
"""
|
|
123
|
+
self.data = data
|
|
124
|
+
# pos 是相对缓冲区起点的绝对读取游标,初始指向开头。
|
|
125
|
+
self.pos = 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def size(self) -> int:
|
|
129
|
+
"""缓冲区总长度。
|
|
130
|
+
|
|
131
|
+
返回:
|
|
132
|
+
int: 源字节缓冲区的字节数。
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
return len(self.data)
|
|
136
|
+
|
|
137
|
+
def tell(self) -> int:
|
|
138
|
+
"""返回当前读取游标。
|
|
139
|
+
|
|
140
|
+
返回:
|
|
141
|
+
int: 当前相对缓冲区起点的绝对偏移。
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
return self.pos
|
|
145
|
+
|
|
146
|
+
def seek(self, pos: int) -> None:
|
|
147
|
+
"""把游标移动到绝对偏移。
|
|
148
|
+
|
|
149
|
+
参数:
|
|
150
|
+
pos (int): 目标绝对偏移,必须落在 ``[0, size]`` 区间内。
|
|
151
|
+
|
|
152
|
+
返回:
|
|
153
|
+
None: 仅更新内部游标。
|
|
154
|
+
|
|
155
|
+
异常:
|
|
156
|
+
ParseError: 当目标偏移越界时抛出。
|
|
157
|
+
"""
|
|
158
|
+
if pos < 0 or pos > self.size:
|
|
159
|
+
raise ParseError(f"seek out of range: {pos}")
|
|
160
|
+
self.pos = pos
|
|
161
|
+
|
|
162
|
+
def read(self, n: int) -> bytes:
|
|
163
|
+
"""读取指定长度的字节并推进游标。
|
|
164
|
+
|
|
165
|
+
参数:
|
|
166
|
+
n (int): 要读取的字节数。
|
|
167
|
+
|
|
168
|
+
返回:
|
|
169
|
+
bytes: 读取出的字节序列,长度为 ``n``。
|
|
170
|
+
|
|
171
|
+
异常:
|
|
172
|
+
ParseError: 当剩余字节不足 ``n`` 时抛出。
|
|
173
|
+
"""
|
|
174
|
+
end = self.pos + n
|
|
175
|
+
if end > self.size:
|
|
176
|
+
raise ParseError(f"read out of range: {self.pos}+{n}")
|
|
177
|
+
out = self.data[self.pos : end]
|
|
178
|
+
self.pos = end
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
def read_struct(self, fmt: str) -> Any:
|
|
182
|
+
"""按 ``struct`` 格式读取并解包一个值。
|
|
183
|
+
|
|
184
|
+
参数:
|
|
185
|
+
fmt (str): :func:`struct.unpack` 使用的格式字符串,应只描述单个值。
|
|
186
|
+
|
|
187
|
+
返回:
|
|
188
|
+
Any: 解包后的单个值,具体类型取决于 ``fmt``。
|
|
189
|
+
"""
|
|
190
|
+
size = struct.calcsize(fmt)
|
|
191
|
+
raw = self.read(size)
|
|
192
|
+
return struct.unpack(fmt, raw)[0]
|
|
193
|
+
|
|
194
|
+
def read_u8(self) -> int:
|
|
195
|
+
"""读取一个无符号 8 位整数(1 字节)。
|
|
196
|
+
|
|
197
|
+
返回:
|
|
198
|
+
int: 取值范围 0-255 的无符号整数。
|
|
199
|
+
"""
|
|
200
|
+
return self.read_struct("<B")
|
|
201
|
+
|
|
202
|
+
def read_s8(self) -> int:
|
|
203
|
+
"""读取一个有符号 8 位整数(1 字节)。
|
|
204
|
+
|
|
205
|
+
返回:
|
|
206
|
+
int: 取值范围 -128~127 的有符号整数。
|
|
207
|
+
"""
|
|
208
|
+
return self.read_struct("<b")
|
|
209
|
+
|
|
210
|
+
def read_u16(self) -> int:
|
|
211
|
+
"""读取一个无符号 16 位整数(2 字节,小端)。
|
|
212
|
+
|
|
213
|
+
返回:
|
|
214
|
+
int: 取值范围 0-65535 的无符号整数。
|
|
215
|
+
"""
|
|
216
|
+
return self.read_struct("<H")
|
|
217
|
+
|
|
218
|
+
def read_s16(self) -> int:
|
|
219
|
+
"""读取一个有符号 16 位整数(2 字节,小端)。
|
|
220
|
+
|
|
221
|
+
返回:
|
|
222
|
+
int: 取值范围 -32768~32767 的有符号整数。
|
|
223
|
+
"""
|
|
224
|
+
return self.read_struct("<h")
|
|
225
|
+
|
|
226
|
+
def read_u32(self) -> int:
|
|
227
|
+
"""读取一个无符号 32 位整数(4 字节,小端)。
|
|
228
|
+
|
|
229
|
+
返回:
|
|
230
|
+
int: 无符号 32 位整数。
|
|
231
|
+
"""
|
|
232
|
+
return self.read_struct("<I")
|
|
233
|
+
|
|
234
|
+
def read_s32(self) -> int:
|
|
235
|
+
"""读取一个有符号 32 位整数(4 字节,小端)。
|
|
236
|
+
|
|
237
|
+
返回:
|
|
238
|
+
int: 有符号 32 位整数。
|
|
239
|
+
"""
|
|
240
|
+
return self.read_struct("<i")
|
|
241
|
+
|
|
242
|
+
def read_u64(self) -> int:
|
|
243
|
+
"""读取一个无符号 64 位整数(8 字节,小端)。
|
|
244
|
+
|
|
245
|
+
返回:
|
|
246
|
+
int: 无符号 64 位整数。
|
|
247
|
+
"""
|
|
248
|
+
return self.read_struct("<Q")
|
|
249
|
+
|
|
250
|
+
def read_s64(self) -> int:
|
|
251
|
+
"""读取一个有符号 64 位整数(8 字节,小端)。
|
|
252
|
+
|
|
253
|
+
返回:
|
|
254
|
+
int: 有符号 64 位整数。
|
|
255
|
+
"""
|
|
256
|
+
return self.read_struct("<q")
|
|
257
|
+
|
|
258
|
+
def read_f32(self) -> float:
|
|
259
|
+
"""读取一个 32 位单精度浮点数(4 字节,小端)。
|
|
260
|
+
|
|
261
|
+
返回:
|
|
262
|
+
float: 解析出的单精度浮点数。
|
|
263
|
+
"""
|
|
264
|
+
return self.read_struct("<f")
|
|
265
|
+
|
|
266
|
+
def read_f64(self) -> float:
|
|
267
|
+
"""读取一个 64 位双精度浮点数(8 字节,小端)。
|
|
268
|
+
|
|
269
|
+
返回:
|
|
270
|
+
float: 解析出的双精度浮点数。
|
|
271
|
+
"""
|
|
272
|
+
return self.read_struct("<d")
|
|
273
|
+
|
|
274
|
+
def read_wstring_null(self, offset: int) -> str:
|
|
275
|
+
"""从绝对偏移读取以空字符结尾的 UTF-16LE 字符串。
|
|
276
|
+
|
|
277
|
+
与游标无关:本方法直接按给定偏移读取,不改变 ``self.pos``,
|
|
278
|
+
适合解析头部路径表这类“偏移指向别处”的字符串。
|
|
279
|
+
|
|
280
|
+
参数:
|
|
281
|
+
offset (int): 字符串起始的绝对偏移。
|
|
282
|
+
|
|
283
|
+
返回:
|
|
284
|
+
str: 解码并规范化后的字符串;偏移越界时返回空字符串。
|
|
285
|
+
"""
|
|
286
|
+
if offset < 0 or offset >= self.size:
|
|
287
|
+
return ""
|
|
288
|
+
out: list[int] = []
|
|
289
|
+
i = offset
|
|
290
|
+
# RE Engine 路径表常以 UTF-16LE 存储,并由 0 结束。
|
|
291
|
+
while i + 1 < self.size:
|
|
292
|
+
ch = struct.unpack_from("<H", self.data, i)[0]
|
|
293
|
+
i += 2
|
|
294
|
+
if ch == 0:
|
|
295
|
+
break
|
|
296
|
+
out.append(ch)
|
|
297
|
+
return normalize_guid_candidate_text("".join(chr(c) for c in out))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def read_len_utf16(reader: BinaryReader) -> str:
|
|
301
|
+
"""读取带 4 字节长度前缀的 UTF-16LE 字符串。
|
|
302
|
+
|
|
303
|
+
参数:
|
|
304
|
+
reader (BinaryReader): 二进制读取器,会从其当前游标处读取。
|
|
305
|
+
|
|
306
|
+
返回:
|
|
307
|
+
str: 解码并去掉结尾空字符、规范化后的字符串;长度异常时返回空字符串。
|
|
308
|
+
"""
|
|
309
|
+
# 字符串前的长度字段按 4 字节对齐。
|
|
310
|
+
reader.seek(align(reader.tell(), 4))
|
|
311
|
+
length = reader.read_u32()
|
|
312
|
+
if length == 0:
|
|
313
|
+
return ""
|
|
314
|
+
remaining_chars = (reader.size - reader.tell()) // 2
|
|
315
|
+
# 长度异常时返回空字符串,而不是继续越界读取破坏后续解析。
|
|
316
|
+
if length > remaining_chars or length > 2_000_000:
|
|
317
|
+
return ""
|
|
318
|
+
raw = reader.read(length * 2)
|
|
319
|
+
decoded = raw.decode("utf-16-le", errors="replace").rstrip("\x00")
|
|
320
|
+
return normalize_guid_candidate_text(decoded)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read_len_c8(reader: BinaryReader) -> str:
|
|
324
|
+
"""读取带 4 字节长度前缀的 UTF-8/C8 字符串。
|
|
325
|
+
|
|
326
|
+
参数:
|
|
327
|
+
reader (BinaryReader): 二进制读取器,会从其当前游标处读取。
|
|
328
|
+
|
|
329
|
+
返回:
|
|
330
|
+
str: 解码并去掉结尾空字符、规范化后的字符串;长度异常时返回空字符串。
|
|
331
|
+
"""
|
|
332
|
+
reader.seek(align(reader.tell(), 4))
|
|
333
|
+
length = reader.read_u32()
|
|
334
|
+
if length == 0:
|
|
335
|
+
return ""
|
|
336
|
+
remaining = reader.size - reader.tell()
|
|
337
|
+
if length > remaining or length > 2_000_000:
|
|
338
|
+
return ""
|
|
339
|
+
raw = reader.read(length)
|
|
340
|
+
decoded = raw.decode("utf-8", errors="replace").rstrip("\x00")
|
|
341
|
+
return normalize_guid_candidate_text(decoded)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def read_guid_like(reader: BinaryReader) -> str:
|
|
345
|
+
"""读取 16 字节 GUID 数据并规范化为标准文本。
|
|
346
|
+
|
|
347
|
+
参数:
|
|
348
|
+
reader (BinaryReader): 二进制读取器,会从其当前游标处读取 16 字节。
|
|
349
|
+
|
|
350
|
+
返回:
|
|
351
|
+
str: 标准 GUID 文本;无法按 UUID 解析时退回十六进制格式化结果。
|
|
352
|
+
"""
|
|
353
|
+
raw = reader.read(16)
|
|
354
|
+
try:
|
|
355
|
+
# RE Engine 的 GUID 以小端字节序存储,使用 bytes_le 还原。
|
|
356
|
+
return str(uuid.UUID(bytes_le=raw))
|
|
357
|
+
except Exception:
|
|
358
|
+
return format_guid_text_from_hex32(raw.hex())
|
pyreuser3/export/base.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""`.user.3` 到 JSON 的导出器入口。
|
|
2
|
+
|
|
3
|
+
`User3Exporter` 通过多个 Mixin 组合出完整的解析链路:读取 USR/RSZ 结构、
|
|
4
|
+
解析字段、构建对象引用树、应用枚举元数据并做后处理。本文件只负责装配
|
|
5
|
+
这些能力、管理批处理流程,以及处理文件发现与输出路径计算。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .enums import ExporterEnumSourceMixin
|
|
15
|
+
from .fields import ExporterFieldParserMixin
|
|
16
|
+
from .metadata import ExporterMetadataMixin
|
|
17
|
+
from .postprocess import ExporterPostprocessMixin
|
|
18
|
+
from .tree import ExporterTreeMixin
|
|
19
|
+
from .user3 import ExporterUser3ParserMixin
|
|
20
|
+
from ..core import RSZ_MAGIC, USR_MAGIC, resolve_schema_path
|
|
21
|
+
from ..rich_ui import BatchProgress
|
|
22
|
+
from ..schema import TypeDB
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class User3Exporter(
|
|
26
|
+
ExporterEnumSourceMixin,
|
|
27
|
+
ExporterMetadataMixin,
|
|
28
|
+
ExporterPostprocessMixin,
|
|
29
|
+
ExporterTreeMixin,
|
|
30
|
+
ExporterFieldParserMixin,
|
|
31
|
+
ExporterUser3ParserMixin,
|
|
32
|
+
):
|
|
33
|
+
"""把 RE Engine `.user.3` 二进制文件导出为紧凑 JSON。
|
|
34
|
+
|
|
35
|
+
通过组合枚举源、元数据、后处理、对象树、字段解析和 USR/RSZ 解析等
|
|
36
|
+
Mixin,提供从单文件解析到批量导出的完整能力。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
user3_root: str | Path,
|
|
42
|
+
schema_dir: str | Path,
|
|
43
|
+
output_root: str | Path,
|
|
44
|
+
tree_depth: int | str = "auto",
|
|
45
|
+
exclude_regexes: list[str] | None = None,
|
|
46
|
+
il2cpp_dump_path: str | Path = "",
|
|
47
|
+
user_magic: int = USR_MAGIC,
|
|
48
|
+
rsz_magic: int = RSZ_MAGIC,
|
|
49
|
+
):
|
|
50
|
+
"""初始化导出器配置和运行期索引。
|
|
51
|
+
|
|
52
|
+
参数:
|
|
53
|
+
user3_root (str | Path): 输入根目录或单个 ``.user.3`` 文件。
|
|
54
|
+
schema_dir (str | Path): 显式传入的 RE_RSZ 模板 JSON 文件路径。
|
|
55
|
+
output_root (str | Path): JSON 输出根目录。
|
|
56
|
+
tree_depth (int | str): 对象引用树展开深度,支持非负整数或 ``"auto"``。
|
|
57
|
+
exclude_regexes (list[str] | None): 用于排除相对路径的正则表达式列表。
|
|
58
|
+
il2cpp_dump_path (str | Path): 必填的 ``il2cpp_dump.json`` 文件路径。
|
|
59
|
+
user_magic (int): 期望读取到的 USR 文件 magic。
|
|
60
|
+
rsz_magic (int): 期望读取到的 RSZ 块 magic。
|
|
61
|
+
|
|
62
|
+
返回:
|
|
63
|
+
None: 构造函数,仅初始化实例属性。
|
|
64
|
+
|
|
65
|
+
异常:
|
|
66
|
+
FileNotFoundError: 当 ``il2cpp_dump.json`` 不存在时抛出。
|
|
67
|
+
"""
|
|
68
|
+
# 路径在入口处统一转为 Path,后续模块只处理 Path 对象。
|
|
69
|
+
self.user3_root = Path(user3_root)
|
|
70
|
+
self.schema_dir = Path(schema_dir)
|
|
71
|
+
self.output_root = Path(output_root)
|
|
72
|
+
self.il2cpp_dump_path = Path(il2cpp_dump_path)
|
|
73
|
+
if not self.il2cpp_dump_path.is_file():
|
|
74
|
+
raise FileNotFoundError(
|
|
75
|
+
f"il2cpp_dump.json not found: {self.il2cpp_dump_path}"
|
|
76
|
+
)
|
|
77
|
+
self.tree_depth = self._normalize_tree_depth(tree_depth)
|
|
78
|
+
self.user_magic = int(user_magic)
|
|
79
|
+
self.rsz_magic = int(rsz_magic)
|
|
80
|
+
self.exclude_regexes = exclude_regexes or []
|
|
81
|
+
self._exclude_patterns = [re.compile(p) for p in self.exclude_regexes]
|
|
82
|
+
self.schema_path = self._resolve_schema_path(self.schema_dir)
|
|
83
|
+
self.typedb = TypeDB.load(self.schema_path)
|
|
84
|
+
# 下面这些索引在导出前由 il2cpp_dump.json 构建,用于把固定枚举值
|
|
85
|
+
# 转成 `[数值] 成员名`,并在泛型容器中推断字段对应的枚举类型。
|
|
86
|
+
self.enum_lookup: dict[str, dict[int, tuple[str, int]]] = {}
|
|
87
|
+
self.class_field_fixed_types: dict[str, dict[str, str]] = {}
|
|
88
|
+
self.serializable_to_fixed: dict[str, str] = {}
|
|
89
|
+
self.generic_container_rules: dict[str, tuple[str, str]] = {}
|
|
90
|
+
self.param_type_default_enum: dict[str, str] = {}
|
|
91
|
+
self.enum_member_to_types: dict[str, list[str]] = {}
|
|
92
|
+
|
|
93
|
+
def run(self) -> dict[str, int]:
|
|
94
|
+
"""执行批量导出流程。
|
|
95
|
+
|
|
96
|
+
发现输入文件、构建枚举索引,然后逐个导出并通过 Rich 进度条反馈进度。
|
|
97
|
+
|
|
98
|
+
返回:
|
|
99
|
+
dict[str, int]: 统计字典,含 ``total``、``success``、``failed`` 三个计数。
|
|
100
|
+
"""
|
|
101
|
+
files = self._discover_user3_files()
|
|
102
|
+
self.output_root.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
# 每次导出都根据显式传入的 il2cpp_dump.json 重新生成枚举表,
|
|
104
|
+
# 不复用旧目录中的 Enums_Internal.json,避免跨游戏或跨版本污染。
|
|
105
|
+
enums_internal = self._ensure_internal_metadata_files()
|
|
106
|
+
self.enum_lookup = self._build_enum_lookup_from_enums_internal(enums_internal)
|
|
107
|
+
self._load_enum_context_from_il2cpp_dump()
|
|
108
|
+
self._ensure_enum_lookup()
|
|
109
|
+
|
|
110
|
+
success = 0
|
|
111
|
+
failed = 0
|
|
112
|
+
# 单文件失败只计入失败数量,不中断整批导出;这样大批量资源更容易排查。
|
|
113
|
+
with BatchProgress(
|
|
114
|
+
"Exporting user3", total=len(files), unit="file"
|
|
115
|
+
) as progress:
|
|
116
|
+
progress.log(f"发现 {len(files)} 个 .user.3 文件。")
|
|
117
|
+
progress.log(f"使用模板: {self.schema_path}")
|
|
118
|
+
progress.log(f"输出目录: {self.output_root}")
|
|
119
|
+
for user3_file in files:
|
|
120
|
+
label = user3_file.name.replace(".user.3", "")
|
|
121
|
+
progress.update(advance=0, description=label)
|
|
122
|
+
progress.log(f"开始导出 user3: {user3_file}")
|
|
123
|
+
ok, output_path, error = self._export_one_file(user3_file)
|
|
124
|
+
if ok:
|
|
125
|
+
success += 1
|
|
126
|
+
progress.log(f"user3 导出完成: {output_path}", style="green")
|
|
127
|
+
else:
|
|
128
|
+
failed += 1
|
|
129
|
+
progress.log(f"user3 导出失败: {user3_file} ({error})", style="red")
|
|
130
|
+
progress.update(1)
|
|
131
|
+
|
|
132
|
+
return {"total": len(files), "success": success, "failed": failed}
|
|
133
|
+
|
|
134
|
+
def _export_one_file(
|
|
135
|
+
self, user3_file: Path
|
|
136
|
+
) -> tuple[bool, Path | None, str | None]:
|
|
137
|
+
"""导出单个 `.user.3` 文件。
|
|
138
|
+
|
|
139
|
+
参数:
|
|
140
|
+
user3_file (Path): 源 ``.user.3`` 文件路径。
|
|
141
|
+
|
|
142
|
+
返回:
|
|
143
|
+
tuple[bool, Path | None, str | None]: 三元组 ``(是否成功, 输出路径, 错误信息)``;
|
|
144
|
+
成功时输出路径有效、错误信息为 ``None``,失败时反之。
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
# 解析出的原始树先经过枚举后处理,再移除内部索引和值包装,
|
|
148
|
+
# 最后对展示用浮点数做轻微圆整,生成更适合人工编辑的 JSON。
|
|
149
|
+
tree = self._parse_user3(user3_file)
|
|
150
|
+
tree = self._postprocess_enum_nodes(tree)
|
|
151
|
+
tree = self._finalize_export_tree(tree)
|
|
152
|
+
tree = self._round_export_floats(tree)
|
|
153
|
+
output_path = self._output_path_for(user3_file)
|
|
154
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
with output_path.open("w", encoding="utf-8") as f:
|
|
156
|
+
json.dump(tree, f, ensure_ascii=False, indent=2)
|
|
157
|
+
return True, output_path, None
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
# 把异常转成简短文本返回给批处理统计,不向上抛出以免中断整批。
|
|
160
|
+
return False, None, f"{exc.__class__.__name__}: {exc}"
|
|
161
|
+
|
|
162
|
+
def _resolve_schema_path(self, schema_dir: Path) -> Path:
|
|
163
|
+
"""校验并返回模板文件路径。
|
|
164
|
+
|
|
165
|
+
参数:
|
|
166
|
+
schema_dir (Path): 历史参数名,实际必须是具体模板 JSON 文件。
|
|
167
|
+
|
|
168
|
+
返回:
|
|
169
|
+
Path: 校验后的模板文件路径。
|
|
170
|
+
"""
|
|
171
|
+
return resolve_schema_path(schema_dir)
|
|
172
|
+
|
|
173
|
+
def _normalize_tree_depth(self, tree_depth: int | str) -> int | str:
|
|
174
|
+
"""规范化对象树展开深度。
|
|
175
|
+
|
|
176
|
+
参数:
|
|
177
|
+
tree_depth (int | str): 用户传入的深度设置,整数或字符串 ``"auto"``。
|
|
178
|
+
|
|
179
|
+
返回:
|
|
180
|
+
int | str: 非负整数或字符串 ``"auto"``。
|
|
181
|
+
|
|
182
|
+
异常:
|
|
183
|
+
ValueError: 字符串非 ``"auto"`` 或整数为负时抛出。
|
|
184
|
+
TypeError: 类型既不是 ``int`` 也不是 ``str`` 时抛出。
|
|
185
|
+
"""
|
|
186
|
+
if isinstance(tree_depth, str):
|
|
187
|
+
value = tree_depth.strip().lower()
|
|
188
|
+
if value != "auto":
|
|
189
|
+
raise ValueError("tree_depth must be a non-negative integer or 'auto'")
|
|
190
|
+
return "auto"
|
|
191
|
+
if isinstance(tree_depth, int):
|
|
192
|
+
if tree_depth < 0:
|
|
193
|
+
raise ValueError("tree_depth must be >= 0")
|
|
194
|
+
return tree_depth
|
|
195
|
+
raise TypeError("tree_depth must be int or str")
|
|
196
|
+
|
|
197
|
+
def _discover_user3_files(self) -> list[Path]:
|
|
198
|
+
"""发现输入 `.user.3` 文件并应用排除规则。
|
|
199
|
+
|
|
200
|
+
返回:
|
|
201
|
+
list[Path]: 过滤后的 ``.user.3`` 文件路径列表。
|
|
202
|
+
|
|
203
|
+
异常:
|
|
204
|
+
FileNotFoundError: 路径不存在、目录下无文件,或全部被排除时抛出。
|
|
205
|
+
"""
|
|
206
|
+
if self.user3_root.is_file():
|
|
207
|
+
files = [self.user3_root]
|
|
208
|
+
else:
|
|
209
|
+
if not self.user3_root.is_dir():
|
|
210
|
+
raise FileNotFoundError(f"user3 root not found: {self.user3_root}")
|
|
211
|
+
files = sorted(self.user3_root.rglob("*.user.3"))
|
|
212
|
+
if not files:
|
|
213
|
+
raise FileNotFoundError(f"no *.user.3 found under: {self.user3_root}")
|
|
214
|
+
if not self._exclude_patterns:
|
|
215
|
+
return files
|
|
216
|
+
|
|
217
|
+
kept: list[Path] = []
|
|
218
|
+
for file_path in files:
|
|
219
|
+
# 目录模式下按相对路径匹配排除正则,便于排除整类子目录。
|
|
220
|
+
if self.user3_root.is_file():
|
|
221
|
+
rel_path = file_path.name
|
|
222
|
+
else:
|
|
223
|
+
rel_path = file_path.relative_to(self.user3_root).as_posix()
|
|
224
|
+
if any(pattern.search(rel_path) for pattern in self._exclude_patterns):
|
|
225
|
+
continue
|
|
226
|
+
kept.append(file_path)
|
|
227
|
+
if not kept:
|
|
228
|
+
raise FileNotFoundError("all *.user.3 files were excluded by regex filters")
|
|
229
|
+
return kept
|
|
230
|
+
|
|
231
|
+
def _output_path_for(self, user3_file: Path) -> Path:
|
|
232
|
+
"""计算单个源文件对应的 JSON 输出路径。
|
|
233
|
+
|
|
234
|
+
参数:
|
|
235
|
+
user3_file (Path): 源 ``.user.3`` 文件。
|
|
236
|
+
|
|
237
|
+
返回:
|
|
238
|
+
Path: 输出 JSON 文件路径(目录模式下会还原相对子目录结构)。
|
|
239
|
+
"""
|
|
240
|
+
if self.user3_root.is_file():
|
|
241
|
+
relative_parent = Path()
|
|
242
|
+
else:
|
|
243
|
+
relative_parent = user3_file.relative_to(self.user3_root).parent
|
|
244
|
+
output_name = f"{user3_file.name}.json"
|
|
245
|
+
return self.output_root / relative_parent / output_name
|