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
rustmap/populations.py ADDED
@@ -0,0 +1,361 @@
1
+ """Extract and evaluate Rust server DensitySpawnPopulation filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from .layers import biome_grid, splat_grid, topology_grid
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class SpawnRule:
17
+ asset_path: str
18
+ name: str
19
+ class_name: str
20
+ active: bool
21
+ resource_folder: str
22
+ resources: list[str]
23
+ target_density: float
24
+ scale_with_large_maps: bool
25
+ scale_with_spawn_filter: bool
26
+ splat_mask: int
27
+ biome_mask: int
28
+ topology_any: int
29
+ topology_all: int
30
+ topology_not: int
31
+ filter_cutoff: float
32
+ filter_radius: float
33
+ filter_out_tutorial_islands: bool
34
+ filter_out_monuments: list[int]
35
+ align_to_normal: bool
36
+ npc_radius_check_distance: float
37
+ population_convar: str | None = None
38
+
39
+
40
+ def heatmap_categories(rules: list[SpawnRule]) -> dict[str, list[str]]:
41
+ """Map Rust+ Desktop's category keys to active population asset paths."""
42
+ exact = {
43
+ "ores": {"ores", "ores_sand", "ores_snow", "v3_ores_jungle"},
44
+ "wood": {"collectable-resource-wood"},
45
+ "mushroom": {"collectable-food-mushroom"},
46
+ "corn": {"plant-corn"}, "pumpkin": {"plant-pumpkin"},
47
+ "potato": {"plant-potato"}, "wheat": {"plant-wheat"},
48
+ "junkpiles": {"junkpiles", "junkpiles_water"},
49
+ "rowboat": {"rowboat.population"}, "modularcar": {"modularcar.population"},
50
+ "horse": {"ridablehorse.population"},
51
+ # Monument bikes use fixed spawn points, not a terrain distribution.
52
+ # Including their unrestricted default filter makes the raster solid.
53
+ "pedalbike": {"pedalbikes_world.population"},
54
+ "hab": {"hab.population"},
55
+ "hemp": {"collectable-resource-hemp", "jungle-collectable-resource-hemp"},
56
+ "bear": {"bear.population", "polarbear.population"},
57
+ "boar": {"boar.population"}, "chicken": {"chicken.population"},
58
+ "wolf": {"wolf.population"}, "stag": {"stag.population"},
59
+ "crocodile": {"crocodile.population"}, "tiger": {"tiger.population"},
60
+ "snake": {"snake.population"},
61
+ }
62
+ result = {key: [] for key in exact}
63
+ result.update({"logs": [], "berries": [], "flowers": []})
64
+ for rule in rules:
65
+ if not rule.active:
66
+ continue
67
+ name = rule.name.casefold()
68
+ for category, names in exact.items():
69
+ if name in names:
70
+ result[category].append(rule.asset_path)
71
+ if name.startswith("logs_") or name in {"driftwood", "wood_log_pile"}:
72
+ result["logs"].append(rule.asset_path)
73
+ if "plant-berry-" in name:
74
+ result["berries"].append(rule.asset_path)
75
+ if name in {"plant-rose", "plant-orchid", "plant-sunflower"}:
76
+ result["flowers"].append(rule.asset_path)
77
+ return {key: sorted(value) for key, value in sorted(result.items())}
78
+
79
+
80
+ def _u32(value: int) -> int:
81
+ return int(value) & 0xFFFFFFFF
82
+
83
+
84
+ def _ptr_id(value: Any) -> int:
85
+ if isinstance(value, dict):
86
+ return int(value.get("m_PathID", value.get("path_id", 0)))
87
+ return int(getattr(value, "path_id", 0))
88
+
89
+
90
+ def _script_classes(environment) -> dict[int, str]:
91
+ result = {}
92
+ for obj in environment.objects:
93
+ if obj.type.name == "MonoScript":
94
+ data = obj.read()
95
+ result[obj.path_id] = getattr(data, "m_ClassName", "")
96
+ return result
97
+
98
+
99
+ def _class_name(data, fallback: dict[int, str], obj=None) -> str:
100
+ """Resolve a script within its serialized file (path IDs repeat per file)."""
101
+ try:
102
+ return str(data.m_Script.get_obj().read().m_ClassName)
103
+ except Exception:
104
+ try:
105
+ script = obj.assets_file.objects[data.m_Script.path_id]
106
+ return str(script.read().m_ClassName)
107
+ except Exception:
108
+ return fallback.get(data.m_Script.path_id, "")
109
+
110
+
111
+ def _guid_paths(environment, classes: dict[int, str]) -> dict[str, str]:
112
+ for obj in environment.objects:
113
+ if obj.type.name != "MonoBehaviour":
114
+ continue
115
+ data = obj.read()
116
+ if _class_name(data, classes, obj) != "GameManifest":
117
+ continue
118
+ tree = obj.read_typetree()
119
+ # Current Rust stores parallel GUID/path arrays. Retain fallbacks for
120
+ # older manifest layouts.
121
+ guids = tree.get("guidList") or tree.get("guids") or []
122
+ paths = tree.get("guidPaths") or tree.get("paths") or []
123
+ if isinstance(paths, list) and paths and isinstance(paths[0], dict):
124
+ return {
125
+ str(x.get("guid", "")): str(x.get("path", x.get("name", "")))
126
+ for x in paths if x.get("guid")
127
+ }
128
+ if len(guids) == len(paths):
129
+ return {str(g): str(p) for g, p in zip(guids, paths)}
130
+ for item in tree.get("prefabProperties", []):
131
+ if item.get("guid") and item.get("name"):
132
+ guids.append(item["guid"]); paths.append(item["name"])
133
+ return {str(g): str(p) for g, p in zip(guids, paths)}
134
+ return {}
135
+
136
+
137
+ def _active_population_ids_local(maps_bundle: Path) -> tuple[set[int], dict]:
138
+ import UnityPy
139
+
140
+ env = UnityPy.load(str(maps_bundle))
141
+ classes = _script_classes(env)
142
+ candidates = []
143
+ # Locate SpawnHandler scripts first, then inspect only their serialized
144
+ # files. A full typetree scan of the 50k behaviours is unnecessarily large.
145
+ for script in env.objects:
146
+ if script.type.name != "MonoScript" or getattr(script.read(), "m_ClassName", "") != "SpawnHandler":
147
+ continue
148
+ for obj in script.assets_file.objects.values():
149
+ if obj.type.name != "MonoBehaviour":
150
+ continue
151
+ data = obj.read()
152
+ if data.m_Script.path_id != script.path_id:
153
+ continue
154
+ tree = obj.read_typetree()
155
+ regular = tree.get("SpawnPopulations", [])
156
+ convar = tree.get("ConvarSpawnPopulations", [])
157
+ refs = regular + convar
158
+ ids = {_ptr_id(x) for x in refs if _ptr_id(x)}
159
+ if ids:
160
+ candidates.append((len(ids), ids, obj.path_id, len(regular), len(convar)))
161
+ if not candidates:
162
+ raise RuntimeError("No SpawnHandler was found in maps.bundle")
163
+ count, ids, path_id, regular_count, convar_count = max(candidates, key=lambda x: x[0])
164
+ result = ids, {"handler_count": len(candidates), "selected_path_id": path_id,
165
+ "selected_population_count": count,
166
+ "regular_population_count": regular_count,
167
+ "convar_population_count": convar_count}
168
+ del env
169
+ return result
170
+
171
+
172
+ def _active_worker(path: str, connection) -> None:
173
+ try:
174
+ ids, metadata = _active_population_ids_local(Path(path))
175
+ connection.send((sorted(ids), metadata, None))
176
+ except Exception as exc:
177
+ connection.send(([], {}, repr(exc)))
178
+ finally:
179
+ connection.close()
180
+
181
+
182
+ def _active_population_ids(maps_bundle: Path) -> tuple[set[int], dict]:
183
+ """Read maps.bundle in a disposable process to release UnityPy's graph."""
184
+ import multiprocessing as mp
185
+ parent, child = mp.get_context("spawn").Pipe(False)
186
+ process = mp.get_context("spawn").Process(
187
+ target=_active_worker, args=(str(maps_bundle), child)
188
+ )
189
+ process.start()
190
+ ids, metadata, error = parent.recv()
191
+ process.join()
192
+ if error:
193
+ raise RuntimeError(f"SpawnHandler extraction failed: {error}")
194
+ return set(ids), metadata
195
+
196
+
197
+ def extract_spawn_rules(content_bundle: str | Path, maps_bundle: str | Path) -> dict:
198
+ """Extract all population assets and mark those in the main SpawnHandler."""
199
+ import UnityPy
200
+
201
+ content = Path(content_bundle).resolve()
202
+ maps = Path(maps_bundle).resolve()
203
+ active_ids, handler = _active_population_ids(maps)
204
+ env = UnityPy.load(str(content))
205
+ classes = _script_classes(env)
206
+ guid_paths = _guid_paths(env, classes)
207
+
208
+ populations = {
209
+ "DensitySpawnPopulation", "ConvarControlledSpawnPopulation",
210
+ "ConvarControlledSpawnPopulationRail", "SpawnPointSpawnPopulation",
211
+ "ConvarControlledSpawnPointPopulation",
212
+ }
213
+ rules = []
214
+ for asset_path, obj in env.container.items():
215
+ normalized_path = str(asset_path).casefold().replace("\\", "/")
216
+ if "/spawnpopulation/" not in normalized_path and not normalized_path.endswith(".population.asset"):
217
+ continue
218
+ if obj.type.name != "MonoBehaviour":
219
+ continue
220
+ data = obj.read()
221
+ class_name = _class_name(data, classes, obj)
222
+ if class_name not in populations:
223
+ continue
224
+ tree = obj.read_typetree()
225
+ filt = tree.get("Filter", {})
226
+ resources = []
227
+ for ref in tree.get("ResourceList", []):
228
+ guid = ref.get("guid", ref) if isinstance(ref, dict) else ref
229
+ resources.append(guid_paths.get(str(guid), str(guid)))
230
+ convar = tree.get("PopulationConvar")
231
+ if isinstance(convar, dict):
232
+ convar = convar.get("name") or convar.get("Value") or str(convar)
233
+ rules.append(SpawnRule(
234
+ asset_path=str(asset_path), name=str(tree.get("m_Name", Path(asset_path).stem)),
235
+ class_name=class_name, active=obj.path_id in active_ids,
236
+ resource_folder=str(tree.get("ResourceFolder", "")), resources=resources,
237
+ target_density=float(tree.get("_targetDensity", 0)),
238
+ scale_with_large_maps=bool(tree.get("ScaleWithLargeMaps", False)),
239
+ scale_with_spawn_filter=bool(tree.get("ScaleWithSpawnFilter", False)),
240
+ splat_mask=int(filt.get("SplatType", -1)), biome_mask=int(filt.get("BiomeType", -1)),
241
+ topology_any=_u32(filt.get("TopologyAny", -1)),
242
+ topology_all=_u32(filt.get("TopologyAll", -1)),
243
+ topology_not=_u32(filt.get("TopologyNot", 0)),
244
+ filter_cutoff=float(tree.get("FilterCutoff", 0)),
245
+ filter_radius=float(tree.get("FilterRadius", 0)),
246
+ filter_out_tutorial_islands=bool(tree.get("FilterOutTutorialIslands", False)),
247
+ filter_out_monuments=[int(x) for x in tree.get("FilterOutMonuments", [])],
248
+ align_to_normal=bool(tree.get("AlignToNormal", False)),
249
+ npc_radius_check_distance=float(tree.get("NpcRadiusCheckDistance", 0)),
250
+ population_convar=str(convar) if convar else None,
251
+ ))
252
+ rules.sort(key=lambda x: x.asset_path.casefold())
253
+ return {
254
+ "schema_version": 1,
255
+ "sources": {
256
+ "content_bundle": {"path": str(content), "size": content.stat().st_size,
257
+ "mtime_ns": content.stat().st_mtime_ns},
258
+ "maps_bundle": {"path": str(maps), "size": maps.stat().st_size,
259
+ "mtime_ns": maps.stat().st_mtime_ns},
260
+ },
261
+ "spawn_handler": handler,
262
+ "rule_count": len(rules), "active_rule_count": sum(r.active for r in rules),
263
+ "heatmap_categories": heatmap_categories(rules),
264
+ "rules": [asdict(r) for r in rules],
265
+ }
266
+
267
+
268
+ def save_rule_database(database: dict, path: str | Path) -> None:
269
+ target = Path(path); target.parent.mkdir(parents=True, exist_ok=True)
270
+ target.write_text(json.dumps(database, indent=2) + "\n", encoding="utf-8")
271
+
272
+
273
+ class SpawnFilterEvaluator:
274
+ """Reusable population evaluator with resolution-specific sampling caches."""
275
+
276
+ def __init__(self, world, resolution: int = 512):
277
+ self.resolution = resolution
278
+ self.source_splat = splat_grid(world)
279
+ source_biome = biome_grid(world)
280
+ source_topology = topology_grid(world)
281
+ self.norm = (np.arange(resolution, dtype=np.float32) + 0.5) / resolution
282
+ ix = np.minimum((self.norm * source_topology.shape[1]).astype(int), source_topology.shape[1] - 1)
283
+ iz = np.minimum((self.norm * source_topology.shape[0]).astype(int), source_topology.shape[0] - 1)
284
+ self.topology = source_topology[np.ix_(iz, ix)]
285
+ sampled_biome = source_biome[:, iz][:, :, ix]
286
+ # Server uses >=, so ties select the last/highest channel.
287
+ self.dominant_biome = sampled_biome.shape[0] - 1 - np.argmax(sampled_biome[::-1], axis=0)
288
+ self.biome_count = sampled_biome.shape[0]
289
+ self._splat_cache: dict[int, np.ndarray] = {}
290
+ # Bilinear sampling a 2048² splat channel is relatively expensive, and
291
+ # the rule database combines the same eight channels into many masks.
292
+ # Keep float64 terms because the original NumPy expression evaluates in
293
+ # float64 before each in-place float32 addition. Storing float32 here
294
+ # changes a small number of final uint8 values.
295
+ self._splat_channel_cache: dict[int, np.ndarray] = {}
296
+ self._evaluation_cache: dict[tuple[int, int, int, int, int, float], np.ndarray] = {}
297
+
298
+ source = self.source_splat
299
+ self._splat_pos = self.norm * (source.shape[2] - 1)
300
+ self._splat_x0 = np.floor(self._splat_pos).astype(int)
301
+ self._splat_x1 = np.minimum(self._splat_x0 + 1, source.shape[2] - 1)
302
+ self._splat_t = self._splat_pos - self._splat_x0
303
+
304
+ def _sample_splat_channel(self, channel: int) -> np.ndarray:
305
+ cached = self._splat_channel_cache.get(channel)
306
+ if cached is not None:
307
+ return cached
308
+ grid = self.source_splat[channel]
309
+ x0, x1, t = self._splat_x0, self._splat_x1, self._splat_t
310
+ a = grid[np.ix_(x0, x0)]
311
+ b = grid[np.ix_(x0, x1)]
312
+ c = grid[np.ix_(x1, x0)]
313
+ d = grid[np.ix_(x1, x1)]
314
+ sampled = ((a * (1-t)[None,:] + b * t[None,:]) * (1-t)[:,None]
315
+ + (c * (1-t)[None,:] + d * t[None,:]) * t[:,None]) / 255.0
316
+ self._splat_channel_cache[channel] = sampled
317
+ return sampled
318
+
319
+ def _splat_factor(self, mask: int) -> np.ndarray:
320
+ cached = self._splat_cache.get(mask)
321
+ if cached is not None:
322
+ return cached
323
+ value = np.zeros((self.resolution, self.resolution), dtype=np.float32)
324
+ for channel in range(self.source_splat.shape[0]):
325
+ if not (mask & (1 << channel)):
326
+ continue
327
+ value += self._sample_splat_channel(channel)
328
+ value = np.clip(value, 0, 1)
329
+ self._splat_cache[mask] = value
330
+ return value
331
+
332
+ def evaluate(self, rule: SpawnRule | dict) -> np.ndarray:
333
+ if isinstance(rule, dict):
334
+ rule = SpawnRule(**rule)
335
+ cache_key = (
336
+ _u32(rule.topology_any), _u32(rule.topology_all), _u32(rule.topology_not),
337
+ int(rule.biome_mask), int(rule.splat_mask), float(rule.filter_cutoff),
338
+ )
339
+ cached = self._evaluation_cache.get(cache_key)
340
+ if cached is not None:
341
+ return cached
342
+ topo = self.topology
343
+ factor = np.ones((self.resolution, self.resolution), dtype=np.float32)
344
+ any_mask, all_mask, not_mask = map(_u32, (rule.topology_any, rule.topology_all, rule.topology_not))
345
+ if any_mask != 0xFFFFFFFF: factor[(topo & any_mask) == 0] = 0
346
+ if all_mask != 0xFFFFFFFF: factor[(topo & all_mask) != all_mask] = 0
347
+ if not_mask: factor[(topo & not_mask) != 0] = 0
348
+
349
+ if rule.biome_mask != -1:
350
+ allowed = np.array([(rule.biome_mask & (1 << i)) != 0 for i in range(self.biome_count)])
351
+ factor[~allowed[self.dominant_biome]] = 0
352
+ if rule.splat_mask != -1:
353
+ factor *= self._splat_factor(rule.splat_mask)
354
+ result = np.where(factor > rule.filter_cutoff, factor * 255, 0).astype(np.uint8)
355
+ self._evaluation_cache[cache_key] = result
356
+ return result
357
+
358
+
359
+ def evaluate_filter(world, rule: SpawnRule | dict, resolution: int = 512) -> np.ndarray:
360
+ """Reproduce DensitySpawnPopulation.GetBaseMapValues (placement map excluded)."""
361
+ return SpawnFilterEvaluator(world, resolution).evaluate(rule)
rustmap/prefabs.py ADDED
@@ -0,0 +1,281 @@
1
+ """Extract Rust's GameManifest and resolve numeric map prefab IDs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections import Counter, defaultdict
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ from .parser import RustMap, RustMapError
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class PrefabManifestEntry:
16
+ prefab_id: int
17
+ path: str
18
+ guid: str | None = None
19
+ pooled: bool | None = None
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class PrefabResolution:
24
+ prefab_id: int
25
+ path: str | None
26
+ instance_count: int
27
+ map_categories: list[str]
28
+ classification: str
29
+ heatmap_relevance: str
30
+ guid: str | None = None
31
+ pooled: bool | None = None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class PrefabManifest:
36
+ entries: dict[int, PrefabManifestEntry]
37
+ collisions: dict[int, list[str]]
38
+ source_bundle: str
39
+ source_size: int
40
+ source_mtime_ns: int
41
+
42
+ def get(self, prefab_id: int) -> PrefabManifestEntry | None:
43
+ return self.entries.get(prefab_id)
44
+
45
+ def save(self, path: str | Path) -> None:
46
+ target = Path(path)
47
+ target.parent.mkdir(parents=True, exist_ok=True)
48
+ payload = {
49
+ "source_bundle": self.source_bundle,
50
+ "source_size": self.source_size,
51
+ "source_mtime_ns": self.source_mtime_ns,
52
+ "entry_count": len(self.entries),
53
+ "entries": {
54
+ str(key): asdict(value) for key, value in sorted(self.entries.items())
55
+ },
56
+ "collisions": {str(k): v for k, v in sorted(self.collisions.items())},
57
+ }
58
+ target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
59
+
60
+ @classmethod
61
+ def load(cls, path: str | Path) -> "PrefabManifest":
62
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
63
+ entries = {
64
+ int(key): PrefabManifestEntry(**value)
65
+ for key, value in payload["entries"].items()
66
+ }
67
+ return cls(
68
+ entries=entries,
69
+ collisions={int(k): v for k, v in payload.get("collisions", {}).items()},
70
+ source_bundle=payload["source_bundle"],
71
+ source_size=int(payload["source_size"]),
72
+ source_mtime_ns=int(payload["source_mtime_ns"]),
73
+ )
74
+
75
+
76
+ def find_content_bundle(rust_install: str | Path) -> Path:
77
+ root = Path(rust_install)
78
+ candidates = (
79
+ root / "Bundles" / "shared" / "content.bundle",
80
+ root / "shared" / "content.bundle",
81
+ root if root.name.casefold() == "content.bundle" else None,
82
+ )
83
+ for candidate in candidates:
84
+ if candidate is not None and candidate.is_file():
85
+ return candidate
86
+ raise FileNotFoundError(f"Could not find Bundles/shared/content.bundle below {root}")
87
+
88
+
89
+ def extract_game_manifest(content_bundle: str | Path) -> PrefabManifest:
90
+ """Extract the authoritative ID/path table from Rust's GameManifest asset."""
91
+ try:
92
+ import UnityPy
93
+ except ImportError as exc:
94
+ raise RuntimeError("UnityPy is required to extract the installed GameManifest") from exc
95
+
96
+ bundle = Path(content_bundle).resolve()
97
+ stat = bundle.stat()
98
+ environment = UnityPy.load(str(bundle))
99
+
100
+ game_manifest_script_id: int | None = None
101
+ for obj in environment.objects:
102
+ if obj.type.name != "MonoScript":
103
+ continue
104
+ script = obj.read()
105
+ if getattr(script, "m_ClassName", "") == "GameManifest":
106
+ game_manifest_script_id = obj.path_id
107
+ break
108
+ if game_manifest_script_id is None:
109
+ raise RustMapError("GameManifest MonoScript was not found in content.bundle")
110
+
111
+ tree = None
112
+ for obj in environment.objects:
113
+ if obj.type.name != "MonoBehaviour":
114
+ continue
115
+ behaviour = obj.read()
116
+ if behaviour.m_Script.path_id == game_manifest_script_id:
117
+ tree = obj.read_typetree()
118
+ break
119
+ if tree is None:
120
+ raise RustMapError("GameManifest data object was not found in content.bundle")
121
+
122
+ properties_by_path: dict[str, dict] = {
123
+ item["name"].casefold(): item for item in tree.get("prefabProperties", [])
124
+ }
125
+ entries: dict[int, PrefabManifestEntry] = {}
126
+ all_paths: dict[int, list[str]] = defaultdict(list)
127
+ for item in tree.get("pooledStrings", []):
128
+ prefab_id = int(item["hash"])
129
+ asset_path = item["str"]
130
+ all_paths[prefab_id].append(asset_path)
131
+ prop = properties_by_path.get(asset_path.casefold())
132
+ entries[prefab_id] = PrefabManifestEntry(
133
+ prefab_id=prefab_id,
134
+ path=asset_path,
135
+ guid=prop.get("guid") if prop else None,
136
+ pooled=bool(prop.get("pool")) if prop else None,
137
+ )
138
+
139
+ collisions = {
140
+ prefab_id: sorted(set(paths))
141
+ for prefab_id, paths in all_paths.items()
142
+ if len(set(paths)) > 1
143
+ }
144
+ return PrefabManifest(
145
+ entries=entries,
146
+ collisions=collisions,
147
+ source_bundle=str(bundle),
148
+ source_size=stat.st_size,
149
+ source_mtime_ns=stat.st_mtime_ns,
150
+ )
151
+
152
+
153
+ def load_or_extract_manifest(
154
+ rust_install: str | Path, cache_path: str | Path | None = None
155
+ ) -> PrefabManifest:
156
+ bundle = find_content_bundle(rust_install)
157
+ stat = bundle.stat()
158
+ if cache_path is not None and Path(cache_path).is_file():
159
+ cached = PrefabManifest.load(cache_path)
160
+ if cached.source_size == stat.st_size and cached.source_mtime_ns == stat.st_mtime_ns:
161
+ return cached
162
+ manifest = extract_game_manifest(bundle)
163
+ if cache_path is not None:
164
+ manifest.save(cache_path)
165
+ return manifest
166
+
167
+
168
+ def classify_prefab(path: str | None, map_categories: list[str]) -> tuple[str, str]:
169
+ if path is None:
170
+ return "unresolved", "unknown"
171
+ value = path.casefold().replace("\\", "/")
172
+ category_text = " ".join(map_categories).casefold()
173
+
174
+ if "/monument/" in value or "monument" in category_text:
175
+ return "monument", "spawn exclusion and orientation reference"
176
+ if any(token in value for token in (
177
+ "rock_formation", "/cliff", "cliff_", "coastal_rock", "shore_ice",
178
+ "iceberg", "ice_sheet", "rockformation",
179
+ )):
180
+ return "terrain_geometry", "slope/ore environment and spawn exclusion"
181
+ if any(token in category_text for token in ("road", "powerline")) or any(
182
+ token in value for token in ("/road", "roadside", "powerline")
183
+ ):
184
+ return "transport_corridor", "vehicle and junkpile spawn environment"
185
+ if "river" in category_text or any(
186
+ token in value for token in ("/river", "riversound", "waterfall", "water_")
187
+ ):
188
+ return "water_feature", "water/river spawn environment"
189
+ if "dungeon" in category_text or "/dungeon" in value:
190
+ return "dungeon", "spawn exclusion and underground geometry"
191
+ if any(token in value for token in (
192
+ "/resource/", "/collectable/", "/animals/", "/plant/", "tree",
193
+ "bush", "foliage", "vine",
194
+ )):
195
+ return "natural_resource", "possible direct resource or vegetation feature"
196
+ if "decor" in category_text or "/decor/" in value:
197
+ return "decor", "possible spawn environment or exclusion geometry"
198
+ return "other", "not yet assigned"
199
+
200
+
201
+ def resolve_world_prefabs(
202
+ world: RustMap, manifest: PrefabManifest
203
+ ) -> list[PrefabResolution]:
204
+ counts = Counter(item.prefab_id for item in world.prefabs)
205
+ categories: dict[int, set[str]] = defaultdict(set)
206
+ for item in world.prefabs:
207
+ categories[item.prefab_id].add(item.category)
208
+
209
+ result: list[PrefabResolution] = []
210
+ for prefab_id, count in counts.most_common():
211
+ entry = manifest.get(prefab_id)
212
+ map_categories = sorted(categories[prefab_id])
213
+ classification, relevance = classify_prefab(
214
+ entry.path if entry else None, map_categories
215
+ )
216
+ result.append(PrefabResolution(
217
+ prefab_id=prefab_id,
218
+ path=entry.path if entry else None,
219
+ instance_count=count,
220
+ map_categories=map_categories,
221
+ classification=classification,
222
+ heatmap_relevance=relevance,
223
+ guid=entry.guid if entry else None,
224
+ pooled=entry.pooled if entry else None,
225
+ ))
226
+ return result
227
+
228
+
229
+ def write_resolution_reports(
230
+ world: RustMap,
231
+ manifest: PrefabManifest,
232
+ output_dir: str | Path,
233
+ ) -> dict:
234
+ output = Path(output_dir)
235
+ output.mkdir(parents=True, exist_ok=True)
236
+ resolutions = resolve_world_prefabs(world, manifest)
237
+ resolved = [item for item in resolutions if item.path is not None]
238
+ unresolved = [item for item in resolutions if item.path is None]
239
+ classification_instances = Counter()
240
+ for item in resolutions:
241
+ classification_instances[item.classification] += item.instance_count
242
+
243
+ report = {
244
+ "manifest": {
245
+ "source_bundle": manifest.source_bundle,
246
+ "source_size": manifest.source_size,
247
+ "entry_count": len(manifest.entries),
248
+ "hash_collision_count": len(manifest.collisions),
249
+ },
250
+ "map": {
251
+ "world_size": world.size,
252
+ "prefab_instances": len(world.prefabs),
253
+ "unique_prefab_ids": len(resolutions),
254
+ },
255
+ "coverage": {
256
+ "resolved_unique_ids": len(resolved),
257
+ "unresolved_unique_ids": len(unresolved),
258
+ "resolved_instances": sum(x.instance_count for x in resolved),
259
+ "unresolved_instances": sum(x.instance_count for x in unresolved),
260
+ },
261
+ "classification_instance_counts": dict(classification_instances.most_common()),
262
+ "unresolved": [asdict(item) for item in unresolved],
263
+ "resolved": [asdict(item) for item in resolutions],
264
+ }
265
+ (output / "phase2_resolution.json").write_text(
266
+ json.dumps(report, indent=2) + "\n", encoding="utf-8"
267
+ )
268
+
269
+ with (output / "resolved_prefabs.csv").open("w", newline="", encoding="utf-8") as stream:
270
+ writer = csv.writer(stream)
271
+ writer.writerow((
272
+ "prefab_id", "instance_count", "map_categories", "classification",
273
+ "heatmap_relevance", "path", "guid", "pooled",
274
+ ))
275
+ for item in resolutions:
276
+ writer.writerow((
277
+ item.prefab_id, item.instance_count, ";".join(item.map_categories),
278
+ item.classification, item.heatmap_relevance, item.path or "",
279
+ item.guid or "", "" if item.pooled is None else item.pooled,
280
+ ))
281
+ return report