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.
Files changed (54) hide show
  1. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PKG-INFO +1 -8
  2. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/PKG-INFO +1 -8
  3. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/SOURCES.txt +2 -0
  4. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/README.md +0 -27
  5. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/docs/PYPI.md +0 -7
  6. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/docs/README.zh-CN.md +0 -23
  7. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyproject.toml +1 -1
  8. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/__init__.py +1 -1
  9. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/postprocess.py +13 -11
  10. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/base.py +4 -28
  11. pyreuser3-0.7.2/pyreuser3/pack/container.py +388 -0
  12. pyreuser3-0.7.2/pyreuser3/pack/plan.py +454 -0
  13. pyreuser3-0.7.2/pyreuser3/pack/values.py +278 -0
  14. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/writer.py +12 -34
  15. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_enum_binary.py +29 -2
  16. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_enum_codec.py +27 -0
  17. pyreuser3-0.7.1/pyreuser3/pack/plan.py +0 -1040
  18. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/LICENSE +0 -0
  19. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/MANIFEST.in +0 -0
  20. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/dependency_links.txt +0 -0
  21. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/entry_points.txt +0 -0
  22. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/requires.txt +0 -0
  23. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/PyREUser3.egg-info/top_level.txt +0 -0
  24. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/__main__.py +0 -0
  25. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/api.py +0 -0
  26. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/cli.py +0 -0
  27. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/core.py +0 -0
  28. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/enum_codec.py +0 -0
  29. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/__init__.py +0 -0
  30. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/base.py +0 -0
  31. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/enums.py +0 -0
  32. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/fields.py +0 -0
  33. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/metadata.py +0 -0
  34. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/tree.py +0 -0
  35. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/export/user3.py +0 -0
  36. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/__init__.py +0 -0
  37. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/pack/models.py +0 -0
  38. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/rich_ui.py +0 -0
  39. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/schema.py +0 -0
  40. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/usr_container.py +0 -0
  41. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/usr_layouts.py +0 -0
  42. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/__init__.py +0 -0
  43. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/__main__.py +0 -0
  44. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/handler.py +0 -0
  45. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/jobs.py +0 -0
  46. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/page.py +0 -0
  47. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/picker.py +0 -0
  48. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/runners.py +0 -0
  49. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/server.py +0 -0
  50. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/pyreuser3/web/settings.py +0 -0
  51. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/setup.cfg +0 -0
  52. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_api_cache.py +0 -0
  53. {pyreuser3-0.7.1 → pyreuser3-0.7.2}/tests/test_export_enums.py +0 -0
  54. {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.1
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.1
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` 文件应来自同一个游戏版本;
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Pure Python tools for converting RE Engine .user.3 files to and from JSON."
9
9
  readme = "docs/PYPI.md"
10
10
  requires-python = ">=3.9"
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from importlib import import_module
10
10
  from typing import Any
11
11
 
12
- __version__ = "0.7.1"
12
+ __version__ = "0.7.2"
13
13
 
14
14
  __all__ = [
15
15
  "__version__",
@@ -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
- # Leave numeric enum values unchanged when no enum lookup is available for the current context.
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
- matched = enum_member_for_value(
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
- self.usr_layout = get_usr_layout(DEFAULT_USR_LAYOUT_ID)
101
- if self.usr_layout is None:
102
- raise RuntimeError(f"default USR layout is not registered: {DEFAULT_USR_LAYOUT_ID}")
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)