PyREUser3 0.8.0__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PKG-INFO +6 -4
  2. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/PKG-INFO +6 -4
  3. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/SOURCES.txt +3 -0
  4. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/README.md +19 -5
  5. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/docs/PYPI.md +5 -3
  6. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/docs/README.zh-CN.md +15 -4
  7. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyproject.toml +1 -1
  8. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/__init__.py +1 -1
  9. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/api.py +7 -9
  10. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/cli.py +10 -4
  11. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/core.py +43 -0
  12. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/base.py +12 -11
  13. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/enums.py +8 -0
  14. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/fields.py +17 -0
  15. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/metadata.py +6 -0
  16. pyreuser3-0.8.1/pyreuser3/native_structs.py +368 -0
  17. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/base.py +27 -5
  18. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/models.py +8 -0
  19. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/values.py +65 -1
  20. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/writer.py +27 -1
  21. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/usr_container.py +5 -9
  22. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/page.py +13 -13
  23. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/runners.py +7 -1
  24. pyreuser3-0.8.1/tests/test_discovery_and_web_depth.py +189 -0
  25. pyreuser3-0.8.1/tests/test_native_structs.py +339 -0
  26. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/LICENSE +0 -0
  27. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/MANIFEST.in +0 -0
  28. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/dependency_links.txt +0 -0
  29. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/entry_points.txt +0 -0
  30. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/requires.txt +0 -0
  31. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/PyREUser3.egg-info/top_level.txt +0 -0
  32. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/__main__.py +0 -0
  33. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/enum_codec.py +0 -0
  34. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/__init__.py +0 -0
  35. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/postprocess.py +0 -0
  36. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/tree.py +0 -0
  37. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/export/user3.py +0 -0
  38. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/__init__.py +0 -0
  39. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/container.py +0 -0
  40. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/pack/plan.py +0 -0
  41. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/rich_ui.py +0 -0
  42. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/schema.py +0 -0
  43. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/usr_layouts.py +0 -0
  44. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/__init__.py +0 -0
  45. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/__main__.py +0 -0
  46. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/handler.py +0 -0
  47. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/jobs.py +0 -0
  48. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/picker.py +0 -0
  49. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/server.py +0 -0
  50. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/pyreuser3/web/settings.py +0 -0
  51. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/setup.cfg +0 -0
  52. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/tests/test_api_cache.py +0 -0
  53. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/tests/test_enum_binary.py +0 -0
  54. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/tests/test_enum_codec.py +0 -0
  55. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/tests/test_export_enums.py +0 -0
  56. {pyreuser3-0.8.0 → pyreuser3-0.8.1}/tests/test_usr_container.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.8.0
3
+ Version: 0.8.1
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
@@ -43,7 +43,7 @@ pip install pyreuser3
43
43
 
44
44
  ## What Is Included
45
45
 
46
- - `.user.3 -> JSON` export.
46
+ - `.user.3` and decorated `.user.3.*` export.
47
47
  - `JSON -> .user.3` packing.
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
@@ -51,6 +51,8 @@ pip install pyreuser3
51
51
  - A local `.user.3` export Web UI through `pyreuser3-web`.
52
52
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
53
53
  modern RSZ v4+ files preserve their original numeric version during repack.
54
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
55
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
54
56
 
55
57
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
56
58
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -71,8 +73,8 @@ of Python scalar objects.
71
73
 
72
74
  - Python 3.9 or newer.
73
75
  - A RE_RSZ schema JSON file for the target game/version.
74
- - An `il2cpp_dump.json` file when exporting readable enum labels.
75
- - One or more unpacked `.user.3` files.
76
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
77
+ - One or more unpacked `.user.3` or `.user.3.*` files.
76
78
 
77
79
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
78
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyREUser3
3
- Version: 0.8.0
3
+ Version: 0.8.1
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
@@ -43,7 +43,7 @@ pip install pyreuser3
43
43
 
44
44
  ## What Is Included
45
45
 
