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.
- rust_map_parser-0.1.0.dist-info/METADATA +538 -0
- rust_map_parser-0.1.0.dist-info/RECORD +110 -0
- rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
- rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
- rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
- rustmap/__init__.py +44 -0
- rustmap/config.py +236 -0
- rustmap/data/__init__.py +1 -0
- rustmap/data/monument_metadata.json +954 -0
- rustmap/data/no_build_zones.json +9543 -0
- rustmap/data/prefab_manifest.json +242637 -0
- rustmap/data/spawn_rules.json +3308 -0
- rustmap/data/tunnel_tiles/__init__.py +1 -0
- rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
- rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
- rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
- rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
- rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
- rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
- rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
- rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
- rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
- rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
- rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
- rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
- rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
- rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
- rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
- rustmap/data/tunnel_tiles/tiles.json +997 -0
- rustmap/exporter.py +409 -0
- rustmap/layers.py +323 -0
- rustmap/monument_assets.py +145 -0
- rustmap/monuments.py +226 -0
- rustmap/no_build.py +288 -0
- rustmap/no_build_assets.py +303 -0
- rustmap/parser.py +297 -0
- rustmap/png.py +18 -0
- rustmap/populations.py +361 -0
- rustmap/prefabs.py +281 -0
- rustmap/renderer.py +460 -0
- rustmap/resources.py +26 -0
- rustmap/tunnel_assets.py +398 -0
- rustmap/tunnels.py +417 -0
- rustmap/validation.py +106 -0
rustmap/layers.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Typed raster decoders and diagnostics for parsed Rust maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image, ImageDraw
|
|
11
|
+
|
|
12
|
+
from .parser import RustMap, RustMapError
|
|
13
|
+
from .png import save_png
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SPLAT_CHANNELS = (
|
|
17
|
+
"dirt", "snow", "sand", "rock", "grass", "forest", "stones", "gravel"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# The first four indices are defined by Facepunch's Rust.World SDK. Jungle was
|
|
21
|
+
# added to the game in May 2025 and is the fifth channel in current maps.
|
|
22
|
+
BIOME_CHANNELS = ("arid", "temperate", "tundra", "arctic", "jungle")
|
|
23
|
+
|
|
24
|
+
TOPOLOGY_BITS = (
|
|
25
|
+
"field", "cliff", "summit", "beachside", "beach", "forest",
|
|
26
|
+
"forestside", "ocean", "oceanside", "decor", "monument", "road",
|
|
27
|
+
"roadside", "swamp", "river", "riverside", "lake", "lakeside",
|
|
28
|
+
"offshore", "powerline", "runway", "building", "cliffside",
|
|
29
|
+
"mountain", "clutter", "alt", "tier0", "tier1", "tier2", "mainland",
|
|
30
|
+
"hilltop",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SHORT_NORMALIZER = 32766.0
|
|
34
|
+
TERRAIN_VERTICAL_SIZE = 1000.0
|
|
35
|
+
TERRAIN_VERTICAL_OFFSET = -500.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _square_resolution(element_count: int, layer_name: str) -> int:
|
|
39
|
+
resolution = math.isqrt(element_count)
|
|
40
|
+
if resolution * resolution != element_count:
|
|
41
|
+
raise RustMapError(
|
|
42
|
+
f"Layer {layer_name!r} has {element_count} elements, not a square grid"
|
|
43
|
+
)
|
|
44
|
+
return resolution
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def int16_grid(world: RustMap, name: str) -> np.ndarray:
|
|
48
|
+
"""Decode a signed-16-bit, single-channel `[z, x]` layer."""
|
|
49
|
+
layer = world.layer(name)
|
|
50
|
+
if len(layer.data) % 2:
|
|
51
|
+
raise RustMapError(f"Layer {name!r} has an odd byte count")
|
|
52
|
+
source = np.frombuffer(layer.data, dtype="<i2")
|
|
53
|
+
resolution = _square_resolution(source.size, name)
|
|
54
|
+
return source.reshape(resolution, resolution)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalized_int16_grid(world: RustMap, name: str) -> np.ndarray:
|
|
58
|
+
return int16_grid(world, name).astype(np.float32) / SHORT_NORMALIZER
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def world_height_grid(world: RustMap, name: str = "height") -> np.ndarray:
|
|
62
|
+
"""Convert a Rust normalized height layer to Unity world-space Y metres."""
|
|
63
|
+
return (
|
|
64
|
+
normalized_int16_grid(world, name) * TERRAIN_VERTICAL_SIZE
|
|
65
|
+
+ TERRAIN_VERTICAL_OFFSET
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def uint8_grid(world: RustMap, name: str) -> np.ndarray:
|
|
70
|
+
layer = world.layer(name)
|
|
71
|
+
source = np.frombuffer(layer.data, dtype=np.uint8)
|
|
72
|
+
resolution = _square_resolution(source.size, name)
|
|
73
|
+
return source.reshape(resolution, resolution)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def topology_grid(world: RustMap) -> np.ndarray:
|
|
77
|
+
"""Decode topology as an unsigned 32-bit `[z, x]` bitmask."""
|
|
78
|
+
layer = world.layer("topology")
|
|
79
|
+
if len(layer.data) % 4:
|
|
80
|
+
raise RustMapError("Topology byte count is not divisible by four")
|
|
81
|
+
source = np.frombuffer(layer.data, dtype="<u4")
|
|
82
|
+
resolution = _square_resolution(source.size, "topology")
|
|
83
|
+
return source.reshape(resolution, resolution)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _texture_resolution(world: RustMap) -> int:
|
|
87
|
+
"""Use alpha/topology to obtain the current map's texture resolution."""
|
|
88
|
+
try:
|
|
89
|
+
return uint8_grid(world, "alpha").shape[0]
|
|
90
|
+
except KeyError:
|
|
91
|
+
return topology_grid(world).shape[0]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def channel_grid(world: RustMap, name: str, channel_names: tuple[str, ...]) -> np.ndarray:
|
|
95
|
+
"""Decode a channel-major byte layer as `[channel, z, x]`."""
|
|
96
|
+
resolution = _texture_resolution(world)
|
|
97
|
+
source = np.frombuffer(world.layer(name).data, dtype=np.uint8)
|
|
98
|
+
pixels = resolution * resolution
|
|
99
|
+
if source.size % pixels:
|
|
100
|
+
raise RustMapError(
|
|
101
|
+
f"Layer {name!r} does not contain whole {resolution}x{resolution} channels"
|
|
102
|
+
)
|
|
103
|
+
channels = source.size // pixels
|
|
104
|
+
if channels > len(channel_names):
|
|
105
|
+
raise RustMapError(
|
|
106
|
+
f"Layer {name!r} contains {channels} channels but only "
|
|
107
|
+
f"{len(channel_names)} names are known"
|
|
108
|
+
)
|
|
109
|
+
return source.reshape(channels, resolution, resolution)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def splat_grid(world: RustMap) -> np.ndarray:
|
|
113
|
+
return channel_grid(world, "splat", SPLAT_CHANNELS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def biome_grid(world: RustMap) -> np.ndarray:
|
|
117
|
+
return channel_grid(world, "biome", BIOME_CHANNELS)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def topology_mask(world: RustMap, name_or_bit: str | int) -> np.ndarray:
|
|
121
|
+
bit = TOPOLOGY_BITS.index(name_or_bit) if isinstance(name_or_bit, str) else name_or_bit
|
|
122
|
+
if not 0 <= bit < 32:
|
|
123
|
+
raise ValueError(f"Invalid topology bit: {bit}")
|
|
124
|
+
return (topology_grid(world) & np.uint32(1 << bit)) != 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def world_to_grid(
|
|
128
|
+
world: RustMap, world_x: float, world_z: float, resolution: int
|
|
129
|
+
) -> tuple[float, float]:
|
|
130
|
+
"""Convert centered Unity `(x,z)` metres to fractional `(grid_x,grid_z)`."""
|
|
131
|
+
scale = resolution - 1
|
|
132
|
+
return (
|
|
133
|
+
(world_x / world.size + 0.5) * scale,
|
|
134
|
+
(world_z / world.size + 0.5) * scale,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def grid_to_world(
|
|
139
|
+
world: RustMap, grid_x: float, grid_z: float, resolution: int
|
|
140
|
+
) -> tuple[float, float]:
|
|
141
|
+
scale = resolution - 1
|
|
142
|
+
return (
|
|
143
|
+
(grid_x / scale - 0.5) * world.size,
|
|
144
|
+
(grid_z / scale - 0.5) * world.size,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sample_bilinear(grid: np.ndarray, grid_x: float, grid_z: float) -> float:
|
|
149
|
+
if grid.ndim != 2:
|
|
150
|
+
raise ValueError("Bilinear sampling requires a two-dimensional grid")
|
|
151
|
+
x = float(np.clip(grid_x, 0, grid.shape[1] - 1))
|
|
152
|
+
z = float(np.clip(grid_z, 0, grid.shape[0] - 1))
|
|
153
|
+
x0, z0 = int(math.floor(x)), int(math.floor(z))
|
|
154
|
+
x1, z1 = min(x0 + 1, grid.shape[1] - 1), min(z0 + 1, grid.shape[0] - 1)
|
|
155
|
+
tx, tz = x - x0, z - z0
|
|
156
|
+
return float(
|
|
157
|
+
grid[z0, x0] * (1 - tx) * (1 - tz)
|
|
158
|
+
+ grid[z0, x1] * tx * (1 - tz)
|
|
159
|
+
+ grid[z1, x0] * (1 - tx) * tz
|
|
160
|
+
+ grid[z1, x1] * tx * tz
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def sample_world_height(
|
|
165
|
+
world: RustMap, world_x: float, world_z: float, name: str = "height"
|
|
166
|
+
) -> float:
|
|
167
|
+
grid = world_height_grid(world, name)
|
|
168
|
+
x, z = world_to_grid(world, world_x, world_z, grid.shape[0])
|
|
169
|
+
return sample_bilinear(grid, x, z)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _save_gray(path: Path, values: np.ndarray) -> None:
|
|
173
|
+
# Rust's decoded Z origin is opposite the image/viewer Y origin. Preserve
|
|
174
|
+
# X (left/right) and reverse rows only; analytical arrays stay unchanged.
|
|
175
|
+
exported = np.asarray(values, dtype=np.uint8)[::-1, :]
|
|
176
|
+
save_png(Image.fromarray(exported, mode="L"), path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _height_preview(values: np.ndarray) -> np.ndarray:
|
|
180
|
+
# The SDK defines the full vertical range as -500..+500 metres.
|
|
181
|
+
normalized = np.clip((values - TERRAIN_VERTICAL_OFFSET) / TERRAIN_VERTICAL_SIZE, 0, 1)
|
|
182
|
+
return np.rint(normalized * 255).astype(np.uint8)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _dominant_color(channels: np.ndarray, colors: np.ndarray) -> np.ndarray:
|
|
186
|
+
weights = channels.astype(np.float32) / 255.0
|
|
187
|
+
rgb = np.einsum("czx,ck->zxk", weights, colors.astype(np.float32))
|
|
188
|
+
return np.clip(rgb, 0, 255).astype(np.uint8)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def validate_orientation(world: RustMap) -> dict:
|
|
192
|
+
"""Check the documented raw `[z,x]` orientation against prefab elevations."""
|
|
193
|
+
heights = world_height_grid(world, "height")
|
|
194
|
+
errors: list[float] = []
|
|
195
|
+
samples = 0
|
|
196
|
+
for prefab in world.prefabs:
|
|
197
|
+
if prefab.position is None:
|
|
198
|
+
continue
|
|
199
|
+
x, z = world_to_grid(world, prefab.position.x, prefab.position.z, heights.shape[0])
|
|
200
|
+
if not (0 <= x < heights.shape[1] and 0 <= z < heights.shape[0]):
|
|
201
|
+
continue
|
|
202
|
+
predicted = sample_bilinear(heights, x, z)
|
|
203
|
+
errors.append(abs(predicted - prefab.position.y))
|
|
204
|
+
samples += 1
|
|
205
|
+
values = np.asarray(errors, dtype=np.float64)
|
|
206
|
+
return {
|
|
207
|
+
"method": "raw grid indexed as [z,x], centered world coordinates, no axis flip",
|
|
208
|
+
"sample_count": samples,
|
|
209
|
+
"median_absolute_height_error_m": float(np.median(values)),
|
|
210
|
+
"p75_absolute_height_error_m": float(np.percentile(values, 75)),
|
|
211
|
+
"mean_absolute_height_error_m": float(np.mean(values)),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def generate_diagnostics(world: RustMap, output_dir: str | Path) -> dict:
|
|
216
|
+
"""Write diagnostic PNGs and return their machine-readable statistics."""
|
|
217
|
+
output = Path(output_dir)
|
|
218
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
stats: dict = {
|
|
221
|
+
"world_size": world.size,
|
|
222
|
+
"orientation_validation": validate_orientation(world),
|
|
223
|
+
"layers": {},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for name in ("terrain", "height", "water"):
|
|
227
|
+
raw = int16_grid(world, name)
|
|
228
|
+
metres = world_height_grid(world, name)
|
|
229
|
+
_save_gray(output / f"{name}.png", _height_preview(metres))
|
|
230
|
+
stats["layers"][name] = {
|
|
231
|
+
"shape": list(raw.shape), "dtype": "int16-le",
|
|
232
|
+
"raw_min": int(raw.min()), "raw_max": int(raw.max()),
|
|
233
|
+
"world_y_min_m": float(metres.min()), "world_y_max_m": float(metres.max()),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
alpha = uint8_grid(world, "alpha")
|
|
237
|
+
_save_gray(output / "alpha.png", alpha)
|
|
238
|
+
stats["layers"]["alpha"] = {
|
|
239
|
+
"shape": list(alpha.shape), "dtype": "uint8",
|
|
240
|
+
"min": int(alpha.min()), "max": int(alpha.max()),
|
|
241
|
+
"visible_pixels": int(np.count_nonzero(alpha)),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
splat = splat_grid(world)
|
|
245
|
+
splat_stats = {}
|
|
246
|
+
for index, name in enumerate(SPLAT_CHANNELS[: splat.shape[0]]):
|
|
247
|
+
_save_gray(output / f"splat_{index}_{name}.png", splat[index])
|
|
248
|
+
splat_stats[name] = {
|
|
249
|
+
"min": int(splat[index].min()), "max": int(splat[index].max()),
|
|
250
|
+
"nonzero_pixels": int(np.count_nonzero(splat[index])),
|
|
251
|
+
}
|
|
252
|
+
splat_colors = np.array([
|
|
253
|
+
[105, 72, 45], [225, 238, 245], [218, 190, 120], [95, 95, 95],
|
|
254
|
+
[92, 145, 65], [45, 95, 48], [125, 120, 110], [145, 135, 120],
|
|
255
|
+
])
|
|
256
|
+
save_png(
|
|
257
|
+
Image.fromarray(_dominant_color(splat, splat_colors[: splat.shape[0]])[::-1, :], mode="RGB"),
|
|
258
|
+
output / "splat_composite.png",
|
|
259
|
+
)
|
|
260
|
+
stats["layers"]["splat"] = {
|
|
261
|
+
"shape": list(splat.shape), "dtype": "uint8-channel-major",
|
|
262
|
+
"pixel_sum_min": int(splat.astype(np.uint16).sum(axis=0).min()),
|
|
263
|
+
"pixel_sum_max": int(splat.astype(np.uint16).sum(axis=0).max()),
|
|
264
|
+
"channels": splat_stats,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
biome = biome_grid(world)
|
|
268
|
+
biome_stats = {}
|
|
269
|
+
for index, name in enumerate(BIOME_CHANNELS[: biome.shape[0]]):
|
|
270
|
+
_save_gray(output / f"biome_{index}_{name}.png", biome[index])
|
|
271
|
+
biome_stats[name] = {
|
|
272
|
+
"min": int(biome[index].min()), "max": int(biome[index].max()),
|
|
273
|
+
"nonzero_pixels": int(np.count_nonzero(biome[index])),
|
|
274
|
+
}
|
|
275
|
+
biome_colors = np.array([
|
|
276
|
+
[210, 135, 60], [90, 155, 75], [105, 125, 95], [220, 235, 245], [35, 120, 58]
|
|
277
|
+
])
|
|
278
|
+
save_png(
|
|
279
|
+
Image.fromarray(_dominant_color(biome, biome_colors[: biome.shape[0]])[::-1, :], mode="RGB"),
|
|
280
|
+
output / "biome_composite.png",
|
|
281
|
+
)
|
|
282
|
+
stats["layers"]["biome"] = {
|
|
283
|
+
"shape": list(biome.shape), "dtype": "uint8-channel-major",
|
|
284
|
+
"pixel_sum_min": int(biome.astype(np.uint16).sum(axis=0).min()),
|
|
285
|
+
"pixel_sum_max": int(biome.astype(np.uint16).sum(axis=0).max()),
|
|
286
|
+
"channels": biome_stats,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
topology = topology_grid(world)
|
|
290
|
+
topology_stats = {}
|
|
291
|
+
for bit, name in enumerate(TOPOLOGY_BITS):
|
|
292
|
+
mask = (topology & np.uint32(1 << bit)) != 0
|
|
293
|
+
_save_gray(output / f"topology_{bit:02d}_{name}.png", mask.astype(np.uint8) * 255)
|
|
294
|
+
topology_stats[name] = {"bit": bit, "set_pixels": int(np.count_nonzero(mask))}
|
|
295
|
+
stats["layers"]["topology"] = {
|
|
296
|
+
"shape": list(topology.shape), "dtype": "uint32-le-bitmask",
|
|
297
|
+
"channels": topology_stats,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Overlay path nodes and monument prefab positions on the height map. This
|
|
301
|
+
# uses the exact same raw [z,x] transform verified by elevation sampling.
|
|
302
|
+
preview = Image.fromarray(_height_preview(world_height_grid(world, "height")), mode="L").convert("RGB")
|
|
303
|
+
draw = ImageDraw.Draw(preview)
|
|
304
|
+
resolution = preview.width
|
|
305
|
+
for path in world.paths:
|
|
306
|
+
points = [world_to_grid(world, node.x, node.z, resolution) for node in path.nodes]
|
|
307
|
+
if len(points) >= 2:
|
|
308
|
+
draw.line(points, fill=(255, 80, 40), width=max(1, resolution // 1024))
|
|
309
|
+
radius = max(2, resolution // 512)
|
|
310
|
+
for prefab in world.prefabs:
|
|
311
|
+
if prefab.category.casefold() != "monument" or prefab.position is None:
|
|
312
|
+
continue
|
|
313
|
+
x, z = world_to_grid(world, prefab.position.x, prefab.position.z, resolution)
|
|
314
|
+
draw.ellipse((x - radius, z - radius, x + radius, z + radius), fill=(40, 190, 255))
|
|
315
|
+
save_png(
|
|
316
|
+
preview.transpose(Image.Transpose.FLIP_TOP_BOTTOM),
|
|
317
|
+
output / "orientation_paths_monuments.png",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
(output / "diagnostics.json").write_text(
|
|
321
|
+
json.dumps(stats, indent=2) + "\n", encoding="utf-8"
|
|
322
|
+
)
|
|
323
|
+
return stats
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Maintainer extraction of sanitized gameplay metadata from monument prefabs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .no_build_assets import _class_name, _root_game_object
|
|
10
|
+
from .tunnel_assets import bundle_identity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
PREFIX = "assets/bundled/prefabs/autospawn/monument/"
|
|
15
|
+
NAME_FILTERS = (
|
|
16
|
+
"recycler", "cardreader", "card_reader", "keycard", "puzzle", "loot",
|
|
17
|
+
"crate", "safezone", "safe_zone", "vending",
|
|
18
|
+
)
|
|
19
|
+
ACCESS_LEVELS = {1: "green", 2: "blue", 3: "red"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _loot_tier(name: str) -> int:
|
|
23
|
+
normalized = name.casefold().replace(" ", "_")
|
|
24
|
+
if "elite" in normalized or "tier3" in normalized or "tier_3" in normalized:
|
|
25
|
+
return 3
|
|
26
|
+
if "tier2" in normalized or "tier_2" in normalized:
|
|
27
|
+
return 2
|
|
28
|
+
if ("tier1" in normalized or "tier_1" in normalized or "low" in normalized or
|
|
29
|
+
"crate_spawner" in normalized or "spawner_normal_crates" in normalized):
|
|
30
|
+
return 1
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_monument_metadata(rust_install_path: str | Path) -> dict:
|
|
35
|
+
"""Extract compact gameplay facts; UnityPy is a maintainer-only dependency."""
|
|
36
|
+
import UnityPy
|
|
37
|
+
|
|
38
|
+
install = Path(rust_install_path)
|
|
39
|
+
environment = UnityPy.load(
|
|
40
|
+
str(install / "Bundles" / "shared" / "assetscenes.bundle"),
|
|
41
|
+
str(install / "Bundles" / "shared" / "content.bundle"),
|
|
42
|
+
)
|
|
43
|
+
scenes = []
|
|
44
|
+
for root in environment.files.values():
|
|
45
|
+
for name, asset_file in (getattr(root, "files", None) or {}).items():
|
|
46
|
+
if name.startswith("BuildPlayer-AssetScene-monument.") and not name.endswith("sharedAssets"):
|
|
47
|
+
scenes.append(asset_file)
|
|
48
|
+
|
|
49
|
+
facts: dict[str, dict] = defaultdict(lambda: {
|
|
50
|
+
"recycler_count": 0,
|
|
51
|
+
"keycard_reader_counts": Counter(),
|
|
52
|
+
"puzzle_reset_count": 0,
|
|
53
|
+
"safe_zone": False,
|
|
54
|
+
"vending_machine_count": 0,
|
|
55
|
+
"loot_spawner_count": 0,
|
|
56
|
+
"loot_tier": 0,
|
|
57
|
+
})
|
|
58
|
+
for scene in scenes:
|
|
59
|
+
for obj in scene.objects.values():
|
|
60
|
+
if obj.type.name != "GameObject":
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
game_object = obj.read()
|
|
64
|
+
object_name = game_object.m_Name
|
|
65
|
+
lowered = object_name.casefold()
|
|
66
|
+
if not any(part in lowered for part in NAME_FILTERS):
|
|
67
|
+
continue
|
|
68
|
+
root = _root_game_object(game_object)
|
|
69
|
+
path = root.m_Name.casefold().replace("\\", "/")
|
|
70
|
+
if not path.startswith(PREFIX):
|
|
71
|
+
continue
|
|
72
|
+
item = facts[path]
|
|
73
|
+
possible_tier = _loot_tier(object_name)
|
|
74
|
+
if possible_tier:
|
|
75
|
+
item["loot_spawner_count"] += 1
|
|
76
|
+
item["loot_tier"] = max(item["loot_tier"], possible_tier)
|
|
77
|
+
for component in game_object.m_Component:
|
|
78
|
+
class_name = _class_name(component)
|
|
79
|
+
if class_name == "Recycler":
|
|
80
|
+
item["recycler_count"] += 1
|
|
81
|
+
elif class_name == "CardReader":
|
|
82
|
+
level = int(component.component.read_typetree().get("accessLevel", 0))
|
|
83
|
+
color = ACCESS_LEVELS.get(level)
|
|
84
|
+
if color:
|
|
85
|
+
item["keycard_reader_counts"][color] += 1
|
|
86
|
+
elif class_name == "PuzzleReset":
|
|
87
|
+
item["puzzle_reset_count"] += 1
|
|
88
|
+
elif class_name == "TriggerSafeZone":
|
|
89
|
+
item["safe_zone"] = True
|
|
90
|
+
elif class_name == "NPCVendingMachine":
|
|
91
|
+
item["vending_machine_count"] += 1
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
prefabs = {}
|
|
96
|
+
for path, item in sorted(facts.items()):
|
|
97
|
+
card_counts = {name: int(item["keycard_reader_counts"].get(name, 0))
|
|
98
|
+
for name in ("green", "blue", "red")}
|
|
99
|
+
cards = [name for name, count in card_counts.items() if count]
|
|
100
|
+
has_cards = bool(cards)
|
|
101
|
+
has_reset = item["puzzle_reset_count"] > 0
|
|
102
|
+
if has_cards and has_reset:
|
|
103
|
+
puzzle_type = "keycard_and_electrical"
|
|
104
|
+
elif has_cards:
|
|
105
|
+
puzzle_type = "keycard"
|
|
106
|
+
elif has_reset:
|
|
107
|
+
puzzle_type = "electrical"
|
|
108
|
+
else:
|
|
109
|
+
puzzle_type = "none"
|
|
110
|
+
prefabs[path] = {
|
|
111
|
+
"recycler_count": int(item["recycler_count"]),
|
|
112
|
+
"keycard_requirements": cards,
|
|
113
|
+
"keycard_reader_counts": card_counts,
|
|
114
|
+
"puzzle_type": puzzle_type,
|
|
115
|
+
"puzzle_reset_count": int(item["puzzle_reset_count"]),
|
|
116
|
+
"loot_tier": int(item["loot_tier"]),
|
|
117
|
+
"loot_spawner_count": int(item["loot_spawner_count"]),
|
|
118
|
+
"safe_zone": bool(item["safe_zone"]),
|
|
119
|
+
"vending_machine_count": int(item["vending_machine_count"]),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
identity = bundle_identity(install)
|
|
123
|
+
for bundle in identity.get("bundles", {}).values():
|
|
124
|
+
bundle.pop("path", None)
|
|
125
|
+
return {
|
|
126
|
+
"schema_version": SCHEMA_VERSION,
|
|
127
|
+
"source": identity,
|
|
128
|
+
"extraction": {
|
|
129
|
+
"asset_scene_count": len(scenes),
|
|
130
|
+
"method": "prefab component and named loot-spawner scan",
|
|
131
|
+
"keycard_access_levels": ACCESS_LEVELS,
|
|
132
|
+
"loot_tier_scale": {"0": "none detected", "1": "low/normal", "2": "tier 2", "3": "tier 3/elite"},
|
|
133
|
+
},
|
|
134
|
+
"prefab_count": len(prefabs),
|
|
135
|
+
"prefabs": prefabs,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def refresh_monument_metadata(rust_install_path: str | Path,
|
|
140
|
+
output_path: str | Path | None = None) -> Path:
|
|
141
|
+
target = Path(output_path) if output_path is not None else Path(__file__).with_name("data") / "monument_metadata.json"
|
|
142
|
+
payload = extract_monument_metadata(rust_install_path)
|
|
143
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
145
|
+
return target
|