rust-map-parser 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.
Files changed (110) hide show
  1. rust_map_parser-0.1.0.dist-info/METADATA +538 -0
  2. rust_map_parser-0.1.0.dist-info/RECORD +110 -0
  3. rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
  4. rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
  6. rustmap/__init__.py +44 -0
  7. rustmap/config.py +236 -0
  8. rustmap/data/__init__.py +1 -0
  9. rustmap/data/monument_metadata.json +954 -0
  10. rustmap/data/no_build_zones.json +9543 -0
  11. rustmap/data/prefab_manifest.json +242637 -0
  12. rustmap/data/spawn_rules.json +3308 -0
  13. rustmap/data/tunnel_tiles/__init__.py +1 -0
  14. rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
  15. rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
  16. rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
  17. rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
  18. rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
  19. rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
  20. rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
  21. rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
  22. rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
  23. rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
  24. rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
  25. rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
  26. rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
  27. rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
  28. rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
  29. rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
  30. rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
  31. rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
  32. rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
  33. rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
  34. rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
  35. rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
  36. rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
  37. rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
  38. rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
  39. rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
  40. rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
  41. rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
  42. rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
  43. rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
  44. rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
  45. rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
  46. rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
  47. rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
  48. rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
  49. rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
  50. rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
  51. rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
  52. rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
  53. rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
  54. rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
  55. rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
  56. rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
  57. rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
  58. rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
  59. rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
  60. rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
  61. rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
  62. rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
  63. rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
  64. rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
  65. rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
  66. rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
  67. rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
  68. rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
  69. rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
  70. rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
  71. rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
  72. rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
  73. rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
  74. rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
  75. rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
  76. rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
  77. rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
  78. rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
  79. rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
  80. rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
  81. rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
  82. rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
  83. rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
  84. rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
  85. rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
  86. rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
  87. rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
  88. rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
  89. rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
  90. rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
  91. rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
  92. rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
  93. rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
  94. rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
  95. rustmap/data/tunnel_tiles/tiles.json +997 -0
  96. rustmap/exporter.py +409 -0
  97. rustmap/layers.py +323 -0
  98. rustmap/monument_assets.py +145 -0
  99. rustmap/monuments.py +226 -0
  100. rustmap/no_build.py +288 -0
  101. rustmap/no_build_assets.py +303 -0
  102. rustmap/parser.py +297 -0
  103. rustmap/png.py +18 -0
  104. rustmap/populations.py +361 -0
  105. rustmap/prefabs.py +281 -0
  106. rustmap/renderer.py +460 -0
  107. rustmap/resources.py +26 -0
  108. rustmap/tunnel_assets.py +398 -0
  109. rustmap/tunnels.py +417 -0
  110. rustmap/validation.py +106 -0