46
- - `.user.3 -> JSON` export.
46
+ - `.user.3` and decorated `.user.3.*` export.
47
47
  - `JSON -> .user.3` packing.
48
48
  - A reusable Python API through `REUser3Converter`.
49
49
  - CLI commands through `pyreuser3`.
@@ -51,6 +51,8 @@ pip install pyreuser3
51
51
  - A local `.user.3` export Web UI through `pyreuser3-web`.
52
52
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
53
53
  modern RSZ v4+ files preserve their original numeric version during repack.
54
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
55
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
54
56
 
55
57
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
56
58
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -71,8 +73,8 @@ of Python scalar objects.
71
73
 
72
74
  - Python 3.9 or newer.
73
75
  - A RE_RSZ schema JSON file for the target game/version.
74
- - An `il2cpp_dump.json` file when exporting readable enum labels.
75
- - One or more unpacked `.user.3` files.
76
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
77
+ - One or more unpacked `.user.3` or `.user.3.*` files.
76
78
 
77
79
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
78
80
 
@@ -16,6 +16,7 @@ pyreuser3/api.py
16
16
  pyreuser3/cli.py
17
17
  pyreuser3/core.py
18
18
  pyreuser3/enum_codec.py
19
+ pyreuser3/native_structs.py
19
20
  pyreuser3/rich_ui.py
20
21
  pyreuser3/schema.py
21
22
  pyreuser3/usr_container.py
@@ -45,7 +46,9 @@ pyreuser3/web/runners.py
45
46
  pyreuser3/web/server.py
46
47
  pyreuser3/web/settings.py
47
48
  tests/test_api_cache.py
49
+ tests/test_discovery_and_web_depth.py
48
50
  tests/test_enum_binary.py
49
51
  tests/test_enum_codec.py
50
52
  tests/test_export_enums.py
53
+ tests/test_native_structs.py
51
54
  tests/test_usr_container.py
@@ -27,7 +27,7 @@ from pyreuser3 import REUser3Converter
27
27
 
28
28
  ## What Is Included
29
29
 
30
- - `.user.3 -> JSON` export.
30
+ - `.user.3` and decorated `.user.3.*` (for example `.user.3.X64`) export.
31
31
  - `JSON -> .user.3` packing.
32
32
  - A reusable Python API through `REUser3Converter`.
33
33
  - CLI commands through `pyreuser3`.
@@ -41,8 +41,8 @@ or repository-specific helper scripts.
41
41
 
42
42
  - Python 3.9 or newer.
43
43
  - A RE_RSZ schema JSON file for the target game/version.
44
- - An `il2cpp_dump.json` file when exporting readable enum labels.
45
- - One or more unpacked `.user.3` files.
44
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
45
+ - One or more unpacked `.user.3` or `.user.3.*` files.
46
46
 
47
47
  Layout probing is the exception: `pyreuser3 probe` only inspects the USR/RSZ
48
48
  container and does not require a schema or `il2cpp_dump.json`.
@@ -78,7 +78,9 @@ pyreuser3 pack \
78
78
  -p <il2cpp_dump.json>
79
79
  ```
80
80
 
81
- The `-p/--il2cpp-dump-path` option is required for export and optional for pack. Passing it during pack is recommended when enum names need to be resolved back to numeric values.
81
+ The `-p/--il2cpp-dump-path` option is required for export and optional for pack. Pass it
82
+ during pack when enum names or readable native structures need to be resolved back to
83
+ binary values. Legacy `{"raw": "..."}` structure values remain packable without it.
82
84
 
83
85
  Probe a file or directory without loading game metadata:
84
86
 
@@ -99,7 +101,9 @@ Start the local `.user.3` export Web UI:
99
101
  pyreuser3-web --port 8765
100
102
  ```
101
103
 
102
- The Web UI only handles `.user.3` export. It does not pack files.
104
+ The Web UI handles `.user.3` and `.user.3.*` export. It does not pack files.
105
+ Its **Tree Depth** field accepts `auto` or a non-negative integer; an explicit
106
+ integer is passed through to readable reference-tree expansion.
103
107
 
