replicar 2.0.4__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.
- replicar-2.0.4/PKG-INFO +34 -0
- replicar-2.0.4/README.md +15 -0
- replicar-2.0.4/pyproject.toml +24 -0
- replicar-2.0.4/setup.cfg +4 -0
- replicar-2.0.4/src/replicar/__init__.py +283 -0
- replicar-2.0.4/src/replicar/py.typed +0 -0
- replicar-2.0.4/src/replicar/rlgym.py +136 -0
- replicar-2.0.4/src/replicar/rocketsim.py +209 -0
- replicar-2.0.4/src/replicar.egg-info/PKG-INFO +34 -0
- replicar-2.0.4/src/replicar.egg-info/SOURCES.txt +13 -0
- replicar-2.0.4/src/replicar.egg-info/dependency_links.txt +1 -0
- replicar-2.0.4/src/replicar.egg-info/requires.txt +15 -0
- replicar-2.0.4/src/replicar.egg-info/top_level.txt +1 -0
- replicar-2.0.4/tests/test_read.py +129 -0
- replicar-2.0.4/tests/test_rocketsim.py +77 -0
replicar-2.0.4/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replicar
|
|
3
|
+
Version: 2.0.4
|
|
4
|
+
Summary: Read replicar files: Rocket League replays reconstructed as RocketSim states
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Requires-Dist: pyarrow>=15
|
|
10
|
+
Provides-Extra: convert
|
|
11
|
+
Requires-Dist: replicar-native==2.0.4; extra == "convert"
|
|
12
|
+
Provides-Extra: rocketsim
|
|
13
|
+
Requires-Dist: rocketsim>=2.1.1; extra == "rocketsim"
|
|
14
|
+
Provides-Extra: rlgym
|
|
15
|
+
Requires-Dist: rlgym-rocket-league>=2.0.1; extra == "rlgym"
|
|
16
|
+
Requires-Dist: rocketsim>=2.1.1; extra == "rlgym"
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
19
|
+
|
|
20
|
+
# replicar (Python reader)
|
|
21
|
+
|
|
22
|
+
Reads replicar files: Rocket League replays reconstructed as RocketSim states, one Parquet file per replay. Reading
|
|
23
|
+
needs only pyarrow and NumPy; the files are written by the `replicar` command (`crates/replicar-cli`).
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import replicar
|
|
27
|
+
|
|
28
|
+
f = replicar.read("match.parquet")
|
|
29
|
+
f.header["players"] # name, team, hitbox of each player index
|
|
30
|
+
a = f.arrays() # a["car_position"]: (frames, players, 3) float32, NaN when unknown
|
|
31
|
+
f.records("ball_contacts") # one row per contact, with its frame
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The words are defined in `docs/glossary.md`. Tests: `PYTHONPATH=src python -m pytest tests` (Python 3.12 or newer).
|
replicar-2.0.4/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# replicar (Python reader)
|
|
2
|
+
|
|
3
|
+
Reads replicar files: Rocket League replays reconstructed as RocketSim states, one Parquet file per replay. Reading
|
|
4
|
+
needs only pyarrow and NumPy; the files are written by the `replicar` command (`crates/replicar-cli`).
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import replicar
|
|
8
|
+
|
|
9
|
+
f = replicar.read("match.parquet")
|
|
10
|
+
f.header["players"] # name, team, hitbox of each player index
|
|
11
|
+
a = f.arrays() # a["car_position"]: (frames, players, 3) float32, NaN when unknown
|
|
12
|
+
f.records("ball_contacts") # one row per contact, with its frame
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The words are defined in `docs/glossary.md`. Tests: `PYTHONPATH=src python -m pytest tests` (Python 3.12 or newer).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "replicar"
|
|
7
|
+
version = "2.0.4"
|
|
8
|
+
description = "Read replicar files: Rocket League replays reconstructed as RocketSim states"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["numpy>=1.26", "pyarrow>=15"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
convert = ["replicar-native==2.0.4"]
|
|
16
|
+
rocketsim = ["rocketsim>=2.1.1"]
|
|
17
|
+
rlgym = ["rlgym-rocket-league>=2.0.1", "rocketsim>=2.1.1"]
|
|
18
|
+
test = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
replicar = ["py.typed"]
|
replicar-2.0.4/setup.cfg
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Read replicar files: Rocket League replays reconstructed as RocketSim states, one Parquet file per replay.
|
|
2
|
+
|
|
3
|
+
A replicar file is an ordinary Parquet file; this package only adds conveniences (docs/glossary.md):
|
|
4
|
+
|
|
5
|
+
>>> import replicar
|
|
6
|
+
>>> f = replicar.read("match.parquet")
|
|
7
|
+
>>> f.header["players"] # who is at each player index
|
|
8
|
+
>>> a = f.arrays() # NumPy: a["car_position"] has shape (rows, players, 3)
|
|
9
|
+
>>> f.records("events") # a pyarrow table with one row per event and its frame
|
|
10
|
+
|
|
11
|
+
Floats are float32 with NaN for unknown values; integers use -1 for unknown; quantized columns are decoded with
|
|
12
|
+
their scale. Reading needs only pyarrow and NumPy.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pyarrow as pa
|
|
26
|
+
import pyarrow.compute as pc
|
|
27
|
+
import pyarrow.parquet as pq
|
|
28
|
+
|
|
29
|
+
__all__ = ["FORMAT_VERSION", "ReplicarFile", "convert", "convert_many", "iter_frames", "read", "resimulate"]
|
|
30
|
+
|
|
31
|
+
#: The newest file format this reader reads.
|
|
32
|
+
FORMAT_VERSION = 1
|
|
33
|
+
|
|
34
|
+
_PLAYER = re.compile(r"^(car|player|network_car|network_player)_(\d+)_(.+)$")
|
|
35
|
+
_PAD = re.compile(r"^pad_(\d+)_(.+)$")
|
|
36
|
+
_COMPONENTS = ("x", "y", "z", "w")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _values(column: pa.ChunkedArray, field: pa.Field) -> np.ndarray:
|
|
40
|
+
"""A column as NumPy: float32 with NaN, integers with -1, booleans (int8 with -1 when any is null),
|
|
41
|
+
names as strings ('' for null); integers with a `scale` decoded to float32."""
|
|
42
|
+
array = column.combine_chunks()
|
|
43
|
+
metadata = field.metadata or {}
|
|
44
|
+
kind = field.type
|
|
45
|
+
if b"scale" in metadata:
|
|
46
|
+
scale = float(metadata[b"scale"])
|
|
47
|
+
values = array.to_numpy(zero_copy_only=False).astype(np.float64)
|
|
48
|
+
if array.null_count:
|
|
49
|
+
values[np.asarray(array.is_null())] = np.nan
|
|
50
|
+
return (values * scale).astype(np.float32)
|
|
51
|
+
if pa.types.is_dictionary(kind):
|
|
52
|
+
array = array.cast(pa.string())
|
|
53
|
+
kind = pa.string()
|
|
54
|
+
if pa.types.is_floating(kind):
|
|
55
|
+
return array.to_numpy(zero_copy_only=False).astype(np.float32)
|
|
56
|
+
if pa.types.is_integer(kind):
|
|
57
|
+
return pc.fill_null(array.cast(pa.int64()), -1).to_numpy(zero_copy_only=False)
|
|
58
|
+
if pa.types.is_boolean(kind):
|
|
59
|
+
if array.null_count:
|
|
60
|
+
return pc.fill_null(array.cast(pa.int8()), -1).to_numpy(zero_copy_only=False)
|
|
61
|
+
return array.to_numpy(zero_copy_only=False)
|
|
62
|
+
if pa.types.is_string(kind):
|
|
63
|
+
return np.array(pc.fill_null(array, "").to_pylist(), dtype=object)
|
|
64
|
+
raise TypeError(f"{field.name}: {kind} is not a plain column; use records()")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ReplicarFile:
|
|
69
|
+
"""An opened replicar file: its header and its columns."""
|
|
70
|
+
|
|
71
|
+
path: Path
|
|
72
|
+
header: dict[str, Any]
|
|
73
|
+
table: pa.Table
|
|
74
|
+
_arrays: dict[str, np.ndarray] | None = field(default=None, repr=False)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def players(self) -> list[dict[str, Any]]:
|
|
78
|
+
return self.header["players"]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def groups(self) -> list[str]:
|
|
82
|
+
return self.header["groups"]
|
|
83
|
+
|
|
84
|
+
def arrays(self) -> dict[str, np.ndarray]:
|
|
85
|
+
"""Every plain column as a NumPy array, with the components of a vector stacked last (`ball_position`:
|
|
86
|
+
(rows, 3)), per-player columns stacked by player index (`car_position`: (rows, players, 3)) and
|
|
87
|
+
per-pad columns by pad index (`pad_cooldown`: (rows, pads)). Record lists are left to `records`."""
|
|
88
|
+
if self._arrays is not None:
|
|
89
|
+
return self._arrays
|
|
90
|
+
columns: dict[str, np.ndarray] = {}
|
|
91
|
+
for name, column in zip(self.table.column_names, self.table.columns):
|
|
92
|
+
field = self.table.schema.field(name)
|
|
93
|
+
if pa.types.is_list(field.type) or pa.types.is_struct(field.type):
|
|
94
|
+
continue
|
|
95
|
+
columns[name] = _values(column, field)
|
|
96
|
+
# Per player or pad: car_0_position_x -> car_position_x at index 0.
|
|
97
|
+
indexed: dict[str, dict[int, np.ndarray]] = {}
|
|
98
|
+
plain: dict[str, np.ndarray] = {}
|
|
99
|
+
for name, values in columns.items():
|
|
100
|
+
match = _PLAYER.match(name)
|
|
101
|
+
if match:
|
|
102
|
+
indexed.setdefault(f"{match[1]}_{match[3]}", {})[int(match[2])] = values
|
|
103
|
+
continue
|
|
104
|
+
match = _PAD.match(name)
|
|
105
|
+
if match:
|
|
106
|
+
indexed.setdefault(f"pad_{match[2]}", {})[int(match[1])] = values
|
|
107
|
+
continue
|
|
108
|
+
plain[name] = values
|
|
109
|
+
rows = self.table.num_rows
|
|
110
|
+
for name, by_index in indexed.items():
|
|
111
|
+
count = max(by_index) + 1
|
|
112
|
+
first = next(iter(by_index.values()))
|
|
113
|
+
stacked = np.full((rows, count), np.nan if first.dtype.kind == "f" else -1, dtype=first.dtype)
|
|
114
|
+
for index, values in by_index.items():
|
|
115
|
+
stacked[:, index] = values
|
|
116
|
+
plain[name] = stacked
|
|
117
|
+
self._arrays = _stack_components(plain)
|
|
118
|
+
return self._arrays
|
|
119
|
+
|
|
120
|
+
def players_table(self) -> pa.Table:
|
|
121
|
+
"""The header's players as a table: index, name, team (0 blue, 1 orange), hitbox, body, key, and one
|
|
122
|
+
column per final statistic (null for a statistic the header's `counted_stats` does not list: unknown)."""
|
|
123
|
+
players = self.header["players"]
|
|
124
|
+
stats = sorted({k for p in players for k in p.get("final_stats", {})})
|
|
125
|
+
columns = {
|
|
126
|
+
"player": pa.array([p["index"] for p in players], pa.uint8()),
|
|
127
|
+
"name": pa.array([p.get("name") for p in players], pa.string()),
|
|
128
|
+
"team": pa.array([p["team"] for p in players], pa.uint8()),
|
|
129
|
+
"hitbox": pa.array([p["hitbox"] for p in players], pa.string()),
|
|
130
|
+
"body_product_id": pa.array([p.get("body_product_id") for p in players], pa.uint32()),
|
|
131
|
+
"key": pa.array([p["key"] for p in players], pa.string()),
|
|
132
|
+
}
|
|
133
|
+
for stat in stats:
|
|
134
|
+
columns[f"final_{stat}"] = pa.array([p.get("final_stats", {}).get(stat) for p in players], pa.int32())
|
|
135
|
+
return pa.table(columns)
|
|
136
|
+
|
|
137
|
+
def long(self, prefix: str = "car") -> pa.Table:
|
|
138
|
+
"""The per-player columns of `prefix` (`car`, `player`, `network_car`, `network_player`) in long form: one
|
|
139
|
+
row per file row and player, with the row's `frame` (and `sim_tick` and `frame_row` when the file has them),
|
|
140
|
+
`player` and the columns without their index (`car_0_boost` -> `car_boost`), for pandas and SQL. Players
|
|
141
|
+
absent from a row have null values there."""
|
|
142
|
+
by_name: dict[str, dict[int, str]] = {}
|
|
143
|
+
for name in self.table.column_names:
|
|
144
|
+
match = _PLAYER.match(name)
|
|
145
|
+
if match and match[1] == prefix:
|
|
146
|
+
by_name.setdefault(f"{prefix}_{match[3]}", {})[int(match[2])] = name
|
|
147
|
+
players = sorted({i for indexed in by_name.values() for i in indexed})
|
|
148
|
+
rows = self.table.num_rows
|
|
149
|
+
keys = {k: self.table.column(k).combine_chunks() for k in ("frame", "sim_tick", "frame_row")
|
|
150
|
+
if k in self.table.column_names}
|
|
151
|
+
order = pa.array(np.arange(rows, dtype=np.int64))
|
|
152
|
+
pieces = []
|
|
153
|
+
for player in players:
|
|
154
|
+
columns = {**keys, "player": pa.array(np.full(rows, player, np.uint8)), "_row": order}
|
|
155
|
+
for base, indexed in by_name.items():
|
|
156
|
+
if player in indexed:
|
|
157
|
+
columns[base] = self.table.column(indexed[player]).combine_chunks()
|
|
158
|
+
else:
|
|
159
|
+
first = self.table.column(next(iter(indexed.values()))).type
|
|
160
|
+
columns[base] = pa.nulls(rows, first)
|
|
161
|
+
pieces.append(pa.table(columns))
|
|
162
|
+
if not pieces:
|
|
163
|
+
return pa.table({"frame": pa.array([], pa.uint32()), "player": pa.array([], pa.uint8())})
|
|
164
|
+
return pa.concat_tables(pieces).sort_by([("_row", "ascending"), ("player", "ascending")]).drop_columns("_row")
|
|
165
|
+
|
|
166
|
+
def records(self, name: str) -> pa.Table:
|
|
167
|
+
"""A record-list column (`events`, `ball_contacts`, `boost_pickups`, `prediction_errors`, ...) as one row
|
|
168
|
+
per record, with the `frame` (and `sim_tick`) of its row."""
|
|
169
|
+
column = self.table.column(name).combine_chunks()
|
|
170
|
+
lengths = pc.list_value_length(column).fill_null(0).to_numpy(zero_copy_only=False)
|
|
171
|
+
frames = np.repeat(self.table.column("frame").to_numpy(), lengths)
|
|
172
|
+
flat = pc.list_flatten(column)
|
|
173
|
+
fields = {"frame": pa.array(frames, pa.uint32())}
|
|
174
|
+
if "sim_tick" in self.table.column_names:
|
|
175
|
+
fields["sim_tick"] = pa.array(np.repeat(self.table.column("sim_tick").to_numpy(), lengths), pa.uint64())
|
|
176
|
+
for i, child in enumerate(flat.type):
|
|
177
|
+
fields[child.name] = flat.field(i)
|
|
178
|
+
return pa.table(fields)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _stack_components(columns: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
|
|
182
|
+
"""`<name>_x`, `_y`, `_z` (and `_w`) of the same shape and type become one array `<name>` with the
|
|
183
|
+
components last; a lone `_x` stays as it is."""
|
|
184
|
+
out: dict[str, np.ndarray] = {}
|
|
185
|
+
used: set[str] = set()
|
|
186
|
+
for name in columns:
|
|
187
|
+
if name in used or not name.endswith("_x"):
|
|
188
|
+
continue
|
|
189
|
+
base = name[:-2]
|
|
190
|
+
parts = []
|
|
191
|
+
for component in _COMPONENTS:
|
|
192
|
+
part = columns.get(f"{base}_{component}")
|
|
193
|
+
if part is None or part.shape != columns[name].shape or part.dtype != columns[name].dtype:
|
|
194
|
+
break
|
|
195
|
+
parts.append(f"{base}_{component}")
|
|
196
|
+
if len(parts) >= 3 and base not in columns:
|
|
197
|
+
out[base] = np.stack([columns[p] for p in parts], axis=-1)
|
|
198
|
+
used.update(parts)
|
|
199
|
+
for name, values in columns.items():
|
|
200
|
+
if name not in used:
|
|
201
|
+
out[name] = values
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read(path: str | Path, columns: list[str] | None = None, replay: str | Path | None = None) -> ReplicarFile:
|
|
206
|
+
"""Open a replicar file (all columns, or the named ones). `replay` is for a file without the `state` group,
|
|
207
|
+
which must be resimulated: that needs the native extra (`pip install replicar[convert]`)."""
|
|
208
|
+
path = Path(path)
|
|
209
|
+
metadata = pq.read_metadata(path).metadata or {}
|
|
210
|
+
if b"replicar" not in metadata:
|
|
211
|
+
raise ValueError(f"{path} has no replicar header: not a replicar file")
|
|
212
|
+
header = json.loads(metadata[b"replicar"])
|
|
213
|
+
if header.get("format_version", 0) > FORMAT_VERSION:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"{path} is format version {header['format_version']}; this reader reads {FORMAT_VERSION} and earlier"
|
|
216
|
+
)
|
|
217
|
+
if replay is not None and "state" not in header.get("groups", []):
|
|
218
|
+
# Resimulate the states and read them with the file's own groups.
|
|
219
|
+
native = _native("reading a file without the state group")
|
|
220
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
221
|
+
full = Path(directory) / "full.parquet"
|
|
222
|
+
groups = [g for g in header.get("groups", []) if g != "resimulation"] + ["state"]
|
|
223
|
+
native.resimulate(str(path), str(replay), str(full), precision=header.get("precision", "float32"),
|
|
224
|
+
groups=groups, all_frames=header.get("all_frames", False),
|
|
225
|
+
rows=header.get("rows", "frames"), tick_step=header.get("tick_step", 1))
|
|
226
|
+
opened = read(full, columns)
|
|
227
|
+
opened.table = opened.table.combine_chunks()
|
|
228
|
+
return ReplicarFile(path=path, header={**opened.header, "groups": header.get("groups", [])},
|
|
229
|
+
table=opened.table)
|
|
230
|
+
if columns is not None and "frame" not in columns:
|
|
231
|
+
columns = ["frame", *columns]
|
|
232
|
+
table = pq.read_table(path, columns=columns)
|
|
233
|
+
return ReplicarFile(path=path, header=header, table=table)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _native(purpose: str):
|
|
237
|
+
try:
|
|
238
|
+
import replicar_native
|
|
239
|
+
except ImportError as error:
|
|
240
|
+
raise ImportError(f"{purpose} needs the native extra: pip install replicar[convert]") from error
|
|
241
|
+
return replicar_native
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def iter_frames(path: str | Path, replay: str | Path | None = None, batch_frames: int = 4096):
|
|
245
|
+
"""The file's rows in pyarrow record batches of `batch_frames` (resimulating a file without states when
|
|
246
|
+
`replay` is given)."""
|
|
247
|
+
if replay is not None:
|
|
248
|
+
opened = read(path, replay=replay)
|
|
249
|
+
yield from opened.table.to_batches(max_chunksize=batch_frames)
|
|
250
|
+
return
|
|
251
|
+
yield from pq.ParquetFile(path).iter_batches(batch_size=batch_frames)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def convert(replay: str | Path, output: str | Path, *, precision: str = "float32", groups: list[str] | None = None,
|
|
255
|
+
with_groups: list[str] | None = None, all_frames: bool = False, rows: str = "ticks", tick_step: int = 1,
|
|
256
|
+
meshes: str | Path | None = None) -> None:
|
|
257
|
+
"""Convert a replay to a replicar file (needs the native extra): a row per simulated tick in play (`rows="ticks"`,
|
|
258
|
+
every `tick_step`-th) or per replay frame (`rows="frames"`). `meshes`: RocketSim's collision meshes, else
|
|
259
|
+
$REPLICAR_MESHES, else ./collision_meshes."""
|
|
260
|
+
_native("converting").convert(str(replay), str(output), precision=precision, groups=groups,
|
|
261
|
+
with_groups=with_groups, all_frames=all_frames, rows=rows, tick_step=tick_step,
|
|
262
|
+
meshes_dir=None if meshes is None else str(meshes))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def convert_many(replays: list[str | Path], output_dir: str | Path, *, jobs: int | None = None,
|
|
266
|
+
skip_existing: bool = False, precision: str = "float32", groups: list[str] | None = None,
|
|
267
|
+
with_groups: list[str] | None = None, all_frames: bool = False, rows: str = "ticks",
|
|
268
|
+
tick_step: int = 1, meshes: str | Path | None = None) -> list[dict[str, Any]]:
|
|
269
|
+
"""Convert replays to `output_dir/<name>.parquet`, `jobs` at a time, and write `output_dir/index.parquet`.
|
|
270
|
+
Returns one dict per replay; a failed one has its `error`."""
|
|
271
|
+
return _native("converting").convert_many(
|
|
272
|
+
[str(r) for r in replays], str(output_dir), jobs=jobs, skip_existing=skip_existing, precision=precision,
|
|
273
|
+
groups=groups, with_groups=with_groups, all_frames=all_frames, rows=rows, tick_step=tick_step,
|
|
274
|
+
meshes_dir=None if meshes is None else str(meshes))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def resimulate(file: str | Path, replay: str | Path, output: str | Path, *, precision: str = "float32",
|
|
278
|
+
groups: list[str] | None = None, with_groups: list[str] | None = None, all_frames: bool = False,
|
|
279
|
+
rows: str = "ticks", tick_step: int = 1, meshes: str | Path | None = None) -> None:
|
|
280
|
+
"""Rebuild a file from its replay and its `resimulation` group, without fitting (needs the native extra)."""
|
|
281
|
+
_native("resimulating").resimulate(str(file), str(replay), str(output), precision=precision, groups=groups,
|
|
282
|
+
with_groups=with_groups, all_frames=all_frames, rows=rows, tick_step=tick_step,
|
|
283
|
+
meshes_dir=None if meshes is None else str(meshes))
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""A replicar row as an RLGym `GameState` (rlgym 2, `rlgym.rocket_league`), for `RocketSimEngine.set_state` or a
|
|
2
|
+
state mutator: an RLGym environment that starts from any replay frame.
|
|
3
|
+
|
|
4
|
+
import replicar, replicar.rlgym
|
|
5
|
+
f = replicar.read("match.parquet")
|
|
6
|
+
state = replicar.rlgym.game_state(f, row=1200) # cars keyed by player index
|
|
7
|
+
engine.set_state(state, {})
|
|
8
|
+
|
|
9
|
+
A `GameState` has less than a RocketSim state: no controls (an environment's actions supply them), of the previous
|
|
10
|
+
controls only the jump (`is_holding_jump`), and no air time, world contact or bump cooldown. `RocketSimEngine.set_state`
|
|
11
|
+
fills a RocketSim arena from it; for everything a row has, use `replicar.rocketsim` instead. The derived values follow
|
|
12
|
+
RLGym's definitions: `is_demoed` is a positive `demo_respawn_timer`, `on_ground` three wheels in contact, and
|
|
13
|
+
`is_supersonic` a positive `supersonic_time`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Hashable
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .rocketsim import PAD_TOLERANCE, has_car, rotation_matrix
|
|
23
|
+
|
|
24
|
+
#: replicar's hitbox names and RLGym's hitbox constants (`rlgym.rocket_league.common_values`; it has no `psyclops`).
|
|
25
|
+
HITBOXES = {"octane": 0, "dominus": 1, "plank": 2, "breakout": 3, "hybrid": 4, "merc": 5}
|
|
26
|
+
|
|
27
|
+
#: Seconds of one tick: a supersonic car gets at least this much `supersonic_time`, else RLGym counts it as not
|
|
28
|
+
#: supersonic.
|
|
29
|
+
_TICK = 1.0 / 120.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _modules():
|
|
33
|
+
try:
|
|
34
|
+
from rlgym.rocket_league import common_values
|
|
35
|
+
from rlgym.rocket_league.api import Car, GameConfig, GameState, PhysicsObject
|
|
36
|
+
except ImportError as error:
|
|
37
|
+
raise ImportError("replicar.rlgym needs RLGym 2: pip install replicar[rlgym]") from error
|
|
38
|
+
return common_values, Car, GameConfig, GameState, PhysicsObject
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _physics(PhysicsObject, position, velocity, angular_velocity, rotation):
|
|
42
|
+
physics = PhysicsObject()
|
|
43
|
+
physics.position = np.asarray(position, dtype=np.float32)
|
|
44
|
+
physics.linear_velocity = np.asarray(velocity, dtype=np.float32)
|
|
45
|
+
physics.angular_velocity = np.asarray(angular_velocity, dtype=np.float32)
|
|
46
|
+
physics.rotation_mtx = rotation_matrix(rotation).astype(np.float32)
|
|
47
|
+
return physics
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def game_state(
|
|
51
|
+
file,
|
|
52
|
+
row: int,
|
|
53
|
+
*,
|
|
54
|
+
agent_ids: dict[int, Hashable] | None = None,
|
|
55
|
+
hitboxes: dict[str, int] | None = None,
|
|
56
|
+
) -> Any:
|
|
57
|
+
"""The row as a `GameState`: the ball, a `Car` per player with a car in the row (keyed by `agent_ids[player]`,
|
|
58
|
+
else the player index), the pad timers in RLGym's pad order (`BOOST_LOCATIONS`), the row's sim tick as
|
|
59
|
+
`tick_count`, and RLGym's default `GameConfig` (standard gravity, boost consumption and dodge deadzone).
|
|
60
|
+
`hitboxes` adds or replaces entries of `HITBOXES`."""
|
|
61
|
+
common_values, Car, GameConfig, GameState, PhysicsObject = _modules()
|
|
62
|
+
a = file.arrays()
|
|
63
|
+
if "car_position" not in a or "ball_position" not in a:
|
|
64
|
+
raise ValueError(f"{file.path}: no state group (read the file with replay=... to rebuild it)")
|
|
65
|
+
presets = {**HITBOXES, **(hitboxes or {})}
|
|
66
|
+
|
|
67
|
+
state = GameState()
|
|
68
|
+
state.tick_count = int(a["sim_tick"][row])
|
|
69
|
+
state.config = GameConfig()
|
|
70
|
+
state.config.gravity = 1.0
|
|
71
|
+
state.config.boost_consumption = 1.0
|
|
72
|
+
state.config.dodge_deadzone = 0.5
|
|
73
|
+
state.ball = _physics(
|
|
74
|
+
PhysicsObject,
|
|
75
|
+
a["ball_position"][row],
|
|
76
|
+
a["ball_velocity"][row],
|
|
77
|
+
a["ball_angular_velocity"][row],
|
|
78
|
+
a["ball_rotation"][row],
|
|
79
|
+
)
|
|
80
|
+
state.goal_scored = bool(abs(a["ball_position"][row][1]) > common_values.GOAL_THRESHOLD)
|
|
81
|
+
|
|
82
|
+
state.cars = {}
|
|
83
|
+
for info in file.players:
|
|
84
|
+
p = info["index"]
|
|
85
|
+
if not has_car(file, row, p):
|
|
86
|
+
continue
|
|
87
|
+
if info["hitbox"] not in presets:
|
|
88
|
+
raise ValueError(f"RLGym has no {info['hitbox']} hitbox (player {p}); pass hitboxes=")
|
|
89
|
+
flag = lambda name: bool(a[f"car_{name}"][row, p] == 1) # noqa: E731
|
|
90
|
+
number = lambda name: float(a[f"car_{name}"][row, p]) # noqa: E731
|
|
91
|
+
car = Car()
|
|
92
|
+
car.team_num = int(info["team"])
|
|
93
|
+
car.hitbox_type = presets[info["hitbox"]]
|
|
94
|
+
car.ball_touches = 0
|
|
95
|
+
car.bump_victim_id = None
|
|
96
|
+
car.physics = _physics(
|
|
97
|
+
PhysicsObject,
|
|
98
|
+
a["car_position"][row, p],
|
|
99
|
+
a["car_velocity"][row, p],
|
|
100
|
+
a["car_angular_velocity"][row, p],
|
|
101
|
+
a["car_rotation"][row, p],
|
|
102
|
+
)
|
|
103
|
+
car.demo_respawn_timer = number("demo_respawn_timer") if flag("is_demoed") else 0.0
|
|
104
|
+
car.wheels_with_contact = tuple(bool(a[f"car_wheel_{k}_contact"][row, p] == 1) for k in range(4))
|
|
105
|
+
# As in replicar.rocketsim: the time left in the grace below start speed, and at least a tick while supersonic.
|
|
106
|
+
car.supersonic_time = max(number("supersonic_grace_timer"), _TICK) if flag("is_supersonic") else 0.0
|
|
107
|
+
car.boost_amount = number("boost")
|
|
108
|
+
car.boost_active_time = number("boosting_time")
|
|
109
|
+
car.handbrake = number("handbrake_value")
|
|
110
|
+
car.is_jumping = flag("is_jumping")
|
|
111
|
+
car.has_jumped = flag("has_jumped")
|
|
112
|
+
car.is_holding_jump = flag("previous_controls_jump")
|
|
113
|
+
car.jump_time = float(a["car_jump_ticks"][row, p]) / 120.0
|
|
114
|
+
car.has_flipped = flag("has_flipped")
|
|
115
|
+
car.has_double_jumped = flag("has_double_jumped")
|
|
116
|
+
car.air_time_since_jump = number("air_time_since_jump")
|
|
117
|
+
car.flip_time = number("flip_time")
|
|
118
|
+
car.flip_torque = np.asarray(a["car_flip_relative_torque"][row, p], dtype=np.float32)
|
|
119
|
+
car.is_autoflipping = flag("is_auto_flipping")
|
|
120
|
+
car.autoflip_timer = number("auto_flip_timer")
|
|
121
|
+
car.autoflip_direction = number("auto_flip_torque_scale")
|
|
122
|
+
state.cars[agent_ids[p] if agent_ids else p] = car
|
|
123
|
+
|
|
124
|
+
# RLGym indexes the pad timers in its BOOST_LOCATIONS order: match the header's pads by position.
|
|
125
|
+
header = np.array([pad["position"][:2] for pad in file.header["pads"]], dtype=np.float64)
|
|
126
|
+
locations = np.array(common_values.BOOST_LOCATIONS, dtype=np.float64)[:, :2]
|
|
127
|
+
timers = np.zeros(len(locations), dtype=np.float32)
|
|
128
|
+
cooldowns = a.get("pad_cooldown")
|
|
129
|
+
for i, (x, y) in enumerate(locations):
|
|
130
|
+
distance = np.hypot(header[:, 0] - x, header[:, 1] - y)
|
|
131
|
+
k = int(np.argmin(distance))
|
|
132
|
+
if distance[k] > PAD_TOLERANCE:
|
|
133
|
+
raise ValueError(f"no pad of the file at RLGym's pad {i} ({x}, {y})")
|
|
134
|
+
timers[i] = 0.0 if cooldowns is None else max(float(cooldowns[row, k]), 0.0)
|
|
135
|
+
state.boost_pad_timers = timers
|
|
136
|
+
return state
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""A replicar row as a state of mtheall's RocketSim bindings (the `rocketsim` package, module `RocketSim`, as RLGym
|
|
2
|
+
uses it), to continue a match from any frame or to start an RLGym environment there.
|
|
3
|
+
|
|
4
|
+
import RocketSim, replicar, replicar.rocketsim
|
|
5
|
+
RocketSim.init("collision_meshes")
|
|
6
|
+
f = replicar.read("match.parquet")
|
|
7
|
+
arena, cars = replicar.rocketsim.arena(f, row=1200) # cars: {player index: RocketSim.Car}
|
|
8
|
+
arena.step(8)
|
|
9
|
+
|
|
10
|
+
replicar simulates with the Rust port of RocketSim (the `rocketsim` crate); the bindings are the C++ RocketSim. They
|
|
11
|
+
model the same game, but a state stepped in the bindings is not replicar's: one frame ahead, a car is about 0.2 UU
|
|
12
|
+
apart at the median (docs/v2-guide.md, "Continuing in RocketSim"), and the difference grows with time. What the bindings' state has and a replicar row does not is left at its
|
|
13
|
+
default: the ball's heatseeker state, a car's flip-reset flags, the car a bump cooldown is for, and the tick of a car's
|
|
14
|
+
last extra ball-hit impulse.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
#: How far (UU) a pad may be from the file's to be the same pad (RLGym's table has one 2 UU off; pads are hundreds
|
|
24
|
+
#: of UU apart).
|
|
25
|
+
PAD_TOLERANCE = 10.0
|
|
26
|
+
|
|
27
|
+
#: replicar's hitbox names and the bindings' `CarConfig` presets (the bindings have no `psyclops`).
|
|
28
|
+
HITBOXES = {
|
|
29
|
+
"octane": "OCTANE",
|
|
30
|
+
"dominus": "DOMINUS",
|
|
31
|
+
"plank": "PLANK",
|
|
32
|
+
"breakout": "BREAKOUT",
|
|
33
|
+
"hybrid": "HYBRID",
|
|
34
|
+
"merc": "MERC",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _module():
|
|
39
|
+
try:
|
|
40
|
+
import RocketSim
|
|
41
|
+
except ImportError as error:
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"replicar.rocketsim needs mtheall's RocketSim bindings: pip install rocketsim"
|
|
44
|
+
) from error
|
|
45
|
+
return RocketSim
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rotation_matrix(rotation: np.ndarray) -> np.ndarray:
|
|
49
|
+
"""A unit quaternion (x, y, z, w) as the rotation matrix whose columns are the forward, right and up axes."""
|
|
50
|
+
x, y, z, w = (float(v) for v in rotation)
|
|
51
|
+
return np.array(
|
|
52
|
+
[
|
|
53
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
|
|
54
|
+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
|
|
55
|
+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _rot_mat(rs, rotation: np.ndarray):
|
|
61
|
+
"""The bindings' rotation matrix, given by its forward, right and up axes."""
|
|
62
|
+
return rs.RotMat(*rotation_matrix(rotation).T.flatten())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _vec(rs, values: np.ndarray):
|
|
66
|
+
return rs.Vec(*(float(v) for v in values))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _controls(rs, a: dict[str, np.ndarray], prefix: str, row: int, p: int):
|
|
70
|
+
controls = rs.CarControls()
|
|
71
|
+
for name in ("throttle", "steer", "pitch", "yaw", "roll"):
|
|
72
|
+
controls.__setattr__(name, float(a[f"car_{prefix}_{name}"][row, p]))
|
|
73
|
+
for name in ("jump", "boost", "handbrake"):
|
|
74
|
+
controls.__setattr__(name, bool(a[f"car_{prefix}_{name}"][row, p] == 1))
|
|
75
|
+
return controls
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _arrays(file) -> dict[str, np.ndarray]:
|
|
79
|
+
a = file.arrays()
|
|
80
|
+
if "car_position" not in a or "ball_position" not in a:
|
|
81
|
+
raise ValueError(f"{file.path}: no state group (read the file with replay=... to rebuild it)")
|
|
82
|
+
return a
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_car(file, row: int, player: int) -> bool:
|
|
86
|
+
"""The player has a car in the row."""
|
|
87
|
+
a = _arrays(file)
|
|
88
|
+
return a["car_status"][row, player] not in ("", "absent") and not np.isnan(a["car_position"][row, player, 0])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ball_state(file, row: int):
|
|
92
|
+
"""The row's ball as a `RocketSim.BallState`."""
|
|
93
|
+
rs = _module()
|
|
94
|
+
a = _arrays(file)
|
|
95
|
+
state = rs.BallState()
|
|
96
|
+
state.pos = _vec(rs, a["ball_position"][row])
|
|
97
|
+
state.vel = _vec(rs, a["ball_velocity"][row])
|
|
98
|
+
state.ang_vel = _vec(rs, a["ball_angular_velocity"][row])
|
|
99
|
+
state.rot_mat = _rot_mat(rs, a["ball_rotation"][row])
|
|
100
|
+
return state
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def car_state(file, row: int, player: int):
|
|
104
|
+
"""The row's car of `player` as a `RocketSim.CarState`, with its previous controls as `last_controls`."""
|
|
105
|
+
rs = _module()
|
|
106
|
+
a = _arrays(file)
|
|
107
|
+
if not has_car(file, row, player):
|
|
108
|
+
raise ValueError(f"player {player} has no car in row {row}")
|
|
109
|
+
p = player
|
|
110
|
+
flag = lambda name: bool(a[f"car_{name}"][row, p] == 1) # noqa: E731
|
|
111
|
+
number = lambda name: float(a[f"car_{name}"][row, p]) # noqa: E731
|
|
112
|
+
state = rs.CarState()
|
|
113
|
+
state.pos = _vec(rs, a["car_position"][row, p])
|
|
114
|
+
state.vel = _vec(rs, a["car_velocity"][row, p])
|
|
115
|
+
state.ang_vel = _vec(rs, a["car_angular_velocity"][row, p])
|
|
116
|
+
state.rot_mat = _rot_mat(rs, a["car_rotation"][row, p])
|
|
117
|
+
state.boost = number("boost")
|
|
118
|
+
state.is_on_ground = flag("is_on_ground")
|
|
119
|
+
state.wheels_with_contact = tuple(bool(a[f"car_wheel_{k}_contact"][row, p] == 1) for k in range(4))
|
|
120
|
+
state.has_jumped = flag("has_jumped")
|
|
121
|
+
state.has_double_jumped = flag("has_double_jumped")
|
|
122
|
+
state.has_flipped = flag("has_flipped")
|
|
123
|
+
state.flip_rel_torque = _vec(rs, a["car_flip_relative_torque"][row, p])
|
|
124
|
+
# The port counts `jump_ticks` on every tick, on the ground too, and restarts it when a jump ends; the bindings'
|
|
125
|
+
# `jump_time` is 0 until the car jumps and then counts from the jump's start (measured, 2026-10-08). The same
|
|
126
|
+
# while the jump lasts; 0 before any jump; after the jump the port's count is the time since it ended.
|
|
127
|
+
state.jump_time = float(a["car_jump_ticks"][row, p]) / 120.0 if flag("has_jumped") else 0.0
|
|
128
|
+
state.flip_time = number("flip_time")
|
|
129
|
+
state.is_flipping = flag("is_flipping")
|
|
130
|
+
state.is_jumping = flag("is_jumping")
|
|
131
|
+
state.air_time = number("air_time")
|
|
132
|
+
state.air_time_since_jump = number("air_time_since_jump")
|
|
133
|
+
state.time_spent_boosting = number("boosting_time")
|
|
134
|
+
state.is_supersonic = flag("is_supersonic")
|
|
135
|
+
# Not the same quantity: the bindings' `supersonic_time` counts from when the car became supersonic, the port's
|
|
136
|
+
# grace timer the time spent below the start speed (measured, 2026-10-08); which matches the game is open.
|
|
137
|
+
state.supersonic_time = number("supersonic_grace_timer")
|
|
138
|
+
state.handbrake_val = number("handbrake_value")
|
|
139
|
+
state.is_auto_flipping = flag("is_auto_flipping")
|
|
140
|
+
state.auto_flip_timer = number("auto_flip_timer")
|
|
141
|
+
state.auto_flip_torque_scale = number("auto_flip_torque_scale")
|
|
142
|
+
state.car_contact_cooldown_timer = number("bump_cooldown_timer")
|
|
143
|
+
normal = a["car_world_contact_normal"][row, p]
|
|
144
|
+
state.has_world_contact = not np.isnan(normal[0])
|
|
145
|
+
if state.has_world_contact:
|
|
146
|
+
state.world_contact_normal = _vec(rs, normal)
|
|
147
|
+
state.is_demoed = flag("is_demoed")
|
|
148
|
+
state.demo_respawn_timer = number("demo_respawn_timer")
|
|
149
|
+
state.last_controls = _controls(rs, a, "previous_controls", row, p)
|
|
150
|
+
return state
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def car_controls(file, row: int, player: int):
|
|
154
|
+
"""The controls the row's car of `player` drives on with from this row, as `RocketSim.CarControls`."""
|
|
155
|
+
return _controls(_module(), _arrays(file), "controls", row, player)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pad_order(file, arena) -> list[int]:
|
|
159
|
+
"""For each of the arena's pads, the header's pad at the same place."""
|
|
160
|
+
header = np.array([pad["position"][:2] for pad in file.header["pads"]], dtype=np.float64)
|
|
161
|
+
order = []
|
|
162
|
+
for pad in arena.get_boost_pads():
|
|
163
|
+
position = pad.get_pos()
|
|
164
|
+
distance = np.hypot(header[:, 0] - position.x, header[:, 1] - position.y)
|
|
165
|
+
nearest = int(np.argmin(distance))
|
|
166
|
+
if distance[nearest] > PAD_TOLERANCE:
|
|
167
|
+
raise ValueError(f"no pad of the file at the arena's pad ({position.x}, {position.y})")
|
|
168
|
+
order.append(nearest)
|
|
169
|
+
return order
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def set_state(arena, cars: dict[int, Any], file, row: int) -> None:
|
|
173
|
+
"""Writes the row into `arena`: the ball, each car of `cars` (player index: `RocketSim.Car`; its state and
|
|
174
|
+
controls) and the pads' cooldowns."""
|
|
175
|
+
rs = _module()
|
|
176
|
+
a = _arrays(file)
|
|
177
|
+
arena.ball.set_state(ball_state(file, row))
|
|
178
|
+
for player, car in cars.items():
|
|
179
|
+
car.set_state(car_state(file, row, player))
|
|
180
|
+
car.set_controls(car_controls(file, row, player))
|
|
181
|
+
cooldowns = a.get("pad_cooldown")
|
|
182
|
+
if cooldowns is not None:
|
|
183
|
+
for pad, k in zip(arena.get_boost_pads(), _pad_order(file, arena)):
|
|
184
|
+
state = rs.BoostPadState()
|
|
185
|
+
cooldown = float(cooldowns[row, k])
|
|
186
|
+
state.cooldown = cooldown
|
|
187
|
+
state.is_active = cooldown <= 0.0
|
|
188
|
+
pad.set_state(state)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def arena(file, row: int, *, game_mode=None, hitboxes: dict[str, str] | None = None) -> tuple[Any, dict[int, Any]]:
|
|
192
|
+
"""A new `RocketSim.Arena` (soccar unless `game_mode`) in the row's state, with a car for every player who has
|
|
193
|
+
one in the row (their hitbox and team), and those cars by player index. `hitboxes` adds or replaces entries of
|
|
194
|
+
`HITBOXES` (`{"psyclops": "OCTANE"}` to accept a stand-in). `RocketSim.init` must have been called."""
|
|
195
|
+
rs = _module()
|
|
196
|
+
presets = {**HITBOXES, **(hitboxes or {})}
|
|
197
|
+
new = rs.Arena(rs.GameMode.SOCCAR if game_mode is None else game_mode)
|
|
198
|
+
cars = {}
|
|
199
|
+
for info in file.players:
|
|
200
|
+
player = info["index"]
|
|
201
|
+
if not has_car(file, row, player):
|
|
202
|
+
continue
|
|
203
|
+
if info["hitbox"] not in presets:
|
|
204
|
+
raise ValueError(f"the bindings have no {info['hitbox']} hitbox (player {player}); pass hitboxes=")
|
|
205
|
+
preset = getattr(rs.CarConfig, presets[info["hitbox"]])
|
|
206
|
+
team = rs.Team.BLUE if info["team"] == 0 else rs.Team.ORANGE
|
|
207
|
+
cars[player] = new.add_car(team, rs.CarConfig(preset))
|
|
208
|
+
set_state(new, cars, file, row)
|
|
209
|
+
return new, cars
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replicar
|
|
3
|
+
Version: 2.0.4
|
|
4
|
+
Summary: Read replicar files: Rocket League replays reconstructed as RocketSim states
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Requires-Dist: pyarrow>=15
|
|
10
|
+
Provides-Extra: convert
|
|
11
|
+
Requires-Dist: replicar-native==2.0.4; extra == "convert"
|
|
12
|
+
Provides-Extra: rocketsim
|
|
13
|
+
Requires-Dist: rocketsim>=2.1.1; extra == "rocketsim"
|
|
14
|
+
Provides-Extra: rlgym
|
|
15
|
+
Requires-Dist: rlgym-rocket-league>=2.0.1; extra == "rlgym"
|
|
16
|
+
Requires-Dist: rocketsim>=2.1.1; extra == "rlgym"
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
19
|
+
|
|
20
|
+
# replicar (Python reader)
|
|
21
|
+
|
|
22
|
+
Reads replicar files: Rocket League replays reconstructed as RocketSim states, one Parquet file per replay. Reading
|
|
23
|
+
needs only pyarrow and NumPy; the files are written by the `replicar` command (`crates/replicar-cli`).
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import replicar
|
|
27
|
+
|
|
28
|
+
f = replicar.read("match.parquet")
|
|
29
|
+
f.header["players"] # name, team, hitbox of each player index
|
|
30
|
+
a = f.arrays() # a["car_position"]: (frames, players, 3) float32, NaN when unknown
|
|
31
|
+
f.records("ball_contacts") # one row per contact, with its frame
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The words are defined in `docs/glossary.md`. Tests: `PYTHONPATH=src python -m pytest tests` (Python 3.12 or newer).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/replicar/__init__.py
|
|
4
|
+
src/replicar/py.typed
|
|
5
|
+
src/replicar/rlgym.py
|
|
6
|
+
src/replicar/rocketsim.py
|
|
7
|
+
src/replicar.egg-info/PKG-INFO
|
|
8
|
+
src/replicar.egg-info/SOURCES.txt
|
|
9
|
+
src/replicar.egg-info/dependency_links.txt
|
|
10
|
+
src/replicar.egg-info/requires.txt
|
|
11
|
+
src/replicar.egg-info/top_level.txt
|
|
12
|
+
tests/test_read.py
|
|
13
|
+
tests/test_rocketsim.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
replicar
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Unit tests of the reader on small files written with pyarrow in the replicar layout."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
import pyarrow.parquet as pq
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import replicar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write(path, columns, header=None, metadata=None):
|
|
14
|
+
fields = []
|
|
15
|
+
arrays = []
|
|
16
|
+
for name, (array, field_metadata) in columns.items():
|
|
17
|
+
fields.append(pa.field(name, array.type, metadata=field_metadata))
|
|
18
|
+
arrays.append(array)
|
|
19
|
+
table = pa.Table.from_arrays(arrays, schema=pa.schema(fields))
|
|
20
|
+
header = {"format_version": 1, "groups": ["state", "game"], "precision": "float32",
|
|
21
|
+
"players": [{"index": 0}, {"index": 1}], **(header or {})}
|
|
22
|
+
table = table.replace_schema_metadata({"replicar": json.dumps(header)})
|
|
23
|
+
pq.write_table(table, path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plain(values, kind):
|
|
27
|
+
return pa.array(values, kind), None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_vectors_and_players_are_stacked_and_unknowns_are_nan_or_minus_one(tmp_path):
|
|
31
|
+
path = tmp_path / "x.parquet"
|
|
32
|
+
columns = {"frame": plain([3, 4], pa.uint32())}
|
|
33
|
+
for p, offset in [(0, 0.0), (1, 10.0)]:
|
|
34
|
+
for k, axis in enumerate("xyz"):
|
|
35
|
+
values = [offset + k, None] if p == 1 else [offset + k, offset + k + 1]
|
|
36
|
+
columns[f"car_{p}_position_{axis}"] = plain(values, pa.float32())
|
|
37
|
+
columns[f"car_{p}_update_tick"] = plain([7, None], pa.uint32())
|
|
38
|
+
columns["pad_0_cooldown"] = plain([0.0, 4.0], pa.float32())
|
|
39
|
+
columns["pad_1_cooldown"] = plain([1.0, 0.0], pa.float32())
|
|
40
|
+
write(path, columns)
|
|
41
|
+
a = replicar.read(path).arrays()
|
|
42
|
+
assert a["car_position"].shape == (2, 2, 3)
|
|
43
|
+
assert a["car_position"].dtype == np.float32
|
|
44
|
+
np.testing.assert_array_equal(a["car_position"][0, 1], [10, 11, 12])
|
|
45
|
+
assert np.isnan(a["car_position"][1, 1]).all()
|
|
46
|
+
np.testing.assert_array_equal(a["car_update_tick"], [[7, 7], [-1, -1]])
|
|
47
|
+
np.testing.assert_array_equal(a["pad_cooldown"], [[0, 1], [4, 0]])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_quantized_columns_are_decoded_with_their_scale(tmp_path):
|
|
51
|
+
path = tmp_path / "q.parquet"
|
|
52
|
+
scale = {b"scale": b"1e-2", b"group": b"state"}
|
|
53
|
+
write(path, {
|
|
54
|
+
"frame": plain([0, 1], pa.uint32()),
|
|
55
|
+
"ball_position_x": (pa.array([123, -5], pa.int32()), scale),
|
|
56
|
+
"ball_position_y": (pa.array([0, None], pa.int32()), scale),
|
|
57
|
+
"ball_position_z": (pa.array([9275, 9275], pa.int32()), scale),
|
|
58
|
+
}, header={"precision": "quantized"})
|
|
59
|
+
a = replicar.read(path).arrays()
|
|
60
|
+
np.testing.assert_allclose(a["ball_position"][0], [1.23, 0.0, 92.75], rtol=1e-6)
|
|
61
|
+
assert np.isnan(a["ball_position"][1, 1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_names_and_flags(tmp_path):
|
|
65
|
+
path = tmp_path / "n.parquet"
|
|
66
|
+
names = pa.array(["running", None, "kickoff"]).dictionary_encode()
|
|
67
|
+
write(path, {
|
|
68
|
+
"frame": plain([0, 1, 2], pa.uint32()),
|
|
69
|
+
"clock_phase": (names, None),
|
|
70
|
+
"ball_updated": plain([True, False, True], pa.bool_()),
|
|
71
|
+
"car_0_updated": plain([True, None, False], pa.bool_()),
|
|
72
|
+
})
|
|
73
|
+
a = replicar.read(path).arrays()
|
|
74
|
+
assert list(a["clock_phase"]) == ["running", "", "kickoff"]
|
|
75
|
+
assert a["ball_updated"].dtype == bool
|
|
76
|
+
np.testing.assert_array_equal(a["car_updated"][:, 0], [1, -1, 0])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_records_are_one_row_per_record_with_their_frame(tmp_path):
|
|
80
|
+
path = tmp_path / "r.parquet"
|
|
81
|
+
event = pa.struct([("kind", pa.string()), ("scoring_team", pa.uint8())])
|
|
82
|
+
events = pa.array([[], [{"kind": "goal", "scoring_team": 1}, {"kind": "goal", "scoring_team": 0}], []],
|
|
83
|
+
pa.list_(event))
|
|
84
|
+
write(path, {"frame": plain([5, 6, 7], pa.uint32()), "events": (events, None)})
|
|
85
|
+
f = replicar.read(path)
|
|
86
|
+
assert "events" not in f.arrays()
|
|
87
|
+
records = f.records("events").to_pylist()
|
|
88
|
+
assert records == [{"frame": 6, "kind": "goal", "scoring_team": 1},
|
|
89
|
+
{"frame": 6, "kind": "goal", "scoring_team": 0}]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_other_files_are_refused(tmp_path):
|
|
93
|
+
path = tmp_path / "plain.parquet"
|
|
94
|
+
pq.write_table(pa.table({"a": [1]}), path)
|
|
95
|
+
with pytest.raises(ValueError, match="not a replicar file"):
|
|
96
|
+
replicar.read(path)
|
|
97
|
+
later = tmp_path / "later.parquet"
|
|
98
|
+
write(later, {"frame": plain([0], pa.uint32())}, header={"format_version": 99})
|
|
99
|
+
with pytest.raises(ValueError, match="format version 99"):
|
|
100
|
+
replicar.read(later)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_file_without_states_needs_the_native_extra(tmp_path, monkeypatch):
|
|
104
|
+
import sys
|
|
105
|
+
monkeypatch.setitem(sys.modules, "replicar_native", None)
|
|
106
|
+
path = tmp_path / "s.parquet"
|
|
107
|
+
write(path, {"frame": plain([0], pa.uint32())}, header={"groups": ["game", "resimulation"]})
|
|
108
|
+
with pytest.raises(ImportError, match="native extra"):
|
|
109
|
+
replicar.read(path, replay="x.replay")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_players_table_and_long_views(tmp_path):
|
|
113
|
+
path = tmp_path / "p.parquet"
|
|
114
|
+
write(path, {
|
|
115
|
+
"frame": plain([3, 4], pa.uint32()),
|
|
116
|
+
"car_0_boost": plain([10.0, 20.0], pa.float32()),
|
|
117
|
+
"car_1_boost": plain([30.0, None], pa.float32()),
|
|
118
|
+
"pad_0_cooldown": plain([0.0, 1.0], pa.float32()),
|
|
119
|
+
}, header={"players": [
|
|
120
|
+
{"index": 0, "name": "a", "team": 0, "hitbox": "octane", "key": "k0", "final_stats": {"goal": 2}},
|
|
121
|
+
{"index": 1, "name": "b", "team": 1, "hitbox": "dominus", "key": "k1", "final_stats": {"save": 1}},
|
|
122
|
+
]})
|
|
123
|
+
f = replicar.read(path)
|
|
124
|
+
players = f.players_table().to_pylist()
|
|
125
|
+
assert players[0]["name"] == "a" and players[0]["final_goal"] == 2 and players[0]["final_save"] is None
|
|
126
|
+
assert players[1]["final_save"] == 1
|
|
127
|
+
long = f.long("car").to_pylist()
|
|
128
|
+
assert [(r["frame"], r["player"], r["car_boost"]) for r in long] == [(3, 0, 10.0), (3, 1, 30.0), (4, 0, 20.0),
|
|
129
|
+
(4, 1, None)]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests of `replicar.rocketsim`: the rotation without the bindings, and a real file with them (skipped without the
|
|
2
|
+
`rocketsim` package, `collision_meshes/` or `target/sample/match-default.parquet` at the repository root)."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import replicar
|
|
11
|
+
import replicar.rocketsim as bridge
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
14
|
+
SAMPLE = ROOT / "target" / "sample" / "match-default.parquet"
|
|
15
|
+
MESHES = ROOT / "collision_meshes"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_a_quaternion_becomes_rows_forward_right_up():
|
|
19
|
+
fake = SimpleNamespace(RotMat=lambda *values: np.array(values).reshape(3, 3))
|
|
20
|
+
# A quarter turn left about z: forward is +y, right is -x, up stays +z.
|
|
21
|
+
s = np.sqrt(0.5)
|
|
22
|
+
rows = bridge._rot_mat(fake, np.array([0.0, 0.0, s, s]))
|
|
23
|
+
np.testing.assert_allclose(rows, [[0, 1, 0], [-1, 0, 0], [0, 0, 1]], atol=1e-6)
|
|
24
|
+
np.testing.assert_allclose(bridge._rot_mat(fake, np.array([0.0, 0.0, 0.0, 1.0])), np.eye(3))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(scope="module")
|
|
28
|
+
def sample():
|
|
29
|
+
rs = pytest.importorskip("RocketSim")
|
|
30
|
+
if not SAMPLE.exists() or not MESHES.exists():
|
|
31
|
+
pytest.skip("no sample file or collision meshes")
|
|
32
|
+
rs.init(str(MESHES))
|
|
33
|
+
return rs, replicar.read(SAMPLE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_a_row_restores_into_an_arena_and_steps_like_the_file(sample):
|
|
37
|
+
rs, f = sample
|
|
38
|
+
a = f.arrays()
|
|
39
|
+
row = int(np.flatnonzero((a["segment"][:-1] >= 0) & (a["segment"][:-1] == a["segment"][1:]))[200])
|
|
40
|
+
arena, cars = bridge.arena(f, row)
|
|
41
|
+
assert sorted(cars) == [p["index"] for p in f.players if bridge.has_car(f, row, p["index"])]
|
|
42
|
+
ball = arena.ball.get_state()
|
|
43
|
+
np.testing.assert_allclose([ball.pos.x, ball.pos.y, ball.pos.z], a["ball_position"][row], atol=1e-3)
|
|
44
|
+
for p, car in cars.items():
|
|
45
|
+
s = car.get_state()
|
|
46
|
+
np.testing.assert_allclose([s.pos.x, s.pos.y, s.pos.z], a["car_position"][row, p], atol=1e-3)
|
|
47
|
+
assert car.team == (rs.Team.BLUE if f.players[p]["team"] == 0 else rs.Team.ORANGE)
|
|
48
|
+
cooldowns = sorted(pad.get_state().cooldown for pad in arena.get_boost_pads())
|
|
49
|
+
np.testing.assert_allclose(cooldowns, sorted(a["pad_cooldown"][row]), atol=1e-4)
|
|
50
|
+
# One frame ahead the ball is where the file has it, unless the next row updated it.
|
|
51
|
+
arena.step(int(a["sim_tick"][row + 1] - a["sim_tick"][row]))
|
|
52
|
+
if a["ball_updated"][row + 1] != 1:
|
|
53
|
+
ball = arena.ball.get_state()
|
|
54
|
+
np.testing.assert_allclose([ball.pos.x, ball.pos.y, ball.pos.z], a["ball_position"][row + 1], atol=0.5)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_row_as_an_rlgym_game_state_restores_through_the_engine(sample):
|
|
58
|
+
pytest.importorskip("rlgym.rocket_league")
|
|
59
|
+
from rlgym.rocket_league.sim import RocketSimEngine
|
|
60
|
+
|
|
61
|
+
import replicar.rlgym
|
|
62
|
+
|
|
63
|
+
_, f = sample
|
|
64
|
+
a = f.arrays()
|
|
65
|
+
row = int(np.flatnonzero(a["segment"] >= 0)[300])
|
|
66
|
+
state = replicar.rlgym.game_state(f, row, agent_ids={p["index"]: p["name"] for p in f.players})
|
|
67
|
+
assert set(state.cars) == {p["name"] for p in f.players if bridge.has_car(f, row, p["index"])}
|
|
68
|
+
out = RocketSimEngine(rlbot_delay=False).set_state(state, {})
|
|
69
|
+
np.testing.assert_allclose(out.ball.position, a["ball_position"][row], atol=1e-3)
|
|
70
|
+
for p in f.players:
|
|
71
|
+
if p["name"] in out.cars:
|
|
72
|
+
car = out.cars[p["name"]]
|
|
73
|
+
np.testing.assert_allclose(car.physics.position, a["car_position"][row, p["index"]], atol=1e-3)
|
|
74
|
+
np.testing.assert_allclose(car.physics.rotation_mtx, state.cars[p["name"]].physics.rotation_mtx, atol=1e-5)
|
|
75
|
+
# The engine indexes pads in its arena's order; the timers come back where they were put.
|
|
76
|
+
np.testing.assert_allclose(out.boost_pad_timers, state.boost_pad_timers, atol=1e-5)
|
|
77
|
+
assert sorted(state.boost_pad_timers) == pytest.approx(sorted(np.maximum(a["pad_cooldown"][row], 0)), abs=1e-5)
|