PyREUser3 0.7.1__tar.gz → 0.7.2__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.
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PKG-INFO +1 -8
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/PKG-INFO +1 -8
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/SOURCES.txt +2 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/README.md +0 -27
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/docs/PYPI.md +0 -7
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/docs/README.zh-CN.md +0 -23
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyproject.toml +1 -1
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/__init__.py +1 -1
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/postprocess.py +13 -11
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/base.py +4 -28
- pyreuser3-0.7.2/pyreuser3/pack/container.py +388 -0
- pyreuser3-0.7.2/pyreuser3/pack/plan.py +454 -0
- pyreuser3-0.7.2/pyreuser3/pack/values.py +278 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/writer.py +12 -34
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_enum_binary.py +29 -2
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_enum_codec.py +27 -0
- pyreuser3-0.7.1/pyreuser3/pack/plan.py +0 -1040
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/LICENSE +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/MANIFEST.in +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/dependency_links.txt +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/entry_points.txt +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/requires.txt +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/top_level.txt +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/__main__.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/api.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/cli.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/core.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/enum_codec.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/__init__.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/base.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/enums.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/fields.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/metadata.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/tree.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/user3.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/__init__.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/models.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/rich_ui.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/schema.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/usr_container.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/usr_layouts.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/__init__.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/__main__.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/handler.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/jobs.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/page.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/picker.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/runners.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/server.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/settings.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/setup.cfg +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_api_cache.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_export_enums.py +0 -0
- {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_usr_container.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyREUser3
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Pure Python tools for converting RE Engine .user.3 files to and from JSON.
|
|
5
5
|
Author: Egg Targaryen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -60,13 +60,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
|
|
|
60
60
|
RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
|
|
61
61
|
until real fixtures provide byte-for-byte validation.
|
|
62
62
|
|
|
63
|
-
Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
|
|
64
|
-
data targets use absolute 16-byte file alignment even though their stored offsets
|
|
65
|
-
remain RSZ-relative. Readable parsing accepts alignment-only deviations without
|
|
66
|
-
hiding structural corruption, while the probe API reports them explicitly. Repack
|
|
67
|
-
output blocks layouts carrying unverified diagnostics. The strict schema-free
|
|
68
|
-
probe can validate large local corpora.
|
|
69
|
-
|
|
70
63
|
Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
|
|
71
64
|
with automatic file-signature invalidation and an explicit
|
|
72
65
|
`clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyREUser3
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Pure Python tools for converting RE Engine .user.3 files to and from JSON.
|
|
5
5
|
Author: Egg Targaryen
|
|
6
6
|
License-Expression: MIT
|
|
@@ -60,13 +60,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
|
|
|
60
60
|
RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
|
|
61
61
|
until real fixtures provide byte-for-byte validation.
|
|
62
62
|
|
|
63
|
-
Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
|
|
64
|
-
data targets use absolute 16-byte file alignment even though their stored offsets
|
|
65
|
-
remain RSZ-relative. Readable parsing accepts alignment-only deviations without
|
|
66
|
-
hiding structural corruption, while the probe API reports them explicitly. Repack
|
|
67
|
-
output blocks layouts carrying unverified diagnostics. The strict schema-free
|
|
68
|
-
probe can validate large local corpora.
|
|
69
|
-
|
|
70
63
|
Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
|
|
71
64
|
with automatic file-signature invalidation and an explicit
|
|
72
65
|
`clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
|
|
@@ -30,8 +30,10 @@ pyreuser3/export/tree.py
|
|
|
30
30
|
pyreuser3/export/user3.py
|
|
31
31
|
pyreuser3/pack/__init__.py
|
|
32
32
|
pyreuser3/pack/base.py
|
|
33
|
+
pyreuser3/pack/container.py
|
|
33
34
|
pyreuser3/pack/models.py
|
|
34
35
|
pyreuser3/pack/plan.py
|
|
36
|
+
pyreuser3/pack/values.py
|
|
35
37
|
pyreuser3/pack/writer.py
|
|
36
38
|
pyreuser3/web/__init__.py
|
|
37
39
|
pyreuser3/web/__main__.py
|
|
@@ -217,33 +217,6 @@ converter.patch_file(
|
|
|
217
217
|
)
|
|
218
218
|
```
|
|
219
219
|
|
|
220
|
-
## Compatibility Validation
|
|
221
|
-
|
|
222
|
-
The repository includes a schema-free corpus runner. Game files are not committed;
|
|
223
|
-
point it at a locally unpacked `natives` tree:
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
python tests/corpus_probe.py D:/game/natives \
|
|
227
|
-
--expected-total 62768 \
|
|
228
|
-
--expected-version 16 \
|
|
229
|
-
--report layout-report.json
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Version 0.7.1 fixes modern RSZ alignment regression found in 0.7.0 and folds the
|
|
233
|
-
planned compatibility work into the bug-fix release: staged layout detection,
|
|
234
|
-
structured diagnostics, safe readable parsing, verified-only repacking, and the
|
|
235
|
-
schema-free probe command. The modern rule was validated against 62,768 MHWS
|
|
236
|
-
`.user.3` files; four SystemSetting fixtures additionally passed readable export,
|
|
237
|
-
repack export, byte-identical packing, and reparse checks.
|
|
238
|
-
|
|
239
|
-
A second corpus from Monster Hunter Stories 3 was also validated: all 42,945 files
|
|
240
|
-
passed strict layout detection and schema-driven repack export. All 42,945 rebuilt
|
|
241
|
-
without exceptions; 38,548 were byte-identical. The remaining 4,397 are successful
|
|
242
|
-
rebuilds but not byte-identical; observed causes include nonzero padding and
|
|
243
|
-
alternate empty-string encodings, so they are not claimed as byte-identical
|
|
244
|
-
fixtures. Two voxel files use the compact large-array representation; both rebuilt
|
|
245
|
-
byte-identically, including the 6.55 MB `dg100_Root` payload.
|
|
246
|
-
|
|
247
220
|
## Build From Source
|
|
248
221
|
|
|
249
222
|
```bash
|
|
@@ -27,13 +27,6 @@ Verified H30/modern layouts support repacking. Experimental physical H28 and leg
|
|
|
27
27
|
RSZ v3 layouts are readable for analysis but intentionally blocked from repacking
|
|
28
28
|
until real fixtures provide byte-for-byte validation.
|
|
29
29
|
|
|
30
|
-
Version 0.7.1 fixes the 0.7.0 modern RSZ alignment regression. Modern userdata and
|
|
31
|
-
data targets use absolute 16-byte file alignment even though their stored offsets
|
|
32
|
-
remain RSZ-relative. Readable parsing accepts alignment-only deviations without
|
|
33
|
-
hiding structural corruption, while the probe API reports them explicitly. Repack
|
|
34
|
-
output blocks layouts carrying unverified diagnostics. The strict schema-free
|
|
35
|
-
probe can validate large local corpora.
|
|
36
|
-
|
|
37
30
|
Repeated operations on one `REUser3Converter` reuse schema and il2cpp metadata,
|
|
38
31
|
with automatic file-signature invalidation and an explicit
|
|
39
32
|
`clear_metadata_cache()` escape hatch. Batch patching reuses one exporter and
|
|
@@ -256,29 +256,6 @@ converter.patch_file(
|
|
|
256
256
|
)
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
-
## 兼容性验证
|
|
260
|
-
|
|
261
|
-
仓库内提供无需 schema 的语料测试脚本。游戏文件不会提交到仓库,请让脚本指向本地已解包的
|
|
262
|
-
`natives` 目录:
|
|
263
|
-
|
|
264
|
-
```bash
|
|
265
|
-
python tests/corpus_probe.py D:/game/natives \
|
|
266
|
-
--expected-total 62768 \
|
|
267
|
-
--expected-version 16 \
|
|
268
|
-
--report layout-report.json
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
0.7.1 修复了 0.7.0 引入的现代 RSZ 对齐回归,并把原计划中的兼容性工作合并进本次 bugfix:
|
|
272
|
-
分阶段布局探测、结构化诊断、安全 readable 解析、仅验证布局可 repack,以及无需 schema 的
|
|
273
|
-
probe 命令。新的现代布局规则已用 62,768 个 MHWS `.user.3` 文件验证;其中四个
|
|
274
|
-
SystemSetting 样本还完成了 readable 导出、repack 导出、逐字节一致回封和二次解析验证。
|
|
275
|
-
|
|
276
|
-
另外还验证了 Monster Hunter Stories 3 语料:42,945 个文件全部通过严格 layout 探测和
|
|
277
|
-
schema 驱动的 repack 导出;42,945 个文件全部能够无异常地重新构建,其中 38,548 个逐字节
|
|
278
|
-
一致。其余 4,397 个可以成功重建但并非逐字节一致;已观察到的原因包括非零 padding、空字符串
|
|
279
|
-
的另一种物理编码,因此不把它们宣称为逐字节 fixture。两个使用紧凑超大数组表示的 voxel 文件
|
|
280
|
-
都能逐字节一致回封,其中包括 6.55 MB 的 `dg100_Root` payload。
|
|
281
|
-
|
|
282
259
|
## 常见注意事项
|
|
283
260
|
|
|
284
261
|
- schema JSON、`il2cpp_dump.json` 和 `.user.3` 文件应来自同一个游戏版本;
|
|
@@ -71,6 +71,9 @@ class ExporterPostprocessMixin:
|
|
|
71
71
|
def _format_enum_value(self, fixed_enum_type: str, value: int) -> Any:
|
|
72
72
|
"""Format enum value.
|
|
73
73
|
|
|
74
|
+
Exact declared members stay intact; flagged enums are decomposed only when the
|
|
75
|
+
numeric value has no exact member.
|
|
76
|
+
|
|
74
77
|
The method keeps parsing, metadata lookup, and JSON shaping explicit so incomplete
|
|
75
78
|
templates can still produce inspectable output.
|
|
76
79
|
|
|
@@ -86,7 +89,15 @@ class ExporterPostprocessMixin:
|
|
|
86
89
|
value_map = self.enum_lookup.get(fixed_enum_type)
|
|
87
90
|
if value_map is None:
|
|
88
91
|
return value
|
|
89
|
-
|
|
92
|
+
matched = enum_member_for_value(
|
|
93
|
+
self.enum_lookup,
|
|
94
|
+
fixed_enum_type,
|
|
95
|
+
value,
|
|
96
|
+
getattr(self, "enum_underlying_types", {}),
|
|
97
|
+
)
|
|
98
|
+
if matched is not None:
|
|
99
|
+
member_name, fixed_value = matched
|
|
100
|
+
return self._id_formatter(member_name, fixed_value)
|
|
90
101
|
if fixed_enum_type in getattr(self, "enum_flags", set()):
|
|
91
102
|
return decode_flags(
|
|
92
103
|
self.enum_lookup,
|
|
@@ -94,16 +105,7 @@ class ExporterPostprocessMixin:
|
|
|
94
105
|
value,
|
|
95
106
|
getattr(self, "enum_underlying_types", {}),
|
|
96
107
|
)
|
|
97
|
-
|
|
98
|
-
self.enum_lookup,
|
|
99
|
-
fixed_enum_type,
|
|
100
|
-
value,
|
|
101
|
-
getattr(self, "enum_underlying_types", {}),
|
|
102
|
-
)
|
|
103
|
-
if matched is None:
|
|
104
|
-
return value
|
|
105
|
-
member_name, fixed_value = matched
|
|
106
|
-
return self._id_formatter(member_name, fixed_value)
|
|
108
|
+
return value
|
|
107
109
|
|
|
108
110
|
@staticmethod
|
|
109
111
|
def _looks_like_class_name(text: str) -> bool:
|
|
@@ -11,12 +11,10 @@ import re
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from .container import RepackContainer
|
|
14
15
|
from .models import (
|
|
15
16
|
ExternalUserdataSpec,
|
|
16
17
|
InstanceSpec,
|
|
17
|
-
RszUserdataSpec,
|
|
18
|
-
UsrResourceSpec,
|
|
19
|
-
UsrUserdataSpec,
|
|
20
18
|
)
|
|
21
19
|
from .plan import PackerPlanMixin
|
|
22
20
|
from .writer import PackerWriterMixin
|
|
@@ -25,12 +23,6 @@ from ..enum_codec import is_probable_flags_enum
|
|
|
25
23
|
from ..export import User3Exporter
|
|
26
24
|
from ..rich_ui import BatchProgress
|
|
27
25
|
from ..schema import TypeDB
|
|
28
|
-
from ..usr_layouts import (
|
|
29
|
-
DEFAULT_RSZ_HEADER_LAYOUT_ID,
|
|
30
|
-
DEFAULT_USR_LAYOUT_ID,
|
|
31
|
-
get_rsz_header_layout,
|
|
32
|
-
get_usr_layout,
|
|
33
|
-
)
|
|
34
26
|
|
|
35
27
|
|
|
36
28
|
class User3Packer(PackerPlanMixin, PackerWriterMixin):
|
|
@@ -97,25 +89,9 @@ class User3Packer(PackerPlanMixin, PackerWriterMixin):
|
|
|
97
89
|
)
|
|
98
90
|
}
|
|
99
91
|
self.instances: list[InstanceSpec | ExternalUserdataSpec | None] = []
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
self.rsz_header_layout = get_rsz_header_layout(
|
|
104
|
-
DEFAULT_RSZ_HEADER_LAYOUT_ID
|
|
105
|
-
)
|
|
106
|
-
if self.rsz_header_layout is None:
|
|
107
|
-
raise RuntimeError(
|
|
108
|
-
"default RSZ header layout is not registered: "
|
|
109
|
-
f"{DEFAULT_RSZ_HEADER_LAYOUT_ID}"
|
|
110
|
-
)
|
|
111
|
-
self.usr_header_padding = b"\x00" * self.usr_layout.header_padding_size
|
|
112
|
-
self.usr_resources: list[UsrResourceSpec] = []
|
|
113
|
-
self.usr_userdata: list[UsrUserdataSpec] = []
|
|
114
|
-
# A numeric RSZ version must be loaded from repack metadata. The modern
|
|
115
|
-
# header family is shared across games and must not imply MHWS version 16.
|
|
116
|
-
self.rsz_version: int | None = None
|
|
117
|
-
self.rsz_reserved = 0
|
|
118
|
-
self.rsz_userdata: list[RszUserdataSpec] = []
|
|
92
|
+
# The container is committed atomically only after one complete repack document
|
|
93
|
+
# has passed layout, table, instance, and resource validation.
|
|
94
|
+
self.container: RepackContainer | None = None
|
|
119
95
|
|
|
120
96
|
def pack_json_file(self, json_path: str | Path, output_path: str | Path) -> Path:
|
|
121
97
|
"""Pack json file.
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Parse and validate pack-side USR/RSZ container metadata.
|
|
2
|
+
|
|
3
|
+
The repack document carries physical container information separately from the RSZ
|
|
4
|
+
instance graph. This module turns that JSON boundary into one immutable plan before
|
|
5
|
+
instance planning or binary writing mutates packer state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
ExternalUserdataSpec,
|
|
16
|
+
InstanceSpec,
|
|
17
|
+
PackError,
|
|
18
|
+
RszUserdataSpec,
|
|
19
|
+
StructValue,
|
|
20
|
+
UsrResourceSpec,
|
|
21
|
+
UsrUserdataSpec,
|
|
22
|
+
)
|
|
23
|
+
from ..core import PACK_JSON_FORMAT
|
|
24
|
+
from ..schema import ClassDef
|
|
25
|
+
from ..usr_layouts import (
|
|
26
|
+
RszHeaderLayout,
|
|
27
|
+
UsrLayoutCandidate,
|
|
28
|
+
get_rsz_header_layout,
|
|
29
|
+
get_usr_layout,
|
|
30
|
+
rsz_header_layouts_for_version,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RepackContainer:
|
|
36
|
+
"""Validated physical metadata required to rebuild one ``.user.3`` container."""
|
|
37
|
+
|
|
38
|
+
usr_layout: UsrLayoutCandidate
|
|
39
|
+
rsz_layout: RszHeaderLayout
|
|
40
|
+
usr_header_padding: bytes
|
|
41
|
+
usr_resources: tuple[UsrResourceSpec, ...]
|
|
42
|
+
usr_userdata: tuple[UsrUserdataSpec, ...]
|
|
43
|
+
rsz_version: int
|
|
44
|
+
rsz_reserved: int
|
|
45
|
+
rsz_userdata: tuple[RszUserdataSpec, ...]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_optional_u32(value: Any) -> int | None:
|
|
49
|
+
"""Parse an optional integer or hexadecimal string as unsigned 32-bit data."""
|
|
50
|
+
|
|
51
|
+
if value is None:
|
|
52
|
+
return None
|
|
53
|
+
if isinstance(value, int):
|
|
54
|
+
return value & 0xFFFFFFFF
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
text = value.strip()
|
|
57
|
+
if not text:
|
|
58
|
+
return None
|
|
59
|
+
return int(text, 0) & 0xFFFFFFFF
|
|
60
|
+
raise PackError(f"expected integer or hex string, got {value!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_required_u32(value: Any, label: str) -> int:
|
|
64
|
+
"""Parse a required unsigned 32-bit value with a document-path label."""
|
|
65
|
+
|
|
66
|
+
parsed = parse_optional_u32(value)
|
|
67
|
+
if parsed is None:
|
|
68
|
+
raise PackError(f"{label} is required")
|
|
69
|
+
return parsed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_path(value: Any, label: str) -> str:
|
|
73
|
+
"""Validate a path-like JSON field without normalizing its binary spelling."""
|
|
74
|
+
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
raise PackError(f"{label} must be a string")
|
|
77
|
+
if "\x00" in value:
|
|
78
|
+
raise PackError(f"{label} cannot contain a NUL character")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_layout_metadata(
|
|
83
|
+
data: dict[str, Any],
|
|
84
|
+
) -> tuple[str, UsrLayoutCandidate, RszHeaderLayout, int, int]:
|
|
85
|
+
"""Resolve and validate the outer and embedded layout declarations."""
|
|
86
|
+
|
|
87
|
+
raw_layout = data.get("_layout")
|
|
88
|
+
if not isinstance(raw_layout, dict):
|
|
89
|
+
raise PackError("repack v3 must contain a _layout object")
|
|
90
|
+
layout_id = raw_layout.get("usr")
|
|
91
|
+
if not isinstance(layout_id, str) or not layout_id:
|
|
92
|
+
raise PackError("repack v3 _layout.usr must be a layout id")
|
|
93
|
+
usr_layout = get_usr_layout(layout_id)
|
|
94
|
+
if usr_layout is None:
|
|
95
|
+
raise PackError(f"unknown USR layout id: {layout_id}")
|
|
96
|
+
if not usr_layout.repack_supported:
|
|
97
|
+
raise PackError(f"USR layout {usr_layout.identifier} is read-only")
|
|
98
|
+
|
|
99
|
+
rsz_version = raw_layout.get("rsz_version")
|
|
100
|
+
if not isinstance(rsz_version, int):
|
|
101
|
+
raise PackError("_layout.rsz_version must be an integer")
|
|
102
|
+
rsz_layout = _resolve_rsz_layout(raw_layout.get("rsz_header"), rsz_version)
|
|
103
|
+
if not rsz_layout.repack_supported:
|
|
104
|
+
raise PackError(f"RSZ header layout {rsz_layout.identifier} is read-only")
|
|
105
|
+
if not rsz_layout.supports_version(rsz_version):
|
|
106
|
+
raise PackError(
|
|
107
|
+
f"RSZ version {rsz_version} is not supported by header layout "
|
|
108
|
+
f"{rsz_layout.identifier}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
rsz_reserved = raw_layout.get("rsz_reserved", 0)
|
|
112
|
+
if not isinstance(rsz_reserved, int) or not -(1 << 31) <= rsz_reserved < (1 << 31):
|
|
113
|
+
raise PackError("_layout.rsz_reserved must be a signed 32-bit integer")
|
|
114
|
+
return layout_id, usr_layout, rsz_layout, rsz_version, rsz_reserved
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_rsz_layout(
|
|
118
|
+
layout_id: Any, rsz_version: int
|
|
119
|
+
) -> RszHeaderLayout:
|
|
120
|
+
"""Resolve an explicit RSZ header id or one unambiguous version match."""
|
|
121
|
+
|
|
122
|
+
if layout_id is None:
|
|
123
|
+
inferred = rsz_header_layouts_for_version(rsz_version)
|
|
124
|
+
if len(inferred) != 1:
|
|
125
|
+
raise PackError(
|
|
126
|
+
f"cannot infer one RSZ header layout for version {rsz_version}"
|
|
127
|
+
)
|
|
128
|
+
return inferred[0]
|
|
129
|
+
if not isinstance(layout_id, str) or not layout_id:
|
|
130
|
+
raise PackError("repack v3 _layout.rsz_header must be a layout id")
|
|
131
|
+
layout = get_rsz_header_layout(layout_id)
|
|
132
|
+
if layout is None:
|
|
133
|
+
raise PackError(f"unknown RSZ header layout id: {layout_id}")
|
|
134
|
+
return layout
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_usr_metadata(
|
|
138
|
+
data: dict[str, Any],
|
|
139
|
+
layout_id: str,
|
|
140
|
+
layout: UsrLayoutCandidate,
|
|
141
|
+
) -> tuple[bytes, tuple[UsrResourceSpec, ...], tuple[UsrUserdataSpec, ...]]:
|
|
142
|
+
"""Parse outer USR padding, dependency tables, and capability gates."""
|
|
143
|
+
|
|
144
|
+
raw_usr = data.get("_usr")
|
|
145
|
+
if not isinstance(raw_usr, dict):
|
|
146
|
+
raise PackError("repack v3 must contain a _usr object")
|
|
147
|
+
header_padding = _parse_header_padding(raw_usr, layout_id, layout)
|
|
148
|
+
resources = _parse_usr_resources(raw_usr, layout_id, layout)
|
|
149
|
+
userdata = _parse_usr_userdata(raw_usr, layout_id, layout)
|
|
150
|
+
raw_info = raw_usr.get("info")
|
|
151
|
+
if not isinstance(raw_info, list):
|
|
152
|
+
raise PackError("_usr.info must be an array")
|
|
153
|
+
if raw_info:
|
|
154
|
+
raise PackError(
|
|
155
|
+
f"layout {layout_id} cannot rebuild a nonempty USR info table"
|
|
156
|
+
)
|
|
157
|
+
return header_padding, resources, userdata
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_header_padding(
|
|
161
|
+
raw_usr: dict[str, Any],
|
|
162
|
+
layout_id: str,
|
|
163
|
+
layout: UsrLayoutCandidate,
|
|
164
|
+
) -> bytes:
|
|
165
|
+
"""Decode the opaque bytes between the semantic and physical USR headers."""
|
|
166
|
+
|
|
167
|
+
padding_hex = raw_usr.get("header_padding_hex", "")
|
|
168
|
+
if not isinstance(padding_hex, str):
|
|
169
|
+
raise PackError("_usr.header_padding_hex must be hexadecimal text")
|
|
170
|
+
try:
|
|
171
|
+
header_padding = bytes.fromhex(padding_hex)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise PackError("_usr.header_padding_hex is not valid hexadecimal") from exc
|
|
174
|
+
if len(header_padding) != layout.header_padding_size:
|
|
175
|
+
raise PackError(
|
|
176
|
+
f"layout {layout_id} requires {layout.header_padding_size} header "
|
|
177
|
+
f"padding bytes, got {len(header_padding)}"
|
|
178
|
+
)
|
|
179
|
+
return header_padding
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_usr_resources(
|
|
183
|
+
raw_usr: dict[str, Any],
|
|
184
|
+
layout_id: str,
|
|
185
|
+
layout: UsrLayoutCandidate,
|
|
186
|
+
) -> tuple[UsrResourceSpec, ...]:
|
|
187
|
+
"""Parse the outer resource dependency table."""
|
|
188
|
+
|
|
189
|
+
raw_resources = raw_usr.get("resources")
|
|
190
|
+
if not isinstance(raw_resources, list):
|
|
191
|
+
raise PackError("_usr.resources must be an array")
|
|
192
|
+
resources: list[UsrResourceSpec] = []
|
|
193
|
+
for index, raw in enumerate(raw_resources):
|
|
194
|
+
if not isinstance(raw, dict):
|
|
195
|
+
raise PackError(f"_usr.resources[{index}] must be an object")
|
|
196
|
+
resources.append(
|
|
197
|
+
UsrResourceSpec(
|
|
198
|
+
path=parse_path(raw.get("path"), f"_usr.resources[{index}].path"),
|
|
199
|
+
reserved=parse_required_u32(
|
|
200
|
+
raw.get("reserved", 0),
|
|
201
|
+
f"_usr.resources[{index}].reserved",
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
if resources and not layout.supports_resources:
|
|
206
|
+
raise PackError(f"layout {layout_id} cannot rebuild USR resources")
|
|
207
|
+
return tuple(resources)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_usr_userdata(
|
|
211
|
+
raw_usr: dict[str, Any],
|
|
212
|
+
layout_id: str,
|
|
213
|
+
layout: UsrLayoutCandidate,
|
|
214
|
+
) -> tuple[UsrUserdataSpec, ...]:
|
|
215
|
+
"""Parse the outer userdata dependency table."""
|
|
216
|
+
|
|
217
|
+
raw_userdata = raw_usr.get("userdata")
|
|
218
|
+
if not isinstance(raw_userdata, list):
|
|
219
|
+
raise PackError("_usr.userdata must be an array")
|
|
220
|
+
userdata: list[UsrUserdataSpec] = []
|
|
221
|
+
for index, raw in enumerate(raw_userdata):
|
|
222
|
+
if not isinstance(raw, dict):
|
|
223
|
+
raise PackError(f"_usr.userdata[{index}] must be an object")
|
|
224
|
+
userdata.append(
|
|
225
|
+
UsrUserdataSpec(
|
|
226
|
+
class_hash=parse_required_u32(
|
|
227
|
+
raw.get("class_hash"),
|
|
228
|
+
f"_usr.userdata[{index}].class_hash",
|
|
229
|
+
),
|
|
230
|
+
crc=parse_required_u32(
|
|
231
|
+
raw.get("crc", 0),
|
|
232
|
+
f"_usr.userdata[{index}].crc",
|
|
233
|
+
),
|
|
234
|
+
path=parse_path(
|
|
235
|
+
raw.get("path"),
|
|
236
|
+
f"_usr.userdata[{index}].path",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
if userdata and not layout.supports_usr_userdata:
|
|
241
|
+
raise PackError(f"layout {layout_id} cannot rebuild USR userdata")
|
|
242
|
+
return tuple(userdata)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_rsz_userdata(
|
|
246
|
+
data: dict[str, Any], layout: RszHeaderLayout
|
|
247
|
+
) -> tuple[RszUserdataSpec, ...]:
|
|
248
|
+
"""Parse the embedded RSZ userdata table and enforce unique instance ids."""
|
|
249
|
+
|
|
250
|
+
raw_rsz = data.get("_rsz")
|
|
251
|
+
if not isinstance(raw_rsz, dict):
|
|
252
|
+
raise PackError("repack v3 must contain a _rsz object")
|
|
253
|
+
raw_userdata = raw_rsz.get("userdata")
|
|
254
|
+
if not isinstance(raw_userdata, list):
|
|
255
|
+
raise PackError("_rsz.userdata must be an array")
|
|
256
|
+
userdata: list[RszUserdataSpec] = []
|
|
257
|
+
seen_instance_ids: set[int] = set()
|
|
258
|
+
for index, raw in enumerate(raw_userdata):
|
|
259
|
+
if not isinstance(raw, dict):
|
|
260
|
+
raise PackError(f"_rsz.userdata[{index}] must be an object")
|
|
261
|
+
instance_id = raw.get("instance_id")
|
|
262
|
+
if not isinstance(instance_id, int) or instance_id <= 0:
|
|
263
|
+
raise PackError(
|
|
264
|
+
f"_rsz.userdata[{index}].instance_id must be a positive integer"
|
|
265
|
+
)
|
|
266
|
+
if instance_id in seen_instance_ids:
|
|
267
|
+
raise PackError(f"duplicate RSZ userdata instance id: {instance_id}")
|
|
268
|
+
seen_instance_ids.add(instance_id)
|
|
269
|
+
userdata.append(
|
|
270
|
+
RszUserdataSpec(
|
|
271
|
+
instance_id=instance_id,
|
|
272
|
+
type_hash=parse_required_u32(
|
|
273
|
+
raw.get("type_hash"),
|
|
274
|
+
f"_rsz.userdata[{index}].type_hash",
|
|
275
|
+
),
|
|
276
|
+
path=parse_path(
|
|
277
|
+
raw.get("path"),
|
|
278
|
+
f"_rsz.userdata[{index}].path",
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
if userdata and not layout.supports_rsz_userdata:
|
|
283
|
+
raise PackError(
|
|
284
|
+
f"RSZ header layout {layout.identifier} cannot rebuild userdata"
|
|
285
|
+
)
|
|
286
|
+
return tuple(userdata)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def parse_repack_container(data: dict[str, Any]) -> RepackContainer:
|
|
290
|
+
"""Build one validated container plan from a repack v3 JSON document."""
|
|
291
|
+
|
|
292
|
+
format_name = data.get("_format")
|
|
293
|
+
if format_name != PACK_JSON_FORMAT:
|
|
294
|
+
raise PackError(
|
|
295
|
+
f"{format_name} does not record USR/RSZ layout metadata; "
|
|
296
|
+
"re-export the source file as repack v3 before packing"
|
|
297
|
+
)
|
|
298
|
+
(
|
|
299
|
+
layout_id,
|
|
300
|
+
usr_layout,
|
|
301
|
+
rsz_layout,
|
|
302
|
+
rsz_version,
|
|
303
|
+
rsz_reserved,
|
|
304
|
+
) = _parse_layout_metadata(data)
|
|
305
|
+
header_padding, resources, usr_userdata = _parse_usr_metadata(
|
|
306
|
+
data, layout_id, usr_layout
|
|
307
|
+
)
|
|
308
|
+
rsz_userdata = _parse_rsz_userdata(data, rsz_layout)
|
|
309
|
+
return RepackContainer(
|
|
310
|
+
usr_layout=usr_layout,
|
|
311
|
+
rsz_layout=rsz_layout,
|
|
312
|
+
usr_header_padding=header_padding,
|
|
313
|
+
usr_resources=resources,
|
|
314
|
+
usr_userdata=usr_userdata,
|
|
315
|
+
rsz_version=rsz_version,
|
|
316
|
+
rsz_reserved=rsz_reserved,
|
|
317
|
+
rsz_userdata=rsz_userdata,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def validate_userdata_metadata(
|
|
322
|
+
container: RepackContainer,
|
|
323
|
+
instances: Sequence[InstanceSpec | ExternalUserdataSpec | None],
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Ensure outer and embedded userdata tables agree with instance metadata."""
|
|
326
|
+
|
|
327
|
+
external = {
|
|
328
|
+
index: spec
|
|
329
|
+
for index, spec in enumerate(instances)
|
|
330
|
+
if isinstance(spec, ExternalUserdataSpec)
|
|
331
|
+
}
|
|
332
|
+
listed_ids = {item.instance_id for item in container.rsz_userdata}
|
|
333
|
+
if listed_ids != set(external):
|
|
334
|
+
raise PackError(
|
|
335
|
+
"RSZ userdata table instance ids do not match userdata_reference "
|
|
336
|
+
f"instances: table={sorted(listed_ids)}, instances={sorted(external)}"
|
|
337
|
+
)
|
|
338
|
+
for item in container.rsz_userdata:
|
|
339
|
+
spec = external[item.instance_id]
|
|
340
|
+
if item.type_hash != spec.class_hash or item.path != spec.path:
|
|
341
|
+
raise PackError(
|
|
342
|
+
f"RSZ userdata metadata does not match instance {item.instance_id}"
|
|
343
|
+
)
|
|
344
|
+
outer = [(item.class_hash, item.path) for item in container.usr_userdata]
|
|
345
|
+
embedded = [(item.type_hash, item.path) for item in container.rsz_userdata]
|
|
346
|
+
if outer != embedded:
|
|
347
|
+
raise PackError("USR userdata dependencies do not match RSZ userdata order")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def validate_resource_metadata(
|
|
351
|
+
container: RepackContainer,
|
|
352
|
+
instances: Sequence[InstanceSpec | ExternalUserdataSpec | None],
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Require every RSZ ``Resource`` value in the outer dependency table."""
|
|
355
|
+
|
|
356
|
+
dependencies = {item.path for item in container.usr_resources}
|
|
357
|
+
missing: set[str] = set()
|
|
358
|
+
for spec in instances[1:]:
|
|
359
|
+
if isinstance(spec, InstanceSpec):
|
|
360
|
+
missing.update(
|
|
361
|
+
path
|
|
362
|
+
for path in _iter_struct_resources(spec.class_def, spec.fields)
|
|
363
|
+
if path and path not in dependencies
|
|
364
|
+
)
|
|
365
|
+
if missing:
|
|
366
|
+
raise PackError(
|
|
367
|
+
"RSZ Resource values are missing from _usr.resources: "
|
|
368
|
+
f"{sorted(missing)}"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _iter_struct_resources(
|
|
373
|
+
class_def: ClassDef,
|
|
374
|
+
fields: dict[str, Any],
|
|
375
|
+
) -> Iterator[str]:
|
|
376
|
+
"""Yield resource paths from one prepared instance or nested struct."""
|
|
377
|
+
|
|
378
|
+
for field_def in class_def.fields:
|
|
379
|
+
value = fields.get(field_def.name or "unnamed")
|
|
380
|
+
values = value if field_def.is_array and isinstance(value, list) else [value]
|
|
381
|
+
if field_def.field_type == "Resource":
|
|
382
|
+
yield from (item for item in values if isinstance(item, str) and item)
|
|
383
|
+
continue
|
|
384
|
+
if field_def.field_type != "Struct":
|
|
385
|
+
continue
|
|
386
|
+
for item in values:
|
|
387
|
+
if isinstance(item, StructValue):
|
|
388
|
+
yield from _iter_struct_resources(item.class_def, item.fields)
|