104
108
  ## Python API
105
109
 
@@ -191,6 +195,16 @@ fixtures validate byte-for-byte repacking. V1 and v2 documents are recognized fo
191
195
  diagnostics but must be re-exported as v3 before packing because they do not record
192
196
  the required layout metadata.
193
197
 
198
+ Fixed-width RE Engine value types use a game-independent, declarative codec registry.
199
+ The first supported codecs are `via.Int2`, `via.Uint2`, `via.Range`, `via.RangeI`,
200
+ and `via.Sphere`, including scalar and array fields. A codec is enabled only when the
201
+ active il2cpp dump exactly validates the value type's parent, boxed size, member types,
202
+ and member offsets, and the schema independently agrees on the original type and payload
203
+ size. Matching values are exported as named JSON objects, for example
204
+ `{"x": 10, "y": 20}` or `{"pos": [1.0, 2.0, 3.0], "r": 4.0}`.
205
+ Any absent or mismatched signature fails closed to the existing raw representation;
206
+ legacy raw scalar and array JSON remains accepted for lossless repacking.
207
+
194
208
  Modern files may use signed `-1` as an explicit null `Object` reference; repack
195
209
  preserves that sentinel while continuing to reject every other missing instance
196
210
  ID. Fixed-width arrays above one million elements are kept compactly as
@@ -10,7 +10,7 @@ pip install pyreuser3
10
10
 
11
11
  ## What Is Included
12
12
 
13
- - `.user.3 -> JSON` export.
13
+ - `.user.3` and decorated `.user.3.*` export.
14
14
  - `JSON -> .user.3` packing.
15
15
  - A reusable Python API through `REUser3Converter`.
16
16
  - CLI commands through `pyreuser3`.
@@ -18,6 +18,8 @@ pip install pyreuser3
18
18
  - A local `.user.3` export Web UI through `pyreuser3-web`.
19
19
  - Automatic separation of the USR outer layout from the embedded RSZ header family;
20
20
  modern RSZ v4+ files preserve their original numeric version during repack.
21
+ - il2cpp-validated readable codecs for `via.Int2`, `via.Uint2`, `via.Range`,
22
+ `via.RangeI`, and `via.Sphere`, with scalar/array round trips and raw fallback.
21
23
 
22
24
  The published package intentionally does not include game resources, dumped game data, RE_RSZ templates,
23
25
  `il2cpp_dump.json`, or repository-specific helper scripts. You need to provide data files that match the target game and
@@ -38,8 +40,8 @@ of Python scalar objects.
38
40
 
39
41
  - Python 3.9 or newer.
40
42
  - A RE_RSZ schema JSON file for the target game/version.
41
- - An `il2cpp_dump.json` file when exporting readable enum labels.
42
- - One or more unpacked `.user.3` files.
43
+ - An `il2cpp_dump.json` file when exporting readable enum labels and validated native structures.
44
+ - One or more unpacked `.user.3` or `.user.3.*` files.
43
45
 
44
46
  `pyreuser3 probe` does not require a schema or `il2cpp_dump.json`.
45
47
 
@@ -29,7 +29,7 @@ from pyreuser3 import REUser3Converter
29
29
 
30
30
  PyREUser3 当前提供:
31
31
 
32
- - `.user.3 -> JSON` 导出;
32
+ - `.user.3` 以及 `.user.3.X64` 这类 `.user.3.*` 变体导出;
33
33
  - `JSON -> .user.3` 封包;
34
34
  - 面向其他项目调用的 `REUser3Converter` Python API;
35
35
  - `pyreuser3` 命令行工具;