@@ -0,0 +1,303 @@
1
+ """Maintainer extraction of sanitized monument no-build collider geometry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import multiprocessing as mp
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from .no_build import AUTOSPAWN_PREFIX, SURFACE_FAMILIES, _autospawn_family, _decompose
15
+ from .tunnel_assets import _component, _local_matrix, _world_matrix, bundle_identity
16
+
17
+
18
+ DATA_SCHEMA_VERSION = 4
19
+ ONLY_BLOCK_DEPLOYABLES = 512
20
+ BLOCKER_NAME_PARTS = ("prevent_build", "preventbuilding", "no_build", "nobuild")
21
+
22
+
23
+ def _include_surface_blocker(family: str | None, tagged: bool) -> bool:
24
+ return family in SURFACE_FAMILIES and (family != "tunnel-upwards" or tagged)
25
+
26
+
27
+ def default_no_build_data() -> Path:
28
+ if sys.platform == "win32":
29
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
30
+ elif sys.platform == "darwin":
31
+ base = Path.home() / "Library" / "Caches"
32
+ else:
33
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
34
+ return base / "rustmap" / "no_build_zones.json"
35
+
36
+
37
+ def _class_name(component) -> str:
38
+ if component.component.type.name != "MonoBehaviour":
39
+ return component.component.type.name
40
+ try:
41
+ return component.component.read().m_Script.read().m_ClassName
42
+ except Exception:
43
+ return "MonoBehaviour"
44
+
45
+
46
+ def _root_game_object(game_object):
47
+ current = game_object
48
+ while True:
49
+ transform = _component(current, "Transform")
50
+ if transform is None or not transform.m_Father.path_id:
51
+ return current
52
+ current = transform.m_Father.read().m_GameObject.read()
53
+
54
+
55
+ def _has_prevent_tag(game_object) -> bool:
56
+ return any(_class_name(item) == "PreventBuildingMonumentTag"
57
+ for item in game_object.m_Component)
58
+
59
+
60
+ def _collider_flags(game_object) -> int:
61
+ for item in game_object.m_Component:
62
+ if _class_name(item) == "ColliderInfo":
63
+ try:
64
+ return int(item.component.read_typetree().get("flags", 0))
65
+ except Exception:
66
+ return 0
67
+ return 0
68
+
69
+
70
+ def _collider_definition(component, local_matrix: np.ndarray, source: str):
71
+ kind = component.type.name
72
+ value = component.read()
73
+ center = getattr(value, "m_Center", None)
74
+ cx, cy, cz = (float(center.x), float(center.y), float(center.z)) if center else (0.0, 0.0, 0.0)
75
+ if kind == "BoxCollider":
76
+ size = value.m_Size
77
+ sx, sy, sz = float(size.x), float(size.y), float(size.z)
78
+ local_points = np.asarray([
79
+ [cx+dx*sx/2, cy+dy*sy/2, cz+dz*sz/2]
80
+ for dx in (-1,1) for dy in (-1,1) for dz in (-1,1)
81
+ ], dtype=np.float64)
82
+ homogeneous = np.column_stack((local_points, np.ones(len(local_points))))
83
+ root_points = (local_matrix @ homogeneous.T).T[:, :3]
84
+ planar = root_points[[0,4,5,1]][:, [0,2]]
85
+ edge_x, edge_z = planar[1]-planar[0], planar[3]-planar[0]
86
+ length_x, length_z = np.linalg.norm(edge_x), np.linalg.norm(edge_z)
87
+ if length_x == 0.0 or length_z == 0.0 or abs(float(edge_x @ edge_z)) > length_x*length_z*1e-5:
88
+ return None, "nonrepresentable"
89
+ projected_area = float(length_x * length_z)
90
+ definition = {"shape": "rectangle", "size": {"x": sx, "y": sy, "z": sz}}
91
+ root_center = (local_matrix @ np.asarray([cx,cy,cz,1.0]))[:3]
92
+ analysis = {
93
+ "shape": "rectangle", "center": root_center[[0,2]].tolist(),
94
+ "axis_x": (edge_x/length_x).tolist(), "axis_z": (edge_z/length_z).tolist(),
95
+ "half_width": float(length_x/2.0), "half_height": float(length_z/2.0),
96
+ }
97
+ elif kind == "SphereCollider":
98
+ radius = float(value.m_Radius)
99
+ projected = local_matrix[[0,2], :3]
100
+ gram = projected @ projected.T
101
+ scale2 = float((gram[0,0] + gram[1,1]) * 0.5)
102
+ tolerance = max(1.0, scale2) * 1e-5
103
+ if abs(float(gram[0,0]-gram[1,1])) > tolerance or abs(float(gram[0,1])) > tolerance:
104
+ return None, "nonrepresentable"
105
+ projected_area = math.pi * radius * radius * scale2
106
+ root_center = (local_matrix @ np.asarray([cx,cy,cz,1.0]))[:3]
107
+ y_radius = radius * float(np.linalg.norm(local_matrix[1,:3]))
108
+ root_points = np.asarray([
109
+ [root_center[0], root_center[1]-y_radius, root_center[2]],
110
+ [root_center[0], root_center[1]+y_radius, root_center[2]],
111
+ ])
112
+ definition = {"shape": "circle", "radius": radius}
113
+ analysis = {
114
+ "shape": "circle", "center": root_center[[0,2]].tolist(),
115
+ "radius": float(radius * math.sqrt(scale2)),
116
+ }
117
+ else:
118
+ return None, "unsupported_shape"
119
+ definition.update({
120
+ "source": source,
121
+ "local_matrix": np.round(local_matrix, 12).tolist(),
122
+ "local_transform": _decompose(local_matrix),
123
+ "center": {"x": cx, "y": cy, "z": cz},
124
+ "local_y_bounds": [float(root_points[:,1].min()), float(root_points[:,1].max())],
125
+ "projected_area_m2": projected_area,
126
+ "_analysis": analysis,
127
+ })
128
+ return definition, None
129
+
130
+
131
+ def _rectangle_corners(primitive: dict) -> list[np.ndarray]:
132
+ center=np.asarray(primitive["center"],dtype=np.float64)
133
+ axis_x=np.asarray(primitive["axis_x"],dtype=np.float64)*primitive["half_width"]
134
+ axis_z=np.asarray(primitive["axis_z"],dtype=np.float64)*primitive["half_height"]
135
+ return [center+sx*axis_x+sz*axis_z for sx,sz in ((-1,-1),(1,-1),(1,1),(-1,1))]
136
+
137
+
138
+ def _primitive_contains(outer: dict, inner: dict, tolerance: float = 1e-6) -> bool:
139
+ """Return whether one prefab-local circle/rectangle fully contains another."""
140
+ outer_center=np.asarray(outer["center"],dtype=np.float64)
141
+ inner_center=np.asarray(inner["center"],dtype=np.float64)
142
+ if outer["shape"] == "circle":
143
+ radius=float(outer["radius"])+tolerance
144
+ if inner["shape"] == "circle":
145
+ return float(np.linalg.norm(inner_center-outer_center))+float(inner["radius"]) <= radius
146
+ return all(float(np.linalg.norm(point-outer_center)) <= radius
147
+ for point in _rectangle_corners(inner))
148
+
149
+ axis_x=np.asarray(outer["axis_x"],dtype=np.float64)
150
+ axis_z=np.asarray(outer["axis_z"],dtype=np.float64)
151
+ half_width=float(outer["half_width"])+tolerance
152
+ half_height=float(outer["half_height"])+tolerance
153
+ if inner["shape"] == "circle":
154
+ offset=inner_center-outer_center
155
+ radius=float(inner["radius"])
156
+ return (abs(float(offset@axis_x))+radius <= half_width and
157
+ abs(float(offset@axis_z))+radius <= half_height)
158
+ for point in _rectangle_corners(inner):
159
+ offset=point-outer_center
160
+ if (abs(float(offset@axis_x)) > half_width or
161
+ abs(float(offset@axis_z)) > half_height):
162
+ return False
163
+ return True
164
+
165
+
166
+ def _remove_contained(zones: list[dict]) -> tuple[list[dict], int]:
167
+ ordered=sorted(zones,key=lambda item:(
168
+ -float(item["projected_area_m2"]), item["object_name"].casefold(), item["shape"],
169
+ json.dumps(item["local_matrix"],separators=(",",":")),
170
+ ))
171
+ retained=[]
172
+ removed=0
173
+ for zone in ordered:
174
+ if any(_primitive_contains(outer["_analysis"],zone["_analysis"])
175
+ for outer in retained):
176
+ removed+=1
177
+ continue
178
+ retained.append(zone)
179
+ for zone in retained:
180
+ zone.pop("_analysis",None)
181
+ zone.pop("object_name",None)
182
+ zone.pop("collider_flags",None)
183
+ zone.pop("local_transform",None)
184
+ return retained,removed
185
+
186
+
187
+ def _extract_worker(install_text: str, target_text: str, conn) -> None:
188
+ try:
189
+ import UnityPy
190
+ install = Path(install_text)
191
+ identity = bundle_identity(install)
192
+ environment = UnityPy.load(
193
+ str(install / "Bundles" / "shared" / "assetscenes.bundle"),
194
+ str(install / "Bundles" / "shared" / "content.bundle"),
195
+ )
196
+ scenes = []
197
+ for root in environment.files.values():
198
+ for name, asset_file in (getattr(root, "files", None) or {}).items():
199
+ if name.startswith("BuildPlayer-AssetScene-monument.") and not name.endswith("sharedAssets"):
200
+ scenes.append((name, asset_file))
201
+ prefab_zones: dict[str, list[dict]] = {}
202
+ excluded = {"contained_by_larger_same_owner_zone": 0,
203
+ "unsupported_shape": 0, "nonrepresentable": 0,
204
+ "deployable_only": 0,
205
+ "untagged_tunnel_upwards_internal": 0}
206
+ for scene_name, scene in sorted(scenes):
207
+ transform_memo: dict[int, np.ndarray] = {}
208
+ for obj in scene.objects.values():
209
+ if obj.type.name != "GameObject":
210
+ continue
211
+ try:
212
+ game_object = obj.read()
213
+ normalized_name = game_object.m_Name.casefold().replace(" ", "_")
214
+ named = any(part in normalized_name for part in BLOCKER_NAME_PARTS)
215
+ tagged = _has_prevent_tag(game_object)
216
+ if not named and not tagged:
217
+ continue
218
+ flags = _collider_flags(game_object)
219
+ if flags & ONLY_BLOCK_DEPLOYABLES:
220
+ excluded["deployable_only"] += 1
221
+ continue
222
+ root_go = _root_game_object(game_object)
223
+ path = root_go.m_Name.casefold().replace("\\", "/")
224
+ family=_autospawn_family(path)
225
+ if not path.startswith(AUTOSPAWN_PREFIX) or family not in SURFACE_FAMILIES:
226
+ continue
227
+ if not _include_surface_blocker(family,tagged):
228
+ excluded["untagged_tunnel_upwards_internal"] += sum(
229
+ item.component.type.name in {"BoxCollider","SphereCollider",
230
+ "CapsuleCollider","MeshCollider"}
231
+ for item in game_object.m_Component
232
+ )
233
+ continue
234
+ root_transform = _component(root_go, "Transform")
235
+ child_transform = _component(game_object, "Transform")
236
+ relative = np.linalg.inv(_world_matrix(root_transform, transform_memo)) @ \
237
+ _world_matrix(child_transform, transform_memo)
238
+ source = "prevent_building_monument_tag" if tagged else "block_placement"
239
+ for item in game_object.m_Component:
240
+ if item.component.type.name not in {
241
+ "BoxCollider", "SphereCollider", "CapsuleCollider", "MeshCollider"
242
+ }:
243
+ continue
244
+ definition, reason = _collider_definition(item.component, relative, source)
245
+ if definition is not None:
246
+ definition["object_name"] = game_object.m_Name
247
+ definition["collider_flags"] = flags
248
+ prefab_zones.setdefault(path, []).append(definition)
249
+ elif reason:
250
+ excluded[reason] += 1
251
+ except Exception:
252
+ continue
253
+ prefabs = []
254
+ for path, zones in sorted(prefab_zones.items()):
255
+ zones,removed=_remove_contained(zones)
256
+ excluded["contained_by_larger_same_owner_zone"]+=removed
257
+ if zones:
258
+ prefabs.append({"prefab_path": path, "zones": zones})
259
+ content = identity["bundles"]["content"]
260
+ payload = {
261
+ "schema_version": DATA_SCHEMA_VERSION,
262
+ "source": {
263
+ "rust_build_id": identity.get("rust_build_id"),
264
+ "content_bundle_size": content["size"],
265
+ "content_bundle_mtime_ns": content["mtime_ns"],
266
+ "asset_scene_count": len(scenes),
267
+ "selection": "prevent-building named/tagged colliders affecting building blocks",
268
+ "selection_strategy": "maximal_same_owner_containment",
269
+ "minimum_area_m2": None,
270
+ "allowed_shapes": ["circle", "rectangle"],
271
+ "included_surface_families": sorted(SURFACE_FAMILIES),
272
+ },
273
+ "prefab_count": len(prefabs),
274
+ "zone_definition_count": sum(len(item["zones"]) for item in prefabs),
275
+ "excluded_definition_counts": excluded,
276
+ "prefabs": prefabs,
277
+ }
278
+ target = Path(target_text)
279
+ target.parent.mkdir(parents=True, exist_ok=True)
280
+ target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n")
281
+ conn.send({"ok": True, "path": str(target), "prefab_count": len(prefabs),
282
+ "zone_count": payload["zone_definition_count"]})
283
+ except BaseException as exc:
284
+ conn.send({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
285
+ finally:
286
+ conn.close()
287
+
288
+
289
+ def refresh_no_build_zone_data(rust_install_path: str | Path,
290
+ output_path: str | Path | None = None) -> Path:
291
+ """Extract sanitized building-block exclusion geometry from local Rust assets."""
292
+ install = Path(rust_install_path).resolve()
293
+ target = Path(output_path) if output_path is not None else default_no_build_data()
294
+ context = mp.get_context("spawn")
295
+ parent, child = context.Pipe(duplex=False)
296
+ process = context.Process(target=_extract_worker, args=(str(install), str(target), child))
297
+ process.start()
298
+ child.close()
299
+ result = parent.recv()
300
+ process.join()
301
+ if not result.get("ok"):
302
+ raise RuntimeError(result.get("error", "No-build extraction failed"))
303
+ return Path(result["path"])
rustmap/parser.py ADDED
@@ -0,0 +1,297 @@
1
+ """Dependency-light parser for Facepunch Rust ``.map`` world files.
2
+
3
+ The only external dependency is ``lz4``. Protobuf wire decoding is implemented
4
+ locally so the parser does not need generated ``.proto`` classes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import struct
11
+ from collections import Counter
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Iterator
15
+
16
+ import lz4.block
17
+
18
+
19
+ class RustMapError(ValueError):
20
+ """Raised when a map is truncated, corrupt, or unsupported."""
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class Vector3:
25
+ x: float = 0.0
26
+ y: float = 0.0
27
+ z: float = 0.0
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class MapLayer:
32
+ name: str
33
+ data: memoryview
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class Prefab:
38
+ category: str = ""
39
+ prefab_id: int = 0
40
+ position: Vector3 | None = None
41
+ rotation: Vector3 | None = None
42
+ scale: Vector3 | None = None
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class MapPath:
47
+ name: str = ""
48
+ spline: bool = False
49
+ start: bool = False
50
+ end: bool = False
51
+ width: float = 0.0
52
+ inner_padding: float = 0.0
53
+ outer_padding: float = 0.0
54
+ inner_fade: float = 0.0
55
+ outer_fade: float = 0.0
56
+ random_scale: float = 0.0
57
+ mesh_offset: float = 0.0
58
+ terrain_offset: float = 0.0
59
+ splat: int = 0
60
+ topology: int = 0
61
+ nodes: list[Vector3] = field(default_factory=list)
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class RustMap:
66
+ serialization_version: int
67
+ timestamp: int
68
+ size: int
69
+ layers: list[MapLayer]
70
+ prefabs: list[Prefab]
71
+ paths: list[MapPath]
72
+ _protobuf_buffer: bytearray = field(repr=False)
73
+
74
+ def layer(self, name: str) -> MapLayer:
75
+ for layer in self.layers:
76
+ if layer.name.casefold() == name.casefold():
77
+ return layer
78
+ raise KeyError(name)
79
+
80
+ def summary(self) -> dict:
81
+ categories = Counter(p.category for p in self.prefabs)
82
+ prefab_ids = Counter(p.prefab_id for p in self.prefabs)
83
+ return {
84
+ "serialization_version": self.serialization_version,
85
+ "timestamp": self.timestamp,
86
+ "world_size": self.size,
87
+ "layers": [{"name": x.name, "bytes": len(x.data)} for x in self.layers],
88
+ "prefab_count": len(self.prefabs),
89
+ "path_count": len(self.paths),
90
+ "prefab_categories": dict(categories.most_common()),
91
+ "most_common_prefab_ids": [
92
+ {"id": prefab_id, "count": count}
93
+ for prefab_id, count in prefab_ids.most_common(100)
94
+ ],
95
+ }
96
+
97
+
98
+ def _read_varint(data: memoryview, offset: int, limit: int) -> tuple[int, int]:
99
+ result = 0
100
+ shift = 0
101
+ while offset < limit and shift < 70:
102
+ byte = data[offset]
103
+ offset += 1
104
+ result |= (byte & 0x7F) << shift
105
+ if not byte & 0x80:
106
+ return result, offset
107
+ shift += 7
108
+ raise RustMapError("Invalid or truncated varint")
109
+
110
+
111
+ def _fields(data: memoryview) -> Iterator[tuple[int, int, int | memoryview]]:
112
+ offset = 0
113
+ limit = len(data)
114
+ while offset < limit:
115
+ tag, offset = _read_varint(data, offset, limit)
116
+ field_number, wire_type = tag >> 3, tag & 7
117
+ if field_number == 0:
118
+ raise RustMapError("Invalid protobuf field number 0")
119
+ if wire_type == 0:
120
+ value, offset = _read_varint(data, offset, limit)
121
+ yield field_number, wire_type, value
122
+ elif wire_type == 1:
123
+ if offset + 8 > limit:
124
+ raise RustMapError("Truncated protobuf fixed64")
125
+ value = struct.unpack_from("<Q", data, offset)[0]
126
+ offset += 8
127
+ yield field_number, wire_type, value
128
+ elif wire_type == 2:
129
+ length, offset = _read_varint(data, offset, limit)
130
+ end = offset + length
131
+ if end > limit:
132
+ raise RustMapError("Truncated protobuf length-delimited field")
133
+ yield field_number, wire_type, data[offset:end]
134
+ offset = end
135
+ elif wire_type == 5:
136
+ if offset + 4 > limit:
137
+ raise RustMapError("Truncated protobuf fixed32")
138
+ value = struct.unpack_from("<I", data, offset)[0]
139
+ offset += 4
140
+ yield field_number, wire_type, value
141
+ else:
142
+ raise RustMapError(f"Unsupported protobuf wire type {wire_type}")
143
+
144
+
145
+ def _text(value: int | memoryview) -> str:
146
+ if not isinstance(value, memoryview):
147
+ raise RustMapError("Expected a protobuf string")
148
+ return bytes(value).decode("utf-8", errors="replace")
149
+
150
+
151
+ def _float32(value: int | memoryview) -> float:
152
+ if not isinstance(value, int):
153
+ raise RustMapError("Expected a protobuf fixed32")
154
+ return struct.unpack("<f", struct.pack("<I", value))[0]
155
+
156
+
157
+ def _vector(data: memoryview) -> Vector3:
158
+ result = Vector3()
159
+ for number, wire, value in _fields(data):
160
+ if wire != 5:
161
+ continue
162
+ if number == 1:
163
+ result.x = _float32(value)
164
+ elif number == 2:
165
+ result.y = _float32(value)
166
+ elif number == 3:
167
+ result.z = _float32(value)
168
+ return result
169
+
170
+
171
+ def _map_layer(data: memoryview) -> MapLayer:
172
+ name = ""
173
+ payload = memoryview(b"")
174
+ for number, wire, value in _fields(data):
175
+ if number == 1 and wire == 2:
176
+ name = _text(value)
177
+ elif number == 2 and wire == 2 and isinstance(value, memoryview):
178
+ payload = value
179
+ return MapLayer(name, payload)
180
+
181
+
182
+ def _prefab(data: memoryview) -> Prefab:
183
+ result = Prefab()
184
+ for number, wire, value in _fields(data):
185
+ if number == 1 and wire == 2:
186
+ result.category = _text(value)
187
+ elif number == 2 and wire == 0:
188
+ result.prefab_id = int(value)
189
+ elif wire == 2 and isinstance(value, memoryview):
190
+ if number == 3:
191
+ result.position = _vector(value)
192
+ elif number == 4:
193
+ result.rotation = _vector(value)
194
+ elif number == 5:
195
+ result.scale = _vector(value)
196
+ return result
197
+
198
+
199
+ def _path(data: memoryview) -> MapPath:
200
+ result = MapPath()
201
+ float_fields = {
202
+ 5: "width", 6: "inner_padding", 7: "outer_padding", 8: "inner_fade",
203
+ 9: "outer_fade", 10: "random_scale", 11: "mesh_offset", 12: "terrain_offset",
204
+ }
205
+ for number, wire, value in _fields(data):
206
+ if number == 1 and wire == 2:
207
+ result.name = _text(value)
208
+ elif number in (2, 3, 4) and wire == 0:
209
+ setattr(result, {2: "spline", 3: "start", 4: "end"}[number], bool(value))
210
+ elif number in float_fields and wire == 5:
211
+ setattr(result, float_fields[number], _float32(value))
212
+ elif number == 13 and wire == 0:
213
+ result.splat = int(value)
214
+ elif number == 14 and wire == 0:
215
+ result.topology = int(value)
216
+ elif number == 15 and wire == 2 and isinstance(value, memoryview):
217
+ result.nodes.append(_vector(value))
218
+ return result
219
+
220
+
221
+ def _read_stream_varint(stream, *, allow_eof: bool = False) -> int | None:
222
+ result = 0
223
+ shift = 0
224
+ for index in range(10):
225
+ raw = stream.read(1)
226
+ if not raw:
227
+ if allow_eof and index == 0:
228
+ return None
229
+ raise RustMapError("Truncated legacy LZ4 chunk header")
230
+ byte = raw[0]
231
+ result |= (byte & 0x7F) << shift
232
+ if not byte & 0x80:
233
+ return result
234
+ shift += 7
235
+ raise RustMapError("Invalid legacy LZ4 chunk varint")
236
+
237
+
238
+ def _decompress_legacy_lz4(stream) -> bytearray:
239
+ output = bytearray()
240
+ while True:
241
+ flags = _read_stream_varint(stream, allow_eof=True)
242
+ if flags is None:
243
+ break
244
+ original_length = _read_stream_varint(stream)
245
+ assert original_length is not None
246
+ is_compressed = bool(flags & 1)
247
+ if flags >> 2:
248
+ raise RustMapError("Multi-pass legacy LZ4 chunks are unsupported")
249
+ compressed_length = _read_stream_varint(stream) if is_compressed else original_length
250
+ assert compressed_length is not None
251
+ if compressed_length > original_length:
252
+ raise RustMapError("Legacy LZ4 chunk length is invalid")
253
+ block = stream.read(compressed_length)
254
+ if len(block) != compressed_length:
255
+ raise RustMapError("Truncated legacy LZ4 chunk")
256
+ if is_compressed:
257
+ decoded = lz4.block.decompress(block, uncompressed_size=original_length)
258
+ if len(decoded) != original_length:
259
+ raise RustMapError("Legacy LZ4 block decoded to the wrong size")
260
+ output.extend(decoded)
261
+ else:
262
+ output.extend(block)
263
+ return output
264
+
265
+
266
+ def load_map(path: str | Path) -> RustMap:
267
+ """Load and decode a Rust world map.
268
+
269
+ Layer byte arrays are zero-copy ``memoryview`` objects backed by the map's
270
+ decompressed protobuf buffer. Keep the returned ``RustMap`` alive while
271
+ using them.
272
+ """
273
+ path = Path(path)
274
+ with path.open("rb") as stream:
275
+ header = stream.read(12)
276
+ if len(header) != 12:
277
+ raise RustMapError("File is too short to be a Rust map")
278
+ version, timestamp = struct.unpack("<IQ", header)
279
+ protobuf_buffer = _decompress_legacy_lz4(stream)
280
+
281
+ view = memoryview(protobuf_buffer)
282
+ size = 0
283
+ layers: list[MapLayer] = []
284
+ prefabs: list[Prefab] = []
285
+ paths: list[MapPath] = []
286
+ for number, wire, value in _fields(view):
287
+ if number == 1 and wire == 0:
288
+ size = int(value)
289
+ elif wire == 2 and isinstance(value, memoryview):
290
+ if number == 2:
291
+ layers.append(_map_layer(value))
292
+ elif number == 3:
293
+ prefabs.append(_prefab(value))
294
+ elif number == 4:
295
+ paths.append(_path(value))
296
+ return RustMap(version, timestamp, size, layers, prefabs, paths, protobuf_buffer)
297
+
rustmap/png.py ADDED
@@ -0,0 +1,18 @@
1
+ """Shared PNG encoding with project provenance metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PIL import Image, PngImagePlugin
8
+
9
+
10
+ PROJECT_URL = "https://github.com/Cooperkit/Rustmap-Parser"
11
+ PNG_SOURCE_KEY = "Source"
12
+
13
+
14
+ def save_png(image: Image.Image, path: str | Path, **save_options: object) -> None:
15
+ """Save a PNG with a non-visible link back to this project's source."""
16
+ metadata = PngImagePlugin.PngInfo()
17
+ metadata.add_text(PNG_SOURCE_KEY, PROJECT_URL)
18
+ image.save(path, "PNG", pnginfo=metadata, **save_options)