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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,15 @@
1
+ numpy>=1.26
2
+ pyarrow>=15
3
+
4
+ [convert]
5
+ replicar-native==2.0.4
6
+
7
+ [rlgym]
8
+ rlgym-rocket-league>=2.0.1
9
+ rocketsim>=2.1.1
10
+
11
+ [rocketsim]
12
+ rocketsim>=2.1.1
13
+
14
+ [test]
15
+ pytest>=8
@@ -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)