@@ -42,8 +42,8 @@ PyPI 包不会包含游戏资源、游戏 dump、RE_RSZ 模板、`il2cpp_dump.js
42
42
 
43
43
  - Python 3.9 或更高版本;
44
44
  - 与目标游戏和版本匹配的 RE_RSZ schema JSON;
45
- - 导出可读枚举标签时需要 `il2cpp_dump.json`;
46
- - 一个或多个已解包的 `.user.3` 文件。
45
+ - 导出可读枚举标签和经验证的原生结构时需要 `il2cpp_dump.json`;
46
+ - 一个或多个已解包的 `.user.3` 或 `.user.3.*` 文件。
47
47
 
48
48
  布局探测是例外:`pyreuser3 probe` 只检查 USR/RSZ 容器,不需要 schema 或
49
49
  `il2cpp_dump.json`。
@@ -82,7 +82,8 @@ pyreuser3 pack \
82
82
  说明:
83
83
 
84
84
  - `export` 时 `-p/--il2cpp-dump-path` 是必填项,用于生成可读枚举标签;
85
- - `pack` 时 `-p/--il2cpp-dump-path` 是可选项,但如果 JSON 中包含枚举名称,建议传入;
85
+ - `pack` 时 `-p/--il2cpp-dump-path` 是可选项;如果 JSON 中包含枚举名称或可读原生结构,
86
+ 需要传入。旧版 `{"raw": "..."}` 结构值无需 il2cpp 仍可封回;
86
87
  - `-s/--schema-path` 必须指向具体 schema JSON 文件,不应传目录;
87
88
  - `-i`、`-j` 都可以传单个文件或目录,目录会递归处理。
88
89
 
@@ -117,6 +118,9 @@ pyreuser3 export \
117
118
  pyreuser3-web --port 8765
118
119
  ```
119
120
 
121
+ Web 界面的“树深度”可填写 `auto` 或非负整数;显式整数会直接用于 readable
122
+ 引用树展开。
123
+
120
124
  默认地址:
121
125
 
122
126
  ```text
@@ -205,6 +209,13 @@ API 则把它们报告为结构化警告;repack 导出会把同一诊断写入
205
209
  获得真实样本并完成逐字节回封验证后才能启用 repack。v1 和 v2 文档仍可识别以便诊断,但由于
206
210
  缺少必要的布局元数据,封回前必须从源文件重新导出为 v3。
207
211
 
212
+ 定长 RE Engine 值类型由与游戏无关的声明式 codec 注册表处理。首批支持
213
+ `via.Int2`、`via.Uint2`、`via.Range`、`via.RangeI` 和 `via.Sphere`,同时覆盖标量与数组。
214
+ 只有当前 il2cpp dump 精确验证其值类型父类、boxed size、成员类型和成员偏移,并且 schema
215
+ 中的原始类型与 payload size 也一致时,codec 才会启用。匹配的数据会导出为命名 JSON 对象,
216
+ 例如 `{"x": 10, "y": 20}` 或 `{"pos": [1.0, 2.0, 3.0], "r": 4.0}`。
217
+ 签名缺失或不匹配时会保守退回原有 raw 表示;旧版 raw 标量与数组 JSON 继续支持无损封回。
218
+
208
219
  部分现代文件会用有符号 `-1` 明确表示空 `Object` 引用;repack 会原样保留这个哨兵,同时继续
209
220
  拒绝其他不存在的实例 ID。超过一百万项的定长数组会紧凑保存为 `_raw_array_count` 和
210
221
  `_raw_array_hex`。这种表示不提供逐项可读性,但能够无损回封、避免展开数百万个 Python 整数,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyREUser3"
7
- version = "0.8.0"
7
+ version = "0.8.1"
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.8.0"
12
+ __version__ = "0.8.1"
13
13
 
14
14
  __all__ = [
15
15
  "__version__",
@@ -14,7 +14,12 @@ import threading
14
14
  from pathlib import Path
15
15
  from typing import Any, Callable, Literal, Optional
16
16
 
17
- from .core import RSZ_MAGIC, USR_MAGIC, resolve_schema_path
17
+ from .core import (
18
+ RSZ_MAGIC,
19
+ USR_MAGIC,
20
+ discover_user3_files,
21
+ resolve_schema_path,
22
+ )
18
23
  from .export import User3Exporter
19
24
  from .pack import User3Packer
20
25
  from .schema import TypeDB
@@ -547,14 +552,7 @@ class REUser3Converter:
547
552
  Raises:
548
553
  FileNotFoundError: A required file or directory was missing.
549
554
  """
550
- if user3_root.is_file():
551
- return [user3_root]
552
- if not user3_root.is_dir():
553
- raise FileNotFoundError(f"user3 root not found: {user3_root}")
554
- files = sorted(user3_root.rglob("*.user.3"))
555
- if not files:
556
- raise FileNotFoundError(f"no *.user.3 found under: {user3_root}")
557
- return files
555
+ return discover_user3_files(user3_root)
558
556
 
559
557
  @staticmethod
560
558
  def _run_callback(
@@ -105,7 +105,7 @@ def build_parser() -> argparse.ArgumentParser:
105
105
  "--input-dir",
106
106
  "-i",
107
107
  required=True,
108
- help="Root directory or single .user.3 file to export.",
108
+ help="Root directory or single .user.3 / .user.3.* file to export.",
109
109
  )
110
110
  export_parser.add_argument(
111
111
  "--schema-path",
@@ -139,7 +139,10 @@ def build_parser() -> argparse.ArgumentParser:
139
139
  "--il2cpp-dump-path",
140
140
  "-p",
141
141
  required=True,
142
- help="Path to il2cpp_dump.json, used to generate enum labels.",
142
+ help=(
143
+ "Path to il2cpp_dump.json, used for enum labels and validated "
144
+ "native structures."
145
+ ),
143
146
  )
144
147
  export_parser.add_argument(
145
148
  "--json-format",
@@ -178,7 +181,10 @@ def build_parser() -> argparse.ArgumentParser:
178
181
  "--il2cpp-dump-path",
179
182
  "-p",
180
183
  default="",
181
- help="Optional il2cpp_dump.json path, used for enum name lookup.",
184
+ help=(
185
+ "Optional il2cpp_dump.json path, used for enum lookup and readable "
186
+ "native structures."
187
+ ),
182
188
  )
183
189
  pack_parser.add_argument(
184
190
  "--exclude-regex",
@@ -198,7 +204,7 @@ def build_parser() -> argparse.ArgumentParser:
198
204
  "--input-dir",
199
205
  "-i",
200
206
  required=True,
201
- help="Root directory or single .user.3 file to inspect.",
207
+ help="Root directory or single .user.3 / .user.3.* file to inspect.",
202
208
  )
203
209
  probe_parser.add_argument(
204
210
  "--json-report",
@@ -32,6 +32,7 @@ HEX32_RE = re.compile(r"^[0-9a-fA-F]{32}$")
32
32
  # Register enum values through the shared lookup tables so readable labels and numeric
33
33
  # packing stay reversible.
34
34
  ENUM_UNUSED_KEY = "value__"
35
+ USER3_NAME_MARKER = ".user.3"
35
36
 
36
37
  ENUM_UNDERLYING_TYPE_MAP = {
37
38
  "byte": "U8",
@@ -76,6 +77,48 @@ class ParseError(RuntimeError):
76
77
  pass
77
78
 
78
79
 
80
+ def is_user3_source_path(path: str | Path) -> bool:
81
+ """Return whether a filename is a binary ``.user.3`` source variant.
82
+
83
+ RE Engine resources may append platform or build qualifiers after the normal
84
+ suffix, for example ``foo.user.3.X64``. Generated JSON files deliberately do
85
+ not qualify, even though their names retain the source filename.
86
+ """
87
+
88
+ name = Path(path).name.casefold()
89
+ marker_index = name.rfind(USER3_NAME_MARKER)
90
+ if marker_index < 0 or name.endswith(".json"):
91
+ return False
92
+ tail = name[marker_index + len(USER3_NAME_MARKER) :]
93
+ return not tail or tail.startswith(".")
94
+
95
+
96
+ def discover_user3_files(root: str | Path) -> list[Path]:
97
+ """Discover standard and decorated ``.user.3`` files below a root path.
98
+
99
+ An explicitly supplied file is preserved for backward compatibility. Directory
100
+ discovery accepts both ``*.user.3`` and ``*.user.3.*`` (case-insensitively), while
101
+ excluding generated ``*.json`` export and pack documents.
102
+ """
103
+
104
+ source_root = Path(root)
105
+ if source_root.is_file():
106
+ return [source_root]
107
+ if not source_root.is_dir():
108
+ raise FileNotFoundError(f"user3 root not found: {source_root}")
109
+
110
+ files = sorted(
111
+ path
112
+ for path in source_root.rglob("*")
113
+ if path.is_file() and is_user3_source_path(path)
114
+ )
115
+ if not files:
116
+ raise FileNotFoundError(
117
+ f"no .user.3 or .user.3.* files found under: {source_root}"
118
+ )
119
+ return files
120
+
121
+
79
122
  def align(value: int, alignment: int) -> int:
80
123
  """Round an offset up to the requested byte alignment.
81
124
 
@@ -16,7 +16,12 @@ from .metadata import ExporterMetadataMixin
16
16
  from .postprocess import ExporterPostprocessMixin
17
17
  from .tree import ExporterTreeMixin
18
18
  from .user3 import ExporterUser3ParserMixin
19
- from ..core import RSZ_MAGIC, USR_MAGIC, resolve_schema_path
19
+ from ..core import (
20
+ RSZ_MAGIC,
21
+ USR_MAGIC,
22
+ discover_user3_files,
23
+ resolve_schema_path,
24
+ )
20
25
  from ..rich_ui import BatchProgress
21
26
  from ..schema import TypeDB
22
27
 
@@ -106,6 +111,7 @@ class User3Exporter(
106
111
  self.bitset_rules: dict[str, str] = {}
107
112
  self.param_type_default_enum: dict[str, str] = {}
108
113
  self.enum_underlying_types: dict[str, str] = {}
114
+ self.native_struct_layouts: dict[str, dict] = {}
109
115
  self.enum_flags: set[str] = set()
110
116
  self.enum_member_to_types: dict[str, list[str]] = {}
111
117
  self._pending_enum_context: dict | None = None
@@ -139,7 +145,7 @@ class User3Exporter(
139
145
  with BatchProgress(
140
146
  "Exporting user3", total=len(files), unit="file"
141
147
  ) as progress:
142
- progress.log(f"Found {len(files)} .user.3 file(s).")
148
+ progress.log(f"Found {len(files)} user3 source file(s).")
143
149
  progress.log(f"Schema: {self.schema_path}")
144
150
  progress.log(f"Output directory: {self.output_root}")
145
151
  for user3_file in files:
@@ -237,14 +243,7 @@ class User3Exporter(
237
243
  Raises:
238
244
  FileNotFoundError: A required file or directory was missing.
239
245
  """
240
- if self.user3_root.is_file():
241
- files = [self.user3_root]
242
- else:
243
- if not self.user3_root.is_dir():
244
- raise FileNotFoundError(f"user3 root not found: {self.user3_root}")
245
- files = sorted(self.user3_root.rglob("*.user.3"))
246
- if not files:
247
- raise FileNotFoundError(f"no *.user.3 found under: {self.user3_root}")
246
+ files = discover_user3_files(self.user3_root)
248
247
  if not self._exclude_patterns:
249
248
  return files
250
249
 
@@ -260,7 +259,9 @@ class User3Exporter(
260
259
  continue
261
260
  kept.append(file_path)
262
261
  if not kept:
263
- raise FileNotFoundError("all *.user.3 files were excluded by regex filters")
262
+ raise FileNotFoundError(
263
+ "all .user.3 and .user.3.* files were excluded by regex filters"
264
+ )
264
265
  return kept
265
266
 
266
267
  def _output_path_for(self, user3_file: Path) -> Path:
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from typing import Any
14
14
 
15
15
  from ..core import ENUM_UNUSED_KEY, normalize_enum_storage_type
16
+ from ..native_structs import collect_validated_native_struct_layout
16
17
 
17
18
 
18
19
  _FIXED_ENUM_RE = re.compile(r"[A-Za-z0-9_.+]+_Fixed")
@@ -298,6 +299,7 @@ class ExporterEnumSourceMixin:
298
299
  "bitset_rules": {},
299
300
  "generic_candidates": {},
300
301
  "enum_underlying_types": {},
302
+ "native_struct_layouts": {},
301
303
  "enum_types": set(),
302
304
  "serializable_fallback_candidates": [],
303
305
  "fixed_backing_candidates": [],
@@ -311,6 +313,11 @@ class ExporterEnumSourceMixin:
311
313
  if not isinstance(class_name, str) or not isinstance(obj, dict):
312
314
  return
313
315
 
316
+ native_layout = collect_validated_native_struct_layout(class_name, obj)
317
+ if native_layout is not None:
318
+ type_name, descriptor = native_layout
319
+ state["native_struct_layouts"][type_name] = descriptor
320
+
314
321
  if obj.get("parent") == "System.Enum":
315
322
  state["enum_types"].add(class_name)
316
323
  if obj.get("parent") == "System.Enum":
@@ -521,6 +528,7 @@ class ExporterEnumSourceMixin:
521
528
  "generic_scalar_rules": state["generic_scalar_rules"],
522
529
  "bitset_rules": state["bitset_rules"],
523
530
  "enum_underlying_types": state["enum_underlying_types"],
531
+ "native_struct_layouts": state["native_struct_layouts"],
524
532
  }
525
533
 
526
534
  @classmethod
@@ -18,6 +18,11 @@ from ..core import (
18
18
  enum_storage_size,
19
19
  enum_storage_type_from_size,
20
20
  )
21
+ from ..native_structs import (
22
+ NativeStructValueError,
23
+ decode_native_struct,
24
+ resolve_native_struct_codec,
25
+ )
21
26
  from ..schema import ClassDef, FieldDef
22
27
 
23
28
 
@@ -141,6 +146,18 @@ class ExporterFieldParserMixin:
141
146
  count = max(field.size // 4, 1)
142
147
  return [reader.read_f32() for _ in range(count)]
143
148
 
149
+ codec = resolve_native_struct_codec(
150
+ field, getattr(self, "native_struct_layouts", {})
151
+ )
152
+ if codec is not None and reader.size - reader.tell() >= codec.payload_size:
153
+ payload = reader.read(codec.payload_size)
154
+ try:
155
+ return decode_native_struct(codec, payload)
156
+ except NativeStructValueError:
157
+ # Non-finite float payloads and any future non-lossless cases stay raw
158
+ # so a JSON round trip cannot silently alter their bit patterns.
159
+ return {"raw": payload.hex(), "type": t}
160
+
144
161
  if field.size <= 0:
145
162
  return None
146
163
  to_read = max(0, min(field.size, reader.size - reader.tell()))
@@ -12,6 +12,7 @@ from typing import Any
12
12
 
13
13
  from ..core import ParseError, enum_storage_type_from_size
14
14
  from ..enum_codec import enum_member_for_value, is_probable_flags_enum
15
+ from ..native_structs import normalize_native_struct_layouts
15
16
  from ..rich_ui import get_console
16
17
 
17
18
 
@@ -242,8 +243,13 @@ class ExporterMetadataMixin:
242
243
  self.bitset_rules = {}
243
244
  self.param_type_default_enum = {}
244
245
  self.enum_underlying_types = {}
246
+ self.native_struct_layouts = {}
245
247
  self.enum_flags = set()
246
248
 
249
+ self.native_struct_layouts = normalize_native_struct_layouts(
250
+ raw.get("native_struct_layouts")
251
+ )
252
+
247
253
  class_field_fixed_types = raw.get("class_field_fixed_types")
248
254
  if isinstance(class_field_fixed_types, dict):
249
255
  for cls_name, field_map in class_field_fixed_types.items():