diff --git a/Docs/CHANGELOG.md b/Docs/CHANGELOG.md
index c8518ff5f..8c209f5e8 100644
--- a/Docs/CHANGELOG.md
+++ b/Docs/CHANGELOG.md
@@ -1,4 +1,5 @@
## Table of Contents
+* [Unreleased — CARLA 0.10.0 / UE5 compatibility](#unreleased--carla-0100--ue5-compatibility)
* [CARLA ScenarioRunner 0.9.16](#carla-scenariorunner-0916)
* [CARLA ScenarioRunner 0.9.15](#carla-scenariorunner-0915)
* [CARLA ScenarioRunner 0.9.13](#carla-scenariorunner-0913)
@@ -13,6 +14,35 @@
* [CARLA ScenarioRunner 0.9.5](#carla-scenariorunner-095)
* [CARLA ScenarioRunner 0.9.2](#carla-scenariorunner-092)
+## Unreleased — CARLA 0.10.0 / UE5 compatibility
+
+This release extends ScenarioRunner to run against the **CARLA 0.10.0** server (UE5 / Chaos physics) while keeping full compatibility with **CARLA 0.9.x** (UE4 / PhysX). The default supported map on UE5 is `Town10HD_Opt`; other UE5 maps remain reachable for users who explicitly target them via their own configs, `--configFile`, or `--additionalScenario`.
+
+### :rocket: New Features
+* New `srunner/tools/carla_compat.py` runtime version-detection layer. Auto-detects the installed CARLA wheel and exposes `IS_UE5` / `IS_UE4` flags plus version-keyed lookup tables. The `SR_CARLA_VERSION` environment variable can force a version when the wheel disagrees with the running server.
+* `CarlaDataProvider.create_blueprint` consults a version-keyed alias table on UE5, translating legacy blueprint ids (`vehicle.lincoln.mkz_2017`, `vehicle.audi.tt`, `vehicle.dodge.charger_police_2020`, `vehicle.carlamotors.carlacola`, …) to the 0.10.0 catalogue. The per-category fallback dict is also version-keyed so missing models degrade to a documented substitute instead of crashing.
+* New `srunner/examples_ue5/` directory for Town10HD_Opt-targeted scenario configs. `srunner/tools/scenario_parser.py` reads `examples_ue5/*.xml` first on UE5 servers, then falls back to `examples/*.xml`, with per-scenario-name dedup so UE5 entries override their legacy siblings. Initial UE5 configs added for `FollowLeadingVehicle_1`, `FollowLeadingVehicleWithObstacle_1`, and `OtherLeadingVehicle_1`.
+
+### :ghost: Maintenance
+* `VehicleVelocityControl.__init__` skips the wheel-friction-zeroing setup on UE5. The Chaos `WheelPhysicsControl.friction_force_multiplier` parameter is documented but `apply_physics_control` silently discards wheel-attribute writes on the current 0.10.0 build, and no other documented UE5 API exposes per-wheel friction-override for this controller's kinematic-injection use. The UE4 `tire_friction = 0` path is unchanged.
+* `RouteLightsBehavior` (in `srunner/scenariomanager/lights_sim.py`, used by `--route` mode) is now engine-aware. On UE5 it skips the removed `LightManager` API entirely: no `set_day_night_cycle` calls (UE5 has no auto sun motion to disable — `weather.sun_altitude_angle` is the documented day/night control, which `_get_night_mode` already drives off) and no per-street-light `turn_on`/`turn_off` enumeration (UE5 exposes no documented per-`Light` runtime toggle). Vehicle-light control (`Vehicle.set_light_state` / `get_light_state`) is preserved on both engines. On UE5 the behavior emits a one-time INFO log at construction so users know street lights are not being controlled.
+* `MetricsParser` and `Osc2TraceParser` (in `srunner/metrics/tools/`) now branch the Physics Control block parsing on `IS_UE5`. On 0.9.x the existing PhysX path is unchanged. On 0.10.0 the new `srunner/metrics/tools/recorder_chaos.py` walker handles the rewritten Chaos serialization — scalar fields, `forward_gear_ratios` / `reverse_gear_ratios` lists, and the per-wheel `friction_force_multiplier` / `cornering_stiffness` / `max_brake_torque` / `affected_by_*` / `suspension_axis` set documented at `carla-ue5/Docs/python_api.md` §`carla.VehiclePhysicsControl` and §`carla.WheelPhysicsControl`. The parsers also walk past the new top-level recorder sections introduced by the 0.10.0 server (`Vehicle door animations`, `Weathers`, `Walkers Bones`) so downstream blocks remain reachable. PhysX-only fields (`MOI`, `damping_rate*`, `clutch_strength`, `use_gear_autobox`) are not invented out of Chaos data — they are absent from the resulting `VehiclePhysicsControl` on UE5.
+* `RosAgent.publish_can` (in `srunner/autoagents/ros_agent.py`) drops the PhysX-only physics block on UE5. The `CarlaEgoVehicleInfo` message schema is modelled on the removed PhysX fields (`tire_friction`, `damping_rate`, `moi`, `damping_rate_full_throttle`, `damping_rate_zero_throttle_clutch_disengaged`, `use_gear_autobox`, `clutch_strength`); the Chaos struct exposes a different set with different semantics, so there is no faithful field-by-field port — downstream ROS consumers parse PhysX semantics. On UE5 the agent emits a one-time deprecation warning pointing at the native CARLA ROS 2 bridge (the supported ROS path going forward) and populates only the fields that survived (`mass`, `drag_coefficient`, `center_of_mass`). UE4 behavior is unchanged.
+* New carla-free unit suites — `tests/test_carla_compat.py` (16 tests: env override, `IS_UE5` thresholding, blueprint-id aliases, category fallbacks across both engines) and `tests/test_recorder_chaos.py` (25 tests: Chaos wheel-line tokenizer, Physics Control block walker, end-to-end `MetricsParser` / `Osc2TraceParser` runs against a committed 0.10.0 recorder fixture at `tests/fixtures/recorder_ue5_town10.txt`). Installs a stub `carla` module so the tests run in any Python env without a CARLA wheel installed.
+
+### :wrench: Behavior delta on UE5
+* `VehicleVelocityControl` on UE5 continues to track heading-aligned `set_target_velocity` calls tightly (measured 101% of target over 5 s @ 1 m/s along the vehicle's forward axis); sharp lateral / cornering motion tracks less faithfully than on UE4 because wheel lateral friction cannot currently be suppressed. Tracked upstream in carla-ue5.
+* `RouteLightsBehavior` no longer controls street lights on UE5. Vehicle headlights / position lights still flip on and off based on `weather.sun_altitude_angle` (validated live: state `Position|LowBeam` set under sun_altitude=-30°, cleared under sun_altitude=75°).
+* Legacy bicycle / motorbike blueprints are absent from the 0.10.0 catalogue; the alias table substitutes a four-wheel vehicle so spawns succeed, but two-wheeler scenarios lose their visual identity until upstream content lands.
+* The offline `MetricsParser` / `Osc2TraceParser` `physics_control` shape differs between engines. On 0.9.x it returns a `VehiclePhysicsControl` populated with PhysX fields (`tire_friction`, `damping_rate`, `forward_gears`, …). On 0.10.0 it returns a Chaos-shaped `VehiclePhysicsControl` (`friction_force_multiplier`, `cornering_stiffness`, `max_brake_torque`, `forward_gear_ratios`, …). Metric scripts that read these structs must branch on `srunner.tools.carla_compat.IS_UE5` to interpret the right field set. A subset of Chaos list fields (`forward_gear_ratios`, `reverse_gear_ratios`) is settable but currently not readable from Python on the 0.10.0 wheel under test — an upstream `boost::python` to-Python converter gap, not a parser issue.
+* The legacy `RosAgent` (`srunner/autoagents/ros_agent.py`) omits PhysX-only vehicle-info fields on UE5 and logs a one-time deprecation warning. Users on 0.10.0 should consume telemetry via the native CARLA ROS 2 bridge instead of the legacy `CarlaEgoVehicleInfo` schema.
+
+### :bug: Bug Fixes
+* Fixed a latent bug in `CarlaDataProvider.create_blueprint`: when the requested model id was missing and the per-category fallback id was also missing (empty filter result), the fallback path raised an uncaught `ValueError: 'a' cannot be empty` from `np.random.choice([])`. The fallback now degrades gracefully (fallback → `vehicle.*` → explicit diagnostic `ValueError`).
+
+### :white_check_mark: Validation
+* End-to-end validated on a live CARLA 0.10.0 server (Town10HD_Opt) with the full compat stack integrated: `FollowLeadingVehicle_1` (60.05s game), `FollowLeadingVehicleWithObstacle_1` (120.05s game), and `OtherLeadingVehicle_1` (80.05s game) all run spawn → behavior-tree tick → criteria evaluation → result reporting. `CollisionTest` reports `SUCCESS` on every run; the `TIMEOUT` global result is expected in `--scenario` mode without `--agent`.
+
## CARLA ScenarioRunner 0.9.16
### :rocket: New Features
diff --git a/Docs/getting_scenariorunner.md b/Docs/getting_scenariorunner.md
index d853dccd4..362cb13ff 100644
--- a/Docs/getting_scenariorunner.md
+++ b/Docs/getting_scenariorunner.md
@@ -254,5 +254,42 @@ python scenario_runner.py --help
---
+## Running against CARLA 0.10.0 (UE5)
+
+ScenarioRunner supports both **CARLA 0.9.x** (UE4 / PhysX) and **CARLA 0.10.0+** (UE5 / Chaos) from the same source tree. The engine is auto-detected at runtime from the installed `carla` wheel; no code path needs to be selected by hand.
+
+__1. Install the matching CARLA 0.10.0 wheel.__ Use a Python ≥ 3.8 environment and install the `carla-0.10.0-*.whl` shipped with the 0.10.0 server build, then `pip install -r requirements.txt` from this repository.
+
+__2. Default supported map: `Town10HD_Opt`.__ The bundled UE5 example configs target `Town10HD_Opt` only. Other UE5 maps that ship with the server (e.g. `Town15`, `Mine_01`) remain reachable: target them with your own configs via `--configFile` / `--additionalScenario`.
+
+__3. Auto-detected blueprint translation.__ Legacy 0.9.x blueprint ids (e.g. `vehicle.lincoln.mkz_2017`, `vehicle.audi.tt`, `vehicle.dodge.charger_police_2020`) are translated to their 0.10.0 catalogue equivalents on the fly. Bicycle / motorbike blueprints are absent in 0.10.0 and substituted with a four-wheel vehicle so spawns succeed.
+
+__4. Version override (advanced).__ If the running server disagrees with the installed wheel, force the detection with the `SR_CARLA_VERSION` environment variable:
+
+```sh
+export SR_CARLA_VERSION=0.10.0 # force UE5 / Chaos code path
+export SR_CARLA_VERSION=0.9.16 # force UE4 / PhysX code path
+```
+
+__5. Example run against a UE5 server.__
+
+```sh
+# Launch the 0.10.0 server in another terminal first
+python scenario_runner.py --scenario FollowLeadingVehicle_1 --reloadWorld --sync
+```
+
+The runner picks up `srunner/examples_ue5/FollowLeadingVehicle.xml` (Town10HD_Opt) on a UE5 server, and falls back to `srunner/examples/FollowLeadingVehicle.xml` (Town01) on a 0.9.x server.
+
+### Behavior deltas on UE5
+
+A small number of UE4-only APIs have no working UE5 replacement and are dropped on the UE5 code path. See [CHANGELOG](CHANGELOG.md) for the full inventory.
+
+* `VehicleVelocityControl` (used by the OpenSCENARIO 2.0 `follow_trajectory` modifier with `control: "velocity"`) tracks heading-aligned trajectories tightly on UE5 but degrades on sharp lateral / cornering moves because Chaos exposes no working API to zero wheel friction.
+* `RouteLightsBehavior` (used by `--route` mode) does not control street lights on UE5. The removed `LightManager` API has no UE5 replacement. Vehicle headlights / position lights still respond to night vs. day weather (via `weather.sun_altitude_angle`).
+* The offline `MetricsParser` / `Osc2TraceParser` `physics_control` event returns a Chaos-shaped `VehiclePhysicsControl` on UE5 (`friction_force_multiplier`, `cornering_stiffness`, `max_brake_torque`, `forward_gear_ratios`, …) instead of the PhysX-shaped one on 0.9.x. Metric scripts that read these structs must branch on `srunner.tools.carla_compat.IS_UE5` to interpret the right field set.
+* The legacy `RosAgent` (`srunner/autoagents/ros_agent.py`) omits PhysX-only `CarlaEgoVehicleInfo` fields on UE5 (`tire_friction`, `damping_rate`, `moi`, `clutch_strength`, `use_gear_autobox`, `damping_rate_*`) and emits a one-time deprecation warning. Use the native CARLA ROS 2 bridge that ships with 0.10.0 for full physics telemetry.
+
+---
+
Thus concludes the installation process for ScenarioRunner. In case any unexpected error or issue occurs, the [CARLA forum](https://forum.carla.org/c/using-carla/scenario-runner) is open to everybody. There is an _ScenarioRunner_ category to post problems and doubts regarding this module.
diff --git a/srunner/autoagents/ros_agent.py b/srunner/autoagents/ros_agent.py
index b2690a4bc..2a154df76 100644
--- a/srunner/autoagents/ros_agent.py
+++ b/srunner/autoagents/ros_agent.py
@@ -39,6 +39,7 @@
)
from srunner.autoagents.autonomous_agent import AutonomousAgent
+from srunner.tools.carla_compat import IS_UE5
class RosAgent(AutonomousAgent):
@@ -358,24 +359,49 @@ def publish_can(self, sensor_id, data):
self.vehicle_info_publisher = rospy.Publisher(
'/carla/ego_vehicle/vehicle_info', CarlaEgoVehicleInfo, queue_size=1, latch=True)
info_msg = CarlaEgoVehicleInfo()
- for wheel in data['wheels']:
- wheel_info = CarlaEgoVehicleInfoWheel()
- wheel_info.tire_friction = wheel['tire_friction']
- wheel_info.damping_rate = wheel['damping_rate']
- wheel_info.steer_angle = wheel['steer_angle']
- wheel_info.disable_steering = wheel['disable_steering']
- info_msg.wheels.append(wheel_info)
- info_msg.max_rpm = data['max_rpm']
- info_msg.moi = data['moi']
- info_msg.damping_rate_full_throttle = data['damping_rate_full_throttle']
- info_msg.damping_rate_zero_throttle_clutch_disengaged = data['damping_rate_zero_throttle_clutch_disengaged']
- info_msg.use_gear_autobox = data['use_gear_autobox']
- info_msg.clutch_strength = data['clutch_strength']
- info_msg.mass = data['mass']
- info_msg.drag_coefficient = data['drag_coefficient']
- info_msg.center_of_mass.x = data['center_of_mass']['x']
- info_msg.center_of_mass.y = data['center_of_mass']['y']
- info_msg.center_of_mass.z = data['center_of_mass']['z']
+ if IS_UE5:
+ # CARLA 0.10.0 (UE5 / Chaos) removed every PhysX-shaped field this
+ # message schema was modelled on (tire_friction, damping_rate, moi,
+ # damping_rate_full_throttle, damping_rate_zero_throttle_clutch_disengaged,
+ # use_gear_autobox, clutch_strength). The Chaos struct exposes a
+ # different set with different semantics (friction_force_multiplier,
+ # cornering_stiffness, max_brake_torque, forward_gear_ratios, ...) so
+ # there is no faithful field-by-field port — downstream ROS consumers
+ # parse PhysX semantics. We populate only the fields that survived
+ # (mass, drag_coefficient, center_of_mass) and leave the rest at
+ # their CarlaEgoVehicleInfo defaults. CARLA 0.10.0 ships a native
+ # ROS 2 bridge that is the supported ROS path going forward.
+ if not getattr(self, "_ros_agent_ue5_warned", False):
+ rospy.logwarn(
+ "RosAgent: omitting PhysX-only vehicle info fields on "
+ "CARLA 0.10.0 (UE5/Chaos) — use the native CARLA ROS 2 "
+ "bridge for full physics telemetry."
+ )
+ self._ros_agent_ue5_warned = True
+ info_msg.mass = data['mass']
+ info_msg.drag_coefficient = data['drag_coefficient']
+ info_msg.center_of_mass.x = data['center_of_mass']['x']
+ info_msg.center_of_mass.y = data['center_of_mass']['y']
+ info_msg.center_of_mass.z = data['center_of_mass']['z']
+ else:
+ for wheel in data['wheels']:
+ wheel_info = CarlaEgoVehicleInfoWheel()
+ wheel_info.tire_friction = wheel['tire_friction']
+ wheel_info.damping_rate = wheel['damping_rate']
+ wheel_info.steer_angle = wheel['steer_angle']
+ wheel_info.disable_steering = wheel['disable_steering']
+ info_msg.wheels.append(wheel_info)
+ info_msg.max_rpm = data['max_rpm']
+ info_msg.moi = data['moi']
+ info_msg.damping_rate_full_throttle = data['damping_rate_full_throttle']
+ info_msg.damping_rate_zero_throttle_clutch_disengaged = data['damping_rate_zero_throttle_clutch_disengaged']
+ info_msg.use_gear_autobox = data['use_gear_autobox']
+ info_msg.clutch_strength = data['clutch_strength']
+ info_msg.mass = data['mass']
+ info_msg.drag_coefficient = data['drag_coefficient']
+ info_msg.center_of_mass.x = data['center_of_mass']['x']
+ info_msg.center_of_mass.y = data['center_of_mass']['y']
+ info_msg.center_of_mass.z = data['center_of_mass']['z']
self.vehicle_info_publisher.publish(info_msg)
msg = CarlaEgoVehicleStatus()
msg.header = self.get_header()
diff --git a/srunner/examples_ue5/FollowLeadingVehicle.xml b/srunner/examples_ue5/FollowLeadingVehicle.xml
new file mode 100644
index 000000000..d008bb093
--- /dev/null
+++ b/srunner/examples_ue5/FollowLeadingVehicle.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/srunner/examples_ue5/LeadingVehicle.xml b/srunner/examples_ue5/LeadingVehicle.xml
new file mode 100644
index 000000000..57816a5d6
--- /dev/null
+++ b/srunner/examples_ue5/LeadingVehicle.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/srunner/metrics/tools/metrics_parser.py b/srunner/metrics/tools/metrics_parser.py
index d270143e5..8052890c4 100644
--- a/srunner/metrics/tools/metrics_parser.py
+++ b/srunner/metrics/tools/metrics_parser.py
@@ -13,6 +13,9 @@
import carla
+from srunner.tools.carla_compat import IS_UE5
+from srunner.metrics.tools.recorder_chaos import parse_chaos_physics_block, skip_indented_section
+
def parse_actor(info):
"""Returns a dictionary with the basic actor information"""
@@ -345,6 +348,11 @@ def parse_recorder_info(self):
self.next_row()
+ # CARLA 0.10.0 (UE5) records additional sections the 0.9.x parser
+ # had no awareness of. Walk past them so downstream blocks (in
+ # particular Physics Control events) remain reachable.
+ skip_indented_section(self, ' Vehicle door animations')
+
if self.frame_row.startswith(' Positions'):
self.next_row()
@@ -403,6 +411,9 @@ def parse_recorder_info(self):
frame_state["actors"][actor_id].update({"lights": lights})
self.next_row()
+ # 0.10.0 weather snapshot — not modelled by this parser yet.
+ skip_indented_section(self, ' Weathers')
+
if self.frame_row.startswith(' Scene light changes'):
self.next_row()
@@ -469,60 +480,66 @@ def parse_recorder_info(self):
if self.frame_row.startswith(' Physics Control'):
self.next_row()
- actor_id = None
- while self.frame_row.startswith(' '):
-
- elements = self.get_row_elements(2, " ")
- actor_id = int(elements[1])
- physics_control = carla.VehiclePhysicsControl()
- self.next_row()
+ if IS_UE5:
+ # CARLA 0.10.0 (Chaos) rewrites the Physics Control block —
+ # see srunner/metrics/tools/recorder_chaos.py for the format
+ # and the documented Chaos field set.
+ parse_chaos_physics_block(self, frame_state["events"]["physics_control"])
+ else:
+ actor_id = None
+ while self.frame_row.startswith(' '):
- forward_gears = []
- wheels = []
- while self.frame_row.startswith(' '):
-
- if self.frame_row.startswith(' '):
- elements = self.get_row_elements(4, " ")
- if elements[0] == "gear":
- forward_gears.append(parse_gears_control(elements))
- elif elements[0] == "wheel":
- wheels.append(parse_wheels_control(elements))
-
- else:
- elements = self.get_row_elements(3, " = ")
- name = elements[0]
-
- if name == "center_of_mass":
- values = elements[1].split(" ")
- value = carla.Vector3D(
- float(values[0][1:-1]),
- float(values[1][:-1]),
- float(values[2][:-1]),
- )
- setattr(physics_control, name, value)
- elif name == "torque_curve" or name == "steering_curve":
- values = elements[1].split(" ")
- value = parse_vector_list(values)
- setattr(physics_control, name, value)
-
- elif name == "use_gear_auto_box":
- name = "use_gear_autobox"
- value = elements[1] == "true"
- setattr(physics_control, name, value)
-
- elif "forward_gears" in name or "wheels" in name:
- pass
+ elements = self.get_row_elements(2, " ")
+ actor_id = int(elements[1])
+ physics_control = carla.VehiclePhysicsControl()
+ self.next_row()
- else:
- name = name.lower()
- value = float(elements[1])
- setattr(physics_control, name, value)
+ forward_gears = []
+ wheels = []
+ while self.frame_row.startswith(' '):
- self.next_row()
+ if self.frame_row.startswith(' '):
+ elements = self.get_row_elements(4, " ")
+ if elements[0] == "gear":
+ forward_gears.append(parse_gears_control(elements))
+ elif elements[0] == "wheel":
+ wheels.append(parse_wheels_control(elements))
- setattr(physics_control, "forward_gears", forward_gears)
- setattr(physics_control, "wheels", wheels)
- frame_state["events"]["physics_control"].update({actor_id: physics_control})
+ else:
+ elements = self.get_row_elements(3, " = ")
+ name = elements[0]
+
+ if name == "center_of_mass":
+ values = elements[1].split(" ")
+ value = carla.Vector3D(
+ float(values[0][1:-1]),
+ float(values[1][:-1]),
+ float(values[2][:-1]),
+ )
+ setattr(physics_control, name, value)
+ elif name == "torque_curve" or name == "steering_curve":
+ values = elements[1].split(" ")
+ value = parse_vector_list(values)
+ setattr(physics_control, name, value)
+
+ elif name == "use_gear_auto_box":
+ name = "use_gear_autobox"
+ value = elements[1] == "true"
+ setattr(physics_control, name, value)
+
+ elif "forward_gears" in name or "wheels" in name:
+ pass
+
+ else:
+ name = name.lower()
+ value = float(elements[1])
+ setattr(physics_control, name, value)
+
+ self.next_row()
+
+ setattr(physics_control, "forward_gears", forward_gears)
+ setattr(physics_control, "wheels", wheels)
+ frame_state["events"]["physics_control"].update({actor_id: physics_control})
if self.frame_row.startswith(' Traffic Light time events'):
self.next_row()
@@ -535,6 +552,9 @@ def parse_recorder_info(self):
frame_state["events"]["traffic_light_state_time"].update({actor_id: state_times})
self.next_row()
+ # 0.10.0 per-walker bone transforms — not consumed yet.
+ skip_indented_section(self, ' Walkers Bones')
+
frames_info.append(frame_state)
return simulation_info, actors_info, frames_info
diff --git a/srunner/metrics/tools/osc2_trace_parser.py b/srunner/metrics/tools/osc2_trace_parser.py
index 1fdf4b5ad..96563dd80 100644
--- a/srunner/metrics/tools/osc2_trace_parser.py
+++ b/srunner/metrics/tools/osc2_trace_parser.py
@@ -13,6 +13,9 @@
import carla
+from srunner.tools.carla_compat import IS_UE5
+from srunner.metrics.tools.recorder_chaos import parse_chaos_physics_block, skip_indented_section
+
def parse_actor(info):
"""Returns a dictionary with the basic actor information"""
@@ -350,6 +353,11 @@ def parse_recorder_info(self):
self.next_row()
+ # CARLA 0.10.0 (UE5) records additional sections the 0.9.x parser
+ # had no awareness of. Walk past them so downstream blocks (in
+ # particular Physics Control events) remain reachable.
+ skip_indented_section(self, " Vehicle door animations")
+
if self.frame_row.startswith(" Positions"):
self.next_row()
@@ -405,6 +413,9 @@ def parse_recorder_info(self):
frame_state["actors"][actor_id].update({"lights": lights})
self.next_row()
+ # 0.10.0 weather snapshot — not modelled by this parser yet.
+ skip_indented_section(self, " Weathers")
+
if self.frame_row.startswith(" Scene light changes"):
self.next_row()
@@ -476,60 +487,68 @@ def parse_recorder_info(self):
if self.frame_row.startswith(" Physics Control"):
self.next_row()
- actor_id = None
- while self.frame_row.startswith(" "):
- elements = self.get_row_elements(2, " ")
- actor_id = int(elements[1])
- physics_control = carla.VehiclePhysicsControl()
- self.next_row()
+ if IS_UE5:
+ # CARLA 0.10.0 (Chaos) rewrites the Physics Control block —
+ # see srunner/metrics/tools/recorder_chaos.py for the format
+ # and the documented Chaos field set.
+ parse_chaos_physics_block(
+ self, frame_state["events"]["physics_control"]
+ )
+ else:
+ actor_id = None
+ while self.frame_row.startswith(" "):
+ elements = self.get_row_elements(2, " ")
+ actor_id = int(elements[1])
+ physics_control = carla.VehiclePhysicsControl()
+ self.next_row()
- forward_gears = []
- wheels = []
- while self.frame_row.startswith(" "):
- if self.frame_row.startswith(" "):
- elements = self.get_row_elements(4, " ")
- if elements[0] == "gear":
- forward_gears.append(parse_gears_control(elements))
- elif elements[0] == "wheel":
- wheels.append(parse_wheels_control(elements))
-
- else:
- elements = self.get_row_elements(3, " = ")
- name = elements[0]
-
- if name == "center_of_mass":
- values = elements[1].split(" ")
- value = carla.Vector3D(
- float(values[0][1:-1]),
- float(values[1][:-1]),
- float(values[2][:-1]),
- )
- setattr(physics_control, name, value)
- elif name == "torque_curve" or name == "steering_curve":
- values = elements[1].split(" ")
- value = parse_vector_list(values)
- setattr(physics_control, name, value)
-
- elif name == "use_gear_auto_box":
- name = "use_gear_autobox"
- value = elements[1] == "true"
- setattr(physics_control, name, value)
-
- elif "forward_gears" in name or "wheels" in name:
- pass
+ forward_gears = []
+ wheels = []
+ while self.frame_row.startswith(" "):
+ if self.frame_row.startswith(" "):
+ elements = self.get_row_elements(4, " ")
+ if elements[0] == "gear":
+ forward_gears.append(parse_gears_control(elements))
+ elif elements[0] == "wheel":
+ wheels.append(parse_wheels_control(elements))
else:
- name = name.lower()
- value = float(elements[1])
- setattr(physics_control, name, value)
-
- self.next_row()
-
- setattr(physics_control, "forward_gears", forward_gears)
- setattr(physics_control, "wheels", wheels)
- frame_state["events"]["physics_control"].update(
- {actor_id: physics_control}
- )
+ elements = self.get_row_elements(3, " = ")
+ name = elements[0]
+
+ if name == "center_of_mass":
+ values = elements[1].split(" ")
+ value = carla.Vector3D(
+ float(values[0][1:-1]),
+ float(values[1][:-1]),
+ float(values[2][:-1]),
+ )
+ setattr(physics_control, name, value)
+ elif name == "torque_curve" or name == "steering_curve":
+ values = elements[1].split(" ")
+ value = parse_vector_list(values)
+ setattr(physics_control, name, value)
+
+ elif name == "use_gear_auto_box":
+ name = "use_gear_autobox"
+ value = elements[1] == "true"
+ setattr(physics_control, name, value)
+
+ elif "forward_gears" in name or "wheels" in name:
+ pass
+
+ else:
+ name = name.lower()
+ value = float(elements[1])
+ setattr(physics_control, name, value)
+
+ self.next_row()
+
+ setattr(physics_control, "forward_gears", forward_gears)
+ setattr(physics_control, "wheels", wheels)
+ frame_state["events"]["physics_control"].update(
+ {actor_id: physics_control}
+ )
if self.frame_row.startswith(" Traffic Light time events"):
self.next_row()
@@ -544,6 +563,9 @@ def parse_recorder_info(self):
)
self.next_row()
+ # 0.10.0 per-walker bone transforms — not consumed yet.
+ skip_indented_section(self, " Walkers Bones")
+
frames_info.append(frame_state)
return simulation_info, actors_info, frames_info
diff --git a/srunner/metrics/tools/recorder_chaos.py b/srunner/metrics/tools/recorder_chaos.py
new file mode 100644
index 000000000..4201fcd48
--- /dev/null
+++ b/srunner/metrics/tools/recorder_chaos.py
@@ -0,0 +1,421 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2026 Intel Corporation
+#
+# This work is licensed under the terms of the MIT license.
+# For a copy, see .
+
+"""
+Chaos-format helpers for the CARLA recorder text dump on UE5 / CARLA 0.10.0+.
+
+The recorder serialization of `Physics Control events` diverges structurally
+between the PhysX (0.9.x) and Chaos (0.10.0+) backends:
+
+PhysX (0.9.x)::
+
+ Physics Control events: 1
+ Id: 42
+ max_rpm = 5000
+ ...
+ wheel 1 = (tire_friction 3.5 damping_rate 0.25 ...)
+ ...
+ gear 1 = (ratio 4 down_ratio 0.5 up_ratio 0.65)
+
+Chaos (0.10.0+)::
+
+ Physics Control events: 1
+ Id: 42
+ max_torque = 550
+ use_gear_auto_box = true
+ ...
+ torque_curve = (0, 500) (5000, 500) ...
+ forward_gear_ratios:
+ gear 0: ratio 4
+ gear 1: ratio 2.5
+ reverse_gear_ratios:
+ gear 0: ratio 2.943
+ wheels:
+ wheel #0:
+ axle_type: offset: (0, 0, 0) wheel_radius: 35.5 ... friction_force_multiplier: 3.5 ...
+
+`wheel #N:` headers and their data lines start at column 0/1 (no shared indent
+with the rest of the physics block), so the per-version paths cannot share a
+walker. This module owns the Chaos walker; PhysX stays inline in the parsers.
+
+Field-name and type reference (citations in `carla-ue5/Docs/python_api.md`):
+- `carla.VehiclePhysicsControl` — §carla.VehiclePhysicsControl, lines ~2793-2895.
+- `carla.WheelPhysicsControl` — §carla.WheelPhysicsControl, lines ~3209-3320.
+"""
+
+from __future__ import print_function
+
+import carla
+
+
+# ---------------------------------------------------------------------------
+# carla.VehiclePhysicsControl — known Chaos fields the recorder dumps
+# ---------------------------------------------------------------------------
+
+_VEHICLE_SCALAR_FIELDS = frozenset({
+ "max_torque", "max_rpm", "idle_rpm", "brake_effect",
+ "rev_up_moi", "rev_down_rate",
+ "front_rear_split", "gear_change_time", "final_ratio",
+ "change_up_rpm", "change_down_rpm", "transmission_efficiency",
+ "mass", "drag_coefficient",
+ "chassis_width", "chassis_height", "downforce_coefficient",
+ "sleep_threshold", "sleep_slope_limit",
+})
+
+_VEHICLE_INT_FIELDS = frozenset({
+ "differential_type",
+})
+
+_VEHICLE_BOOL_FIELDS = frozenset({
+ "use_automatic_gears", "use_sweep_wheel_collision",
+})
+
+# Recorder dumps the legacy PhysX-flavoured name even on Chaos servers;
+# remap to the documented Chaos attribute.
+_VEHICLE_BOOL_ALIASES = {
+ "use_gear_auto_box": "use_automatic_gears",
+}
+
+# Field names the recorder dumps that aren't on Chaos VehiclePhysicsControl.
+# `MOI` is a server-internal artefact (Chaos exposes `rev_up_moi`, which is
+# a different quantity — do not silently remap).
+_VEHICLE_IGNORED = frozenset({
+ "MOI",
+})
+
+
+# ---------------------------------------------------------------------------
+# carla.WheelPhysicsControl — known Chaos fields the recorder dumps per wheel
+# ---------------------------------------------------------------------------
+
+_WHEEL_SCALAR_FIELDS = frozenset({
+ "axel_type",
+ "max_steer_angle", "wheel_radius", "wheel_width", "wheel_mass",
+ "cornering_stiffness", "friction_force_multiplier",
+ "side_slip_modifier", "slip_threshold", "skid_threshold",
+ "max_wheelspin_rotation",
+ "max_brake_torque", "max_handbrake_torque",
+ "suspension_force_offset",
+ "suspension_max_raise", "suspension_max_drop", "suspension_damping_ratio",
+ "wheel_load_ratio", "spring_rate", "spring_preload",
+ "suspension_smoothing", "rollbar_scaling",
+})
+
+_WHEEL_BOOL_FIELDS = frozenset({
+ "affected_by_steering", "affected_by_brake",
+ "affected_by_handbrake", "affected_by_engine",
+ "abs_enabled", "traction_control_enabled",
+})
+
+_WHEEL_VECTOR_FIELDS = frozenset({
+ "offset", "suspension_axis",
+})
+
+# Recorder ↔ Chaos Python attribute name remap.
+# - `max_hand_brake_torque` (recorder) vs `max_handbrake_torque` (Chaos attr).
+# - `axle_type` (recorder, correct spelling) vs `axel_type` (Chaos binding,
+# carla-ue5/Docs/python_api.md §carla.WheelPhysicsControl.axel_type — the
+# upstream attribute really is mis-spelled). Drop this alias once upstream
+# renames the binding.
+_WHEEL_FIELD_ALIASES = {
+ "max_hand_brake_torque": "max_handbrake_torque",
+ "axle_type": "axel_type",
+}
+
+# Runtime telemetry the recorder appends after the wheel struct; not on
+# WheelPhysicsControl itself and not useful for replay.
+_WHEEL_IGNORED = frozenset({
+ "wheel_index", "location", "old_location", "velocity",
+ "external_torque_combine_method", "lateral_slip_graph",
+ "sweep_shape", "sweep_type",
+})
+
+
+# ---------------------------------------------------------------------------
+# Parsing primitives
+# ---------------------------------------------------------------------------
+
+
+def _parse_vector3d(token_str):
+ """Parse a '(x, y, z)' substring into a carla.Vector3D."""
+ s = token_str.strip()
+ if not (s.startswith("(") and s.endswith(")")):
+ raise ValueError("not a vector literal: {!r}".format(token_str))
+ inner = s[1:-1]
+ parts = [p.strip() for p in inner.split(",")]
+ if len(parts) != 3:
+ raise ValueError("expected 3 components: {!r}".format(token_str))
+ return carla.Vector3D(float(parts[0]), float(parts[1]), float(parts[2]))
+
+
+def _parse_vector2d_list(s):
+ """Parse '(0, 500) (5000, 500) ...' into a list of carla.Vector2D.
+
+ Best-effort: a missing closing paren stops parsing at that point rather
+ than raising, so a truncated recorder line just yields the pairs it
+ could decode.
+ """
+ out = []
+ i = 0
+ while i < len(s):
+ if s[i] == "(":
+ j = s.find(")", i)
+ if j == -1:
+ break
+ inner = s[i + 1:j]
+ parts = [p.strip() for p in inner.split(",")]
+ if len(parts) == 2:
+ try:
+ out.append(carla.Vector2D(float(parts[0]), float(parts[1])))
+ except ValueError:
+ pass
+ i = j + 1
+ else:
+ i += 1
+ return out
+
+
+def _set_if_settable(obj, name, value):
+ """Best-effort setattr; tolerate read-only / unknown attributes."""
+ try:
+ setattr(obj, name, value)
+ except (AttributeError, TypeError, ValueError):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Wheel line tokenizer
+# ---------------------------------------------------------------------------
+
+
+def _parse_wheel_line(line):
+ """Build a Chaos `WheelPhysicsControl` from a single recorder wheel line.
+
+ The recorder emits each wheel on one line as space-separated `key: value`
+ pairs, where values may be scalars, vectors `(x, y, z)`, or empty (when
+ two keys are adjacent: `axle_type: offset: ...`). Unknown keys are
+ skipped — the format is not stable across upstream releases.
+ """
+ wheel = carla.WheelPhysicsControl()
+ tokens = line.split()
+
+ cur_key = None
+ cur_vals = []
+
+ def flush():
+ if cur_key is None:
+ return
+ _apply_wheel_field(wheel, cur_key, cur_vals)
+
+ for tok in tokens:
+ if tok.endswith(":") and not tok.startswith("("):
+ flush()
+ cur_key = tok[:-1]
+ cur_vals = []
+ else:
+ cur_vals.append(tok)
+ flush()
+ return wheel
+
+
+def _apply_wheel_field(wheel, key, val_tokens):
+ if key in _WHEEL_FIELD_ALIASES:
+ key = _WHEEL_FIELD_ALIASES[key]
+ if key in _WHEEL_IGNORED:
+ return
+ if not val_tokens:
+ return
+
+ if key in _WHEEL_VECTOR_FIELDS:
+ try:
+ _set_if_settable(wheel, key, _parse_vector3d(" ".join(val_tokens)))
+ except ValueError:
+ pass
+ return
+
+ if key in _WHEEL_BOOL_FIELDS:
+ try:
+ _set_if_settable(wheel, key, bool(int(val_tokens[0])))
+ except ValueError:
+ pass
+ return
+
+ if key in _WHEEL_SCALAR_FIELDS:
+ try:
+ _set_if_settable(wheel, key, float(val_tokens[0]))
+ except ValueError:
+ pass
+ return
+
+ # Unknown field — skip silently. The recorder may emit fields the local
+ # Python binding doesn't expose (or vice versa); robust parsing tolerates
+ # the gap rather than crashing the metrics tool.
+
+
+# ---------------------------------------------------------------------------
+# Vehicle scalar line (' key = value')
+# ---------------------------------------------------------------------------
+
+
+def _apply_vehicle_scalar(physics_control, name, value_str):
+ if name in _VEHICLE_IGNORED:
+ return
+
+ if name == "center_of_mass" and value_str.startswith("("):
+ try:
+ physics_control.center_of_mass = _parse_vector3d(value_str)
+ except (ValueError, AttributeError):
+ pass
+ return
+
+ if name in ("torque_curve", "steering_curve"):
+ try:
+ _set_if_settable(physics_control, name, _parse_vector2d_list(value_str))
+ except ValueError:
+ pass
+ return
+
+ if not value_str:
+ # Recorder may emit fields with empty values (`differential_type = `).
+ # Guard before the bool branches so an empty bool isn't silently
+ # coerced to False.
+ return
+
+ if name in _VEHICLE_BOOL_ALIASES:
+ _set_if_settable(physics_control,
+ _VEHICLE_BOOL_ALIASES[name],
+ value_str == "true")
+ return
+
+ if name in _VEHICLE_BOOL_FIELDS:
+ _set_if_settable(physics_control, name, value_str == "true")
+ return
+
+ if name in _VEHICLE_INT_FIELDS:
+ try:
+ _set_if_settable(physics_control, name, int(float(value_str)))
+ except ValueError:
+ pass
+ return
+
+ if name in _VEHICLE_SCALAR_FIELDS:
+ try:
+ _set_if_settable(physics_control, name, float(value_str))
+ except ValueError:
+ pass
+ return
+
+
+# ---------------------------------------------------------------------------
+# Shared section-skip helper for the new 0.10.0 recorder sections that the
+# 0.9.x text-walkers have no schema for. The pattern is identical in both
+# `MetricsParser` and `Osc2TraceParser`: header at one-space indent, body
+# rows at two-space indent.
+# ---------------------------------------------------------------------------
+
+
+def skip_indented_section(parser, header_prefix):
+ """If `parser.frame_row` starts with `header_prefix`, consume it and any
+ rows indented with two leading spaces. No-op otherwise."""
+ if not parser.frame_row.startswith(header_prefix):
+ return
+ parser.next_row()
+ while parser.frame_row.startswith(" "):
+ parser.next_row()
+
+
+# ---------------------------------------------------------------------------
+# Block walker — entry point used by metrics_parser / osc2_trace_parser
+# ---------------------------------------------------------------------------
+
+
+def _is_block_terminator(row):
+ """Return True if `row` ends the current Physics Control events block."""
+ if not row:
+ return True
+ if row.startswith("Frame "):
+ return True
+ # New top-level recorder section: ` ...`.
+ if row.startswith(" ") and len(row) > 1 and row[1].isupper():
+ return True
+ return False
+
+
+def parse_chaos_physics_block(parser, physics_dest):
+ """Parse a UE5 / Chaos Physics Control events block.
+
+ `parser` must expose the same attributes the in-file parsers do
+ (`frame_row`, `get_row_elements(indent, sep)`, `next_row()`). On entry,
+ `parser.frame_row` points at the first line AFTER ` Physics Control
+ events: N` (i.e. the first ` Id: N` row). On exit, `parser.frame_row`
+ points at the row that terminated the block (next section or `Frame`).
+
+ Populates `physics_dest` (the events.physics_control dict) in place.
+ """
+ while not _is_block_terminator(parser.frame_row):
+ row = parser.frame_row
+ if row.startswith(" Id:"):
+ _parse_chaos_actor(parser, physics_dest)
+ else:
+ # Stray / unexpected row — advance to avoid an infinite loop.
+ parser.next_row()
+
+
+def _parse_chaos_actor(parser, physics_dest):
+ elements = parser.get_row_elements(2, " ") # 'Id:' 'N'
+ actor_id = int(elements[1])
+ physics_control = carla.VehiclePhysicsControl()
+ forward_gear_ratios = []
+ reverse_gear_ratios = []
+ wheels = []
+ parser.next_row()
+
+ while True:
+ row = parser.frame_row
+ if _is_block_terminator(row) or row.startswith(" Id:"):
+ break
+
+ if row.startswith(" forward_gear_ratios:"):
+ parser.next_row()
+ while parser.frame_row.startswith(" gear "):
+ toks = parser.frame_row.split()
+ if len(toks) >= 4 and toks[0] == "gear" and toks[2] == "ratio":
+ forward_gear_ratios.append(float(toks[3]))
+ parser.next_row()
+ continue
+
+ if row.startswith(" reverse_gear_ratios:"):
+ parser.next_row()
+ while parser.frame_row.startswith(" gear "):
+ toks = parser.frame_row.split()
+ if len(toks) >= 4 and toks[0] == "gear" and toks[2] == "ratio":
+ reverse_gear_ratios.append(float(toks[3]))
+ parser.next_row()
+ continue
+
+ if row.startswith(" wheels:"):
+ parser.next_row()
+ while parser.frame_row.startswith("wheel #"):
+ parser.next_row()
+ if parser.frame_row.startswith(" ") and not parser.frame_row.startswith(" "):
+ wheels.append(_parse_wheel_line(parser.frame_row))
+ parser.next_row()
+ continue
+
+ if row.startswith(" ") and " = " in row:
+ name, _, value = row.lstrip().partition(" = ")
+ _apply_vehicle_scalar(physics_control, name.strip(), value.strip())
+ parser.next_row()
+ continue
+
+ # Unknown indented row — skip to avoid getting stuck.
+ parser.next_row()
+
+ _set_if_settable(physics_control, "forward_gear_ratios", forward_gear_ratios)
+ _set_if_settable(physics_control, "reverse_gear_ratios", reverse_gear_ratios)
+ _set_if_settable(physics_control, "wheels", wheels)
+ physics_dest[actor_id] = physics_control
diff --git a/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py b/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py
index c90c35c1a..6a49cada4 100644
--- a/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py
+++ b/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py
@@ -18,6 +18,7 @@
from srunner.scenariomanager.carla_data_provider import CarlaDataProvider
from srunner.scenariomanager.actorcontrols.basic_control import BasicControl
from srunner.scenariomanager.timer import GameTime
+from srunner.tools.carla_compat import IS_UE5
class VehicleVelocityControl(BasicControl):
@@ -37,13 +38,23 @@ def __init__(self, actor, args=None):
super(VehicleVelocityControl, self).__init__(actor)
- # Remove the friction of the wheels so that the velocities aren't reduced by the ground
- physics_control = self._actor.get_physics_control()
- wheels_control = physics_control.wheels
- for w in wheels_control:
- w.tire_friction = 0
- physics_control.wheels = wheels_control
- self._actor.apply_physics_control(physics_control)
+ # Remove the friction of the wheels so that the velocities aren't reduced by the ground.
+ # 0.9.x (PhysX): WheelPhysicsControl.tire_friction is the working knob.
+ # 0.10.0 (Chaos): no equivalent. Writes to either tire_friction or the documented
+ # Chaos parameter friction_force_multiplier are silently accepted by
+ # apply_physics_control but never reach the simulation (read-back keeps the
+ # default), and set_simulate_physics(False) breaks set_target_velocity entirely.
+ # No working UE5 API exists to zero wheel friction for this controller's
+ # kinematic-injection use, so on UE5 we skip the setup. set_target_velocity in
+ # run_step still tracks well when the requested velocity is aligned with the
+ # vehicle's heading; expect degraded tracking on sharp lateral / cornering moves.
+ if not IS_UE5:
+ physics_control = self._actor.get_physics_control()
+ wheels_control = physics_control.wheels
+ for w in wheels_control:
+ w.tire_friction = 0
+ physics_control.wheels = wheels_control
+ self._actor.apply_physics_control(physics_control)
# This is the maximum amount of time used to aim the vehicle towards the next point.
# Used to avoid weird lateral slides if the time between point is high.
diff --git a/srunner/scenariomanager/carla_data_provider.py b/srunner/scenariomanager/carla_data_provider.py
index a7a8fa629..3b57d1d80 100644
--- a/srunner/scenariomanager/carla_data_provider.py
+++ b/srunner/scenariomanager/carla_data_provider.py
@@ -21,6 +21,8 @@
import carla
from agents.navigation.global_route_planner import GlobalRoutePlanner
+from srunner.tools.carla_compat import actor_blueprint_categories, resolve_blueprint_id
+
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Iterable
@@ -668,37 +670,35 @@ def check_attribute_value(blueprint, name, value):
return False
- _actor_blueprint_categories = {
- 'car': 'vehicle.tesla.model3',
- 'van': 'vehicle.volkswagen.t2',
- 'truck': 'vehicle.carlamotors.carlacola',
- 'trailer': '',
- 'semitrailer': '',
- 'bus': 'vehicle.volkswagen.t2',
- 'motorbike': 'vehicle.kawasaki.ninja',
- 'bicycle': 'vehicle.diamondback.century',
- 'train': '',
- 'tram': '',
- 'pedestrian': 'walker.pedestrian.0001',
- 'misc': 'static.prop.streetbarrier'
- }
+ _actor_blueprint_categories = actor_blueprint_categories()
+
+ # Translate legacy ids (e.g. vehicle.lincoln.mkz_2017) to the engine-native
+ # equivalent on UE5; passthrough on 0.9.x.
+ resolved_model = resolve_blueprint_id(model)
# Set the model
- try:
- blueprints = CarlaDataProvider._blueprint_library.filter(model)
- if attribute_filter is not None:
- for key, value in attribute_filter.items():
- blueprints = [x for x in blueprints if check_attribute_value(x, key, value)]
+ blueprints = list(CarlaDataProvider._blueprint_library.filter(resolved_model))
+ if attribute_filter is not None:
+ for key, value in attribute_filter.items():
+ blueprints = [x for x in blueprints if check_attribute_value(x, key, value)]
+ if blueprints:
blueprint = CarlaDataProvider._rng.choice(blueprints)
- except ValueError:
- # The model is not part of the blueprint library. Let's take a default one for the given category
- bp_filter = "vehicle.*"
- new_model = _actor_blueprint_categories[actor_category]
- if new_model != '':
- bp_filter = new_model
- print("WARNING: Actor model {} not available. Using instead {}".format(model, new_model))
- blueprint = CarlaDataProvider._rng.choice(CarlaDataProvider._blueprint_library.filter(bp_filter))
+ else:
+ # The model is not part of the blueprint library. Try the category fallback,
+ # then degrade to 'vehicle.*' so an empty filter never throws uncaught.
+ fallback_model = _actor_blueprint_categories.get(actor_category, '')
+ print("WARNING: Actor model {} not available. Using instead {}".format(
+ model, fallback_model or 'vehicle.*'))
+ fallback_candidates = list(CarlaDataProvider._blueprint_library.filter(fallback_model)) \
+ if fallback_model else []
+ if not fallback_candidates:
+ fallback_candidates = list(CarlaDataProvider._blueprint_library.filter("vehicle.*"))
+ if not fallback_candidates:
+ raise ValueError(
+ "No blueprint available for model {} (category {}); "
+ "blueprint library is empty even for 'vehicle.*'".format(model, actor_category))
+ blueprint = CarlaDataProvider._rng.choice(fallback_candidates)
# Set the color
if color:
diff --git a/srunner/scenariomanager/lights_sim.py b/srunner/scenariomanager/lights_sim.py
index 4fc5ad384..5e3b97d07 100644
--- a/srunner/scenariomanager/lights_sim.py
+++ b/srunner/scenariomanager/lights_sim.py
@@ -15,6 +15,7 @@
import carla
from srunner.scenariomanager.carla_data_provider import CarlaDataProvider
+from srunner.tools.carla_compat import IS_UE5
class RouteLightsBehavior(py_trees.behaviour.Behaviour):
@@ -33,6 +34,10 @@ class RouteLightsBehavior(py_trees.behaviour.Behaviour):
# In cases where more than one weather conditition is active, decrease the thresholds
COMBINED_THRESHOLD = 10
+ # Process-wide latch so the UE5 LightManager-disabled notice fires once,
+ # not once per RouteLightsBehavior instance.
+ _ue5_notice_emitted = False
+
def __init__(self, ego_vehicle, radius=50, radius_increase=15, name="LightsBehavior"):
"""
Setup parameters
@@ -42,8 +47,25 @@ def __init__(self, ego_vehicle, radius=50, radius_increase=15, name="LightsBehav
self._radius = radius
self._radius_increase = radius_increase
self._world = CarlaDataProvider.get_world()
- self._light_manager = self._world.get_lightmanager()
- self._light_manager.set_day_night_cycle(False)
+ # 0.9.x (UE4): LightManager owns the street-light registry and the
+ # automatic sun motion (set_day_night_cycle). We disable the cycle so
+ # weather.sun_altitude_angle drives night-mode unambiguously from below.
+ # 0.10.0 (UE5): LightManager was removed entirely. There is no auto sun
+ # motion to disable, and no documented per-Light runtime toggle API
+ # (MapLayer.StreetLights is a map-layer load flag, not a runtime
+ # toggle). _get_night_mode below already reads weather.sun_altitude_angle
+ # which is the documented UE5 day/night control, so on UE5 we skip the
+ # manager entirely and only drive vehicle lights.
+ if IS_UE5:
+ self._light_manager = None
+ if not RouteLightsBehavior._ue5_notice_emitted:
+ print("[RouteLightsBehavior] CARLA 0.10.0 / UE5 detected: "
+ "street lights will not be controlled (no LightManager API). "
+ "Vehicle lights are still driven from weather.sun_altitude_angle.")
+ RouteLightsBehavior._ue5_notice_emitted = True
+ else:
+ self._light_manager = self._world.get_lightmanager()
+ self._light_manager.set_day_night_cycle(False)
self._vehicle_lights = carla.VehicleLightState.Position | carla.VehicleLightState.LowBeam
self._prev_night_mode = False
@@ -90,21 +112,23 @@ def _turn_close_lights_on(self, location):
ego_speed = CarlaDataProvider.get_velocity(self._ego_vehicle)
radius = max(self._radius, self._radius_increase * ego_speed)
- # Street lights
- on_lights = []
- off_lights = []
+ # Street lights — only on engines that expose a LightManager (UE4 / 0.9.x).
+ # UE5 / 0.10.0 has no documented per-Light runtime toggle, so we skip it.
+ if self._light_manager is not None:
+ on_lights = []
+ off_lights = []
- all_lights = self._light_manager.get_all_lights()
- for light in all_lights:
- if light.location.distance(location) > radius:
- if light.is_on:
- off_lights.append(light)
- else:
- if not light.is_on:
- on_lights.append(light)
+ all_lights = self._light_manager.get_all_lights()
+ for light in all_lights:
+ if light.location.distance(location) > radius:
+ if light.is_on:
+ off_lights.append(light)
+ else:
+ if not light.is_on:
+ on_lights.append(light)
- self._light_manager.turn_on(on_lights)
- self._light_manager.turn_off(off_lights)
+ self._light_manager.turn_on(on_lights)
+ self._light_manager.turn_off(off_lights)
# Vehicles
all_vehicles = CarlaDataProvider.get_all_actors().filter('*vehicle.*')
@@ -127,9 +151,11 @@ def _turn_close_lights_on(self, location):
def _turn_all_lights_off(self):
"""Turns off the lights of all object"""
- all_lights = self._light_manager.get_all_lights()
- off_lights = [l for l in all_lights if l.is_on]
- self._light_manager.turn_off(off_lights)
+ # Street lights — only on engines that expose a LightManager (UE4 / 0.9.x).
+ if self._light_manager is not None:
+ all_lights = self._light_manager.get_all_lights()
+ off_lights = [l for l in all_lights if l.is_on]
+ self._light_manager.turn_off(off_lights)
# Vehicles
all_vehicles = CarlaDataProvider.get_all_actors().filter('*vehicle.*')
@@ -146,5 +172,6 @@ def _turn_all_lights_off(self):
self._ego_vehicle.set_light_state(carla.VehicleLightState(lights))
def terminate(self, new_status):
- self._light_manager.set_day_night_cycle(True)
+ if self._light_manager is not None:
+ self._light_manager.set_day_night_cycle(True)
return super().terminate(new_status)
diff --git a/srunner/tools/carla_compat.py b/srunner/tools/carla_compat.py
new file mode 100644
index 000000000..921159e53
--- /dev/null
+++ b/srunner/tools/carla_compat.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2026 Intel Corporation
+#
+# This work is licensed under the terms of the MIT license.
+# For a copy, see .
+
+"""
+Runtime compatibility shim between CARLA 0.9.x (UE4 / PhysX) and CARLA 0.10.0+
+(UE5 / Chaos). Exposes a version probe, an `IS_UE5` engine flag, and
+version-aware lookup tables that translate legacy blueprint ids and
+per-category fallbacks to the ids that actually ship on the running server.
+
+Version mapping:
+ 0.9.x -> UE4 / PhysX (IS_UE5 == False)
+ 0.10.x -> UE5 / Chaos (IS_UE5 == True)
+
+Detection order (highest priority first):
+ 1. `SR_CARLA_VERSION` environment variable, if set. Useful when the
+ installed wheel version disagrees with the running server, or for
+ smoke tests that need to force a path. Value is a version string
+ (e.g. `0.9.16`, `0.10.0`).
+ 2. The `carla` python package version reported by importlib.metadata.
+
+Design notes:
+- 0.10.0 renamed and trimmed the vehicle/walker catalogue. Several 0.9.x ids
+ (tesla.model3, volkswagen.t2, kawasaki.ninja, diamondback.century, ...) have
+ no direct UE5 equivalent. For those, the alias table points at the closest
+ documented substitute from the 0.10.0 catalogue rather than failing the
+ spawn outright. See .claude/reports/carla_ue5_0.10.0_compatibility_gap_analysis.md
+ section G1 for the full inventory.
+- The shim never invents an id: every UE5 target is taken from the 0.10.0
+ vehicle/walker listing observed in the running server.
+- On 0.9.x the lookup is a passthrough, so legacy behaviour is preserved.
+"""
+
+from __future__ import print_function
+
+import os
+
+try:
+ from importlib.metadata import version as _carla_version, PackageNotFoundError
+
+ def _read_carla_version_str():
+ return _carla_version("carla")
+except ImportError:
+ import pkg_resources
+
+ PackageNotFoundError = pkg_resources.DistributionNotFound # type: ignore[misc,assignment]
+
+ def _read_carla_version_str():
+ return pkg_resources.get_distribution("carla").version
+
+from packaging.version import Version
+
+
+UE5_THRESHOLD = Version("0.10.0")
+"""Version at which CARLA switches from UE4 / PhysX to UE5 / Chaos."""
+
+_VERSION_OVERRIDE_ENV = "SR_CARLA_VERSION"
+
+
+def _detect_carla_version():
+ override = os.environ.get(_VERSION_OVERRIDE_ENV)
+ if override:
+ return Version(override)
+ try:
+ return Version(_read_carla_version_str())
+ except PackageNotFoundError as exc:
+ raise RuntimeError(
+ "scenario_runner could not determine the CARLA version: the `carla` "
+ "Python package is not installed. Install the CARLA wheel matching "
+ "your server, or set the {} environment variable "
+ "(e.g. SR_CARLA_VERSION=0.10.0).".format(_VERSION_OVERRIDE_ENV)
+ ) from exc
+
+
+CARLA_VERSION = _detect_carla_version()
+"""Resolved CARLA version (env override `SR_CARLA_VERSION` or installed wheel)."""
+
+IS_UE5 = CARLA_VERSION >= UE5_THRESHOLD
+"""True when running against CARLA 0.10.0+ (UE5 / Chaos)."""
+
+IS_UE4 = not IS_UE5
+"""True when running against CARLA 0.9.x (UE4 / PhysX). Convenience alias."""
+
+
+def carla_version():
+ """Return the resolved CARLA version as a packaging.Version."""
+ return CARLA_VERSION
+
+
+def is_ue5():
+ """Function form of IS_UE5 (kept for callers that prefer call-style)."""
+ return IS_UE5
+
+
+# ---------------------------------------------------------------------------
+# Blueprint id aliases — 0.9.x identifier -> 0.10.0 identifier
+#
+# Targets are the actual ids advertised by a live CARLA 0.10.0 server's
+# blueprint library. When a 0.9.x model has no direct UE5 sibling (tesla,
+# kawasaki, diamondback, etc.) we fall through to the closest documented
+# substitute so spawns succeed; that substitution is recorded here, not
+# fabricated at call time.
+# ---------------------------------------------------------------------------
+
+_UE5_VEHICLE_ALIASES = {
+ # Direct renames
+ 'vehicle.lincoln.mkz_2017': 'vehicle.lincoln.mkz',
+ 'vehicle.lincoln.mkz2017': 'vehicle.lincoln.mkz',
+ 'vehicle.audi.tt': 'vehicle.ue4.audi.tt',
+ 'vehicle.dodge.charger_police_2020': 'vehicle.dodgecop.charger',
+ 'vehicle.carlamotors.carlacola': 'vehicle.carlacola.actors',
+
+ # Removed in 0.10.0 — substituted to closest catalogue entry
+ 'vehicle.tesla.model3': 'vehicle.lincoln.mkz',
+ 'vehicle.volkswagen.t2': 'vehicle.sprinter.mercedes',
+ 'vehicle.nissan.micra': 'vehicle.mini.cooper',
+ 'vehicle.kawasaki.ninja': 'vehicle.mini.cooper',
+ 'vehicle.diamondback.century': 'vehicle.mini.cooper',
+ 'vehicle.bh.crossbike': 'vehicle.mini.cooper',
+ 'vehicle.gazelle.omafiets': 'vehicle.mini.cooper',
+}
+
+_UE5_WALKER_ALIASES = {
+ # 0.10.0 pedestrians start at .0015 (no .0001..0014).
+ 'walker.pedestrian.0001': 'walker.pedestrian.0015',
+}
+
+
+# ---------------------------------------------------------------------------
+# Per-category fallback used by CarlaDataProvider.create_blueprint when a
+# requested model id is missing from the library. Values must resolve on the
+# active server.
+# ---------------------------------------------------------------------------
+
+_LEGACY_CATEGORY_FALLBACKS = {
+ 'car': 'vehicle.tesla.model3',
+ 'van': 'vehicle.volkswagen.t2',
+ 'truck': 'vehicle.carlamotors.carlacola',
+ 'trailer': '',
+ 'semitrailer': '',
+ 'bus': 'vehicle.volkswagen.t2',
+ 'motorbike': 'vehicle.kawasaki.ninja',
+ 'bicycle': 'vehicle.diamondback.century',
+ 'train': '',
+ 'tram': '',
+ 'pedestrian': 'walker.pedestrian.0001',
+ 'misc': 'static.prop.streetbarrier',
+}
+
+_UE5_CATEGORY_FALLBACKS = {
+ 'car': 'vehicle.lincoln.mkz',
+ 'van': 'vehicle.sprinter.mercedes',
+ 'truck': 'vehicle.carlacola.actors',
+ 'trailer': '',
+ 'semitrailer': '',
+ 'bus': 'vehicle.sprinter.mercedes',
+ 'motorbike': 'vehicle.mini.cooper',
+ 'bicycle': 'vehicle.mini.cooper',
+ 'train': '',
+ 'tram': '',
+ 'pedestrian': 'walker.pedestrian.0015',
+ 'misc': 'static.prop.streetbarrier',
+}
+
+
+def resolve_blueprint_id(model):
+ """Translate a possibly-legacy blueprint id to one valid on the running server.
+
+ On 0.9.x this is a passthrough. On 0.10.0+ it applies the documented
+ vehicle / walker rename tables. Unknown ids pass through unchanged so the
+ existing wildcard `filter()` path still has a chance.
+ """
+ if not is_ue5() or not isinstance(model, str):
+ return model
+ if model in _UE5_VEHICLE_ALIASES:
+ return _UE5_VEHICLE_ALIASES[model]
+ if model in _UE5_WALKER_ALIASES:
+ return _UE5_WALKER_ALIASES[model]
+ return model
+
+
+def actor_blueprint_categories():
+ """Return the per-category fallback dict appropriate for the running server."""
+ return _UE5_CATEGORY_FALLBACKS if is_ue5() else _LEGACY_CATEGORY_FALLBACKS
diff --git a/srunner/tools/scenario_parser.py b/srunner/tools/scenario_parser.py
index d35e7ed90..4507a62ea 100644
--- a/srunner/tools/scenario_parser.py
+++ b/srunner/tools/scenario_parser.py
@@ -17,6 +17,38 @@
from srunner.scenarioconfigs.scenario_configuration import ScenarioConfiguration, ActorConfigurationData
from srunner.scenarioconfigs.route_scenario_configuration import RouteConfiguration
+from srunner.tools.carla_compat import IS_UE5
+
+
+def _example_config_globs(extension):
+ """Return the list of glob patterns for example config files of the given extension,
+ in priority order (highest priority first).
+
+ On UE5 servers (0.10.0+) we read configs from srunner/examples_ue5/ first so that
+ scenarios authored for Town10HD_Opt take precedence over the legacy Town01-05
+ entries when the same scenario name is defined in both locations. On 0.9.x only
+ the legacy examples directory is read.
+ """
+ root = os.getenv('SCENARIO_RUNNER_ROOT', "./")
+ patterns = []
+ if IS_UE5:
+ patterns.append("{}/srunner/examples_ue5/*.{}".format(root, extension))
+ patterns.append("{}/srunner/examples/*.{}".format(root, extension))
+ return patterns
+
+
+def _collect_example_config_files(extensions):
+ """Glob all example config files for the active engine version, in priority order.
+
+ Each glob result is sorted so the within-directory walk is deterministic
+ across platforms; the across-directory priority (examples_ue5 before
+ examples on UE5) is preserved by the outer loop ordering.
+ """
+ files = []
+ for ext in extensions:
+ for pattern in _example_config_globs(ext):
+ files.extend(sorted(glob.glob(pattern)))
+ return files
class ScenarioConfigurationParser(object):
@@ -43,8 +75,9 @@ def parse_scenario_configuration(scenario_name, additional_config_file_name):
scenario_group = False
scenario_configurations = []
+ seen_names = set()
- list_of_config_files = glob.glob("{}/srunner/examples/*.xml".format(os.getenv('SCENARIO_RUNNER_ROOT', "./")))
+ list_of_config_files = _collect_example_config_files(["xml"])
if additional_config_file_name != '':
list_of_config_files.append(additional_config_file_name)
@@ -63,6 +96,12 @@ def parse_scenario_configuration(scenario_name, additional_config_file_name):
elif scenario_group and scenario_config_type != scenario_name:
continue
+ # Dedupe by scenario name. Files are walked in priority order
+ # (examples_ue5 first on UE5), so the first occurrence wins.
+ if scenario_config_name in seen_names:
+ continue
+ seen_names.add(scenario_config_name)
+
config = ScenarioConfiguration()
config.town = scenario.attrib.get('town')
config.name = scenario_config_name
@@ -100,8 +139,7 @@ def get_list_of_scenarios(additional_config_file_name):
Parse *all* config files and provide a list with all scenarios @return
"""
- list_of_config_files = glob.glob("{}/srunner/examples/*.xml".format(os.getenv('SCENARIO_RUNNER_ROOT', "./")))
- list_of_config_files += glob.glob("{}/srunner/examples/*.xosc".format(os.getenv('SCENARIO_RUNNER_ROOT', "./")))
+ list_of_config_files = _collect_example_config_files(["xml", "xosc"])
if additional_config_file_name != '':
list_of_config_files.append(additional_config_file_name)
diff --git a/tests/fixtures/recorder_ue5_town10.txt b/tests/fixtures/recorder_ue5_town10.txt
new file mode 100644
index 000000000..0f1fb59b1
Binary files /dev/null and b/tests/fixtures/recorder_ue5_town10.txt differ
diff --git a/tests/test_carla_compat.py b/tests/test_carla_compat.py
new file mode 100644
index 000000000..ad92273a6
--- /dev/null
+++ b/tests/test_carla_compat.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2026 Intel Corporation
+#
+# This work is licensed under the terms of the MIT license.
+# For a copy, see .
+
+"""
+Unit tests for `srunner.tools.carla_compat`.
+
+Carla-free: forces version detection via the `SR_CARLA_VERSION` env override
+and reloads the module per test so both the UE4 (0.9.x / PhysX) and UE5
+(0.10.x / Chaos) code paths are exercised from the same process.
+"""
+
+from __future__ import print_function
+
+import importlib
+import os
+import sys
+import unittest
+
+# Make `import srunner.tools.carla_compat` resolvable when this file is run
+# directly from `tests/` (the existing OSC2 unit suites do the same).
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _REPO_ROOT not in sys.path:
+ sys.path.insert(0, _REPO_ROOT)
+
+
+def _reload_carla_compat(version):
+ """Reload `srunner.tools.carla_compat` under a forced version."""
+ os.environ["SR_CARLA_VERSION"] = version
+ sys.modules.pop("srunner.tools.carla_compat", None)
+ return importlib.import_module("srunner.tools.carla_compat")
+
+
+class CarlaCompatVersionDetectionTests(unittest.TestCase):
+ """Version probe + IS_UE5 / IS_UE4 flags."""
+
+ def setUp(self):
+ self._saved = os.environ.get("SR_CARLA_VERSION")
+
+ def tearDown(self):
+ if self._saved is None:
+ os.environ.pop("SR_CARLA_VERSION", None)
+ else:
+ os.environ["SR_CARLA_VERSION"] = self._saved
+ sys.modules.pop("srunner.tools.carla_compat", None)
+
+ def test_env_override_resolves_to_version(self):
+ from packaging.version import Version
+ compat = _reload_carla_compat("0.10.0")
+ self.assertEqual(compat.CARLA_VERSION, Version("0.10.0"))
+
+ def test_is_ue5_true_at_threshold(self):
+ compat = _reload_carla_compat("0.10.0")
+ self.assertTrue(compat.IS_UE5)
+ self.assertFalse(compat.IS_UE4)
+ self.assertTrue(compat.is_ue5())
+
+ def test_is_ue5_true_above_threshold(self):
+ compat = _reload_carla_compat("0.10.1")
+ self.assertTrue(compat.IS_UE5)
+
+ def test_is_ue5_false_for_0_9_x(self):
+ compat = _reload_carla_compat("0.9.16")
+ self.assertFalse(compat.IS_UE5)
+ self.assertTrue(compat.IS_UE4)
+ self.assertFalse(compat.is_ue5())
+
+ def test_is_ue5_false_for_0_9_14_minimum(self):
+ compat = _reload_carla_compat("0.9.14")
+ self.assertFalse(compat.IS_UE5)
+
+
+class CarlaCompatBlueprintAliasTests(unittest.TestCase):
+ """`resolve_blueprint_id` translation tables — UE5 alias, UE4 passthrough."""
+
+ def setUp(self):
+ self._saved = os.environ.get("SR_CARLA_VERSION")
+
+ def tearDown(self):
+ if self._saved is None:
+ os.environ.pop("SR_CARLA_VERSION", None)
+ else:
+ os.environ["SR_CARLA_VERSION"] = self._saved
+ sys.modules.pop("srunner.tools.carla_compat", None)
+
+ def test_ue5_renames_lincoln_mkz(self):
+ compat = _reload_carla_compat("0.10.0")
+ self.assertEqual(
+ compat.resolve_blueprint_id("vehicle.lincoln.mkz_2017"),
+ "vehicle.lincoln.mkz",
+ )
+
+ def test_ue5_renames_audi_tt_with_ue4_prefix(self):
+ compat = _reload_carla_compat("0.10.0")
+ self.assertEqual(
+ compat.resolve_blueprint_id("vehicle.audi.tt"),
+ "vehicle.ue4.audi.tt",
+ )
+
+ def test_ue5_substitutes_removed_tesla_model3(self):
+ compat = _reload_carla_compat("0.10.0")
+ # tesla.model3 is gone in 0.10.0 — alias substitutes a documented car.
+ self.assertEqual(
+ compat.resolve_blueprint_id("vehicle.tesla.model3"),
+ "vehicle.lincoln.mkz",
+ )
+
+ def test_ue5_substitutes_removed_two_wheelers(self):
+ compat = _reload_carla_compat("0.10.0")
+ for legacy in (
+ "vehicle.diamondback.century",
+ "vehicle.bh.crossbike",
+ "vehicle.gazelle.omafiets",
+ "vehicle.kawasaki.ninja",
+ ):
+ self.assertEqual(
+ compat.resolve_blueprint_id(legacy),
+ "vehicle.mini.cooper",
+ msg="expected mini.cooper substitute for {!r}".format(legacy),
+ )
+
+ def test_ue5_remaps_first_pedestrian(self):
+ compat = _reload_carla_compat("0.10.0")
+ # 0.10.0 pedestrians start at .0015 — .0001 is gone.
+ self.assertEqual(
+ compat.resolve_blueprint_id("walker.pedestrian.0001"),
+ "walker.pedestrian.0015",
+ )
+
+ def test_ue5_passes_unknown_ids_through(self):
+ compat = _reload_carla_compat("0.10.0")
+ # Wildcard or unknown ids should fall through so the existing
+ # filter() path still gets a chance.
+ self.assertEqual(
+ compat.resolve_blueprint_id("vehicle.*"),
+ "vehicle.*",
+ )
+ self.assertEqual(
+ compat.resolve_blueprint_id("vehicle.future_model.x"),
+ "vehicle.future_model.x",
+ )
+
+ def test_ue5_non_string_inputs_pass_through(self):
+ compat = _reload_carla_compat("0.10.0")
+ self.assertIsNone(compat.resolve_blueprint_id(None))
+ self.assertEqual(compat.resolve_blueprint_id(42), 42)
+
+ def test_ue4_is_a_passthrough(self):
+ compat = _reload_carla_compat("0.9.16")
+ for legacy in (
+ "vehicle.lincoln.mkz_2017",
+ "vehicle.tesla.model3",
+ "vehicle.audi.tt",
+ "walker.pedestrian.0001",
+ "vehicle.diamondback.century",
+ ):
+ self.assertEqual(compat.resolve_blueprint_id(legacy), legacy)
+
+
+class CarlaCompatCategoryFallbackTests(unittest.TestCase):
+ """`actor_blueprint_categories()` — per-category fallback ids per engine."""
+
+ def setUp(self):
+ self._saved = os.environ.get("SR_CARLA_VERSION")
+
+ def tearDown(self):
+ if self._saved is None:
+ os.environ.pop("SR_CARLA_VERSION", None)
+ else:
+ os.environ["SR_CARLA_VERSION"] = self._saved
+ sys.modules.pop("srunner.tools.carla_compat", None)
+
+ def test_ue5_car_fallback_resolves_on_0_10_0(self):
+ compat = _reload_carla_compat("0.10.0")
+ cats = compat.actor_blueprint_categories()
+ self.assertEqual(cats["car"], "vehicle.lincoln.mkz")
+ self.assertEqual(cats["pedestrian"], "walker.pedestrian.0015")
+ # Two-wheelers absent from 0.10.0 — fall back to a four-wheel car.
+ self.assertEqual(cats["bicycle"], "vehicle.mini.cooper")
+ self.assertEqual(cats["motorbike"], "vehicle.mini.cooper")
+
+ def test_ue4_car_fallback_keeps_legacy_ids(self):
+ compat = _reload_carla_compat("0.9.16")
+ cats = compat.actor_blueprint_categories()
+ self.assertEqual(cats["car"], "vehicle.tesla.model3")
+ self.assertEqual(cats["pedestrian"], "walker.pedestrian.0001")
+ self.assertEqual(cats["bicycle"], "vehicle.diamondback.century")
+ self.assertEqual(cats["motorbike"], "vehicle.kawasaki.ninja")
+
+ def test_both_engines_expose_the_same_categories(self):
+ ue4 = _reload_carla_compat("0.9.16").actor_blueprint_categories()
+ ue5 = _reload_carla_compat("0.10.0").actor_blueprint_categories()
+ self.assertEqual(set(ue4.keys()), set(ue5.keys()))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_recorder_chaos.py b/tests/test_recorder_chaos.py
new file mode 100644
index 000000000..140f5d957
--- /dev/null
+++ b/tests/test_recorder_chaos.py
@@ -0,0 +1,518 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2026 Intel Corporation
+#
+# This work is licensed under the terms of the MIT license.
+# For a copy, see .
+
+"""
+Unit tests for `srunner.metrics.tools.recorder_chaos` (the UE5 / Chaos
+recorder Physics Control walker) and the new UE5 section-skip handlers
+added to `MetricsParser` / `Osc2TraceParser`.
+
+Carla-free: installs a minimal stub `carla` module into `sys.modules`
+before importing the targets, so the suite runs in any Python env with
+no CARLA wheel installed.
+"""
+
+from __future__ import print_function
+
+import os
+import sys
+import types
+import unittest
+
+
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _REPO_ROOT not in sys.path:
+ sys.path.insert(0, _REPO_ROOT)
+
+_FIXTURE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures")
+
+
+# ---------------------------------------------------------------------------
+# Fake `carla` module — covers everything the metrics parsers reach for.
+# ---------------------------------------------------------------------------
+
+def _install_fake_carla():
+ """Install a stub `carla` module if (and only if) one isn't already loaded.
+
+ Real `carla` wheels expose strict C++ bindings; the fake stays permissive
+ so unit tests aren't held hostage to upstream attribute-set semantics.
+ """
+ if "carla" in sys.modules and hasattr(sys.modules["carla"], "VehiclePhysicsControl"):
+ return
+
+ fake = types.ModuleType("carla")
+
+ class _Bag(object):
+ """Permissive object that accepts kwargs and arbitrary setattrs."""
+
+ def __init__(self, *args, **kwargs):
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+ class Vector3D(object):
+ def __init__(self, x=0.0, y=0.0, z=0.0):
+ self.x = float(x)
+ self.y = float(y)
+ self.z = float(z)
+
+ def __sub__(self, other):
+ return Vector3D(self.x - other.x, self.y - other.y, self.z - other.z)
+
+ def __truediv__(self, scalar):
+ return Vector3D(self.x / scalar, self.y / scalar, self.z / scalar)
+
+ class Vector2D(object):
+ def __init__(self, x=0.0, y=0.0):
+ self.x = float(x)
+ self.y = float(y)
+
+ class Location(Vector3D):
+ pass
+
+ class Rotation(object):
+ def __init__(self, pitch=0.0, yaw=0.0, roll=0.0):
+ self.pitch = float(pitch)
+ self.yaw = float(yaw)
+ self.roll = float(roll)
+
+ class Transform(object):
+ def __init__(self, location=None, rotation=None):
+ self.location = location or Location()
+ self.rotation = rotation or Rotation()
+
+ class BoundingBox(object):
+ def __init__(self, location=None, extent=None):
+ self.location = location or Location()
+ self.extent = extent or Vector3D()
+
+ class VehicleControl(_Bag):
+ pass
+
+ class Color(object):
+ def __init__(self, r=0, g=0, b=0):
+ self.r, self.g, self.b = r, g, b
+
+ class _Enum(object):
+ def __init__(self, name):
+ self.name = name
+
+ def __repr__(self):
+ return "".format(self.name)
+
+ class VehicleLightState(object):
+ NONE = _Enum("None")
+ Position = _Enum("Position")
+ LowBeam = _Enum("LowBeam")
+ HighBeam = _Enum("HighBeam")
+ Brake = _Enum("Brake")
+ RightBlinker = _Enum("RightBlinker")
+ LeftBlinker = _Enum("LeftBlinker")
+ Reverse = _Enum("Reverse")
+ Fog = _Enum("Fog")
+ Interior = _Enum("Interior")
+ Special1 = _Enum("Special1")
+ Special2 = _Enum("Special2")
+
+ class TrafficLightState(object):
+ Red = _Enum("Red")
+ Yellow = _Enum("Yellow")
+ Green = _Enum("Green")
+ Off = _Enum("Off")
+ Unknown = _Enum("Unknown")
+
+ class LightGroup(object):
+ NONE = _Enum("LightGroup.NONE")
+
+ class LightState(_Bag):
+ pass
+
+ class VehiclePhysicsControl(_Bag):
+ pass
+
+ class WheelPhysicsControl(_Bag):
+ pass
+
+ class GearPhysicsControl(_Bag):
+ pass
+
+ fake.Vector3D = Vector3D
+ fake.Vector2D = Vector2D
+ fake.Location = Location
+ fake.Rotation = Rotation
+ fake.Transform = Transform
+ fake.BoundingBox = BoundingBox
+ fake.VehicleControl = VehicleControl
+ fake.Color = Color
+ fake.VehicleLightState = VehicleLightState
+ fake.TrafficLightState = TrafficLightState
+ fake.LightGroup = LightGroup
+ fake.LightState = LightState
+ fake.VehiclePhysicsControl = VehiclePhysicsControl
+ fake.WheelPhysicsControl = WheelPhysicsControl
+ fake.GearPhysicsControl = GearPhysicsControl
+
+ sys.modules["carla"] = fake
+
+
+_install_fake_carla()
+
+# Snapshot SR_CARLA_VERSION so tearDownModule can restore it for any
+# downstream suite that runs in the same process.
+_PREV_SR_CARLA_VERSION = os.environ.get("SR_CARLA_VERSION")
+
+# Force IS_UE5 before importing modules that snapshot it at import time.
+os.environ["SR_CARLA_VERSION"] = "0.10.0"
+sys.modules.pop("srunner.tools.carla_compat", None)
+sys.modules.pop("srunner.metrics.tools.recorder_chaos", None)
+sys.modules.pop("srunner.metrics.tools.metrics_parser", None)
+sys.modules.pop("srunner.metrics.tools.osc2_trace_parser", None)
+
+from srunner.metrics.tools import recorder_chaos
+from srunner.metrics.tools.metrics_parser import MetricsParser
+from srunner.metrics.tools.osc2_trace_parser import Osc2TraceParser
+
+
+def tearDownModule():
+ """Restore the SR_CARLA_VERSION env var so the suite is hermetic."""
+ if _PREV_SR_CARLA_VERSION is None:
+ os.environ.pop("SR_CARLA_VERSION", None)
+ else:
+ os.environ["SR_CARLA_VERSION"] = _PREV_SR_CARLA_VERSION
+ # Drop the carla_compat snapshot so later suites re-detect with the
+ # restored env (or with no env, if the wheel is present).
+ sys.modules.pop("srunner.tools.carla_compat", None)
+
+
+# ---------------------------------------------------------------------------
+# A tiny parser-like object so `parse_chaos_physics_block` can be exercised
+# without dragging in the full MetricsParser / recorder framing.
+# ---------------------------------------------------------------------------
+
+class _MockParser(object):
+ def __init__(self, rows):
+ self._rows = list(rows)
+ self._i = 0
+ self.frame_row = self._rows[0] if self._rows else ""
+
+ def next_row(self):
+ self._i += 1
+ if self._i < len(self._rows):
+ self.frame_row = self._rows[self._i]
+ else:
+ self.frame_row = ""
+
+ def get_row_elements(self, indent_num, split_string):
+ return self.frame_row[indent_num:].split(split_string)
+
+
+# ---------------------------------------------------------------------------
+# Wheel-line tokenizer
+# ---------------------------------------------------------------------------
+
+
+class ChaosWheelTokenizerTests(unittest.TestCase):
+ """`_parse_wheel_line` — Chaos wheel data row tokenizer."""
+
+ SAMPLE = (
+ " axle_type: offset: (0.000000, 0.000000, 0.000000) wheel_radius: 35.5 "
+ "wheel_width: 23 wheel_mass: 20 cornering_stiffness: 1000 "
+ "friction_force_multiplier: 3.5 side_slip_modifier: 1 slip_threshold: 20 "
+ "skid_threshold: 20 max_steer_angle: 70 affected_by_steering: 1 "
+ "affected_by_brake: 1 affected_by_handbrake: 1 affected_by_engine: 1 "
+ "abs_enabled: 1 traction_control_enabled: 1 max_wheelspin_rotation: 30 "
+ "external_torque_combine_method: lateral_slip_graph: [] "
+ "suspension_axis: (0.000000, 0.000000, -1.000000) "
+ "suspension_force_offset: (0.000000, 0.000000, 0.000000) "
+ "suspension_max_raise: 8 suspension_max_drop: 10 suspension_damping_ratio: 1 "
+ "wheel_load_ratio: 0.5 spring_rate: 250 spring_preload: 50 "
+ "suspension_smoothing: 4 rollbar_scaling: 0.3 sweep_shape: sweep_type: "
+ "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 0 "
+ "location: (0.000000, 0.000000, 0.000000) "
+ "old_location: (0.000000, 0.000000, 0.000000) "
+ "velocity: (0.000000, 0.000000, 0.000000)"
+ )
+
+ def test_parses_friction_force_multiplier(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ self.assertEqual(w.friction_force_multiplier, 3.5)
+
+ def test_parses_wheel_geometry_scalars(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ self.assertEqual(w.wheel_radius, 35.5)
+ self.assertEqual(w.wheel_width, 23.0)
+ self.assertEqual(w.wheel_mass, 20.0)
+ self.assertEqual(w.max_steer_angle, 70.0)
+ self.assertEqual(w.cornering_stiffness, 1000.0)
+
+ def test_parses_brake_torques(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ self.assertEqual(w.max_brake_torque, 1000.0)
+
+ def test_remaps_recorder_max_hand_brake_torque_to_chaos_attr(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ # Recorder dumps `max_hand_brake_torque` (extra underscore); the
+ # documented Chaos attribute is `max_handbrake_torque`.
+ self.assertEqual(w.max_handbrake_torque, 2000.0)
+
+ def test_parses_boolean_flags(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ self.assertTrue(w.affected_by_steering)
+ self.assertTrue(w.affected_by_brake)
+ self.assertTrue(w.affected_by_handbrake)
+ self.assertTrue(w.affected_by_engine)
+ self.assertTrue(w.abs_enabled)
+ self.assertTrue(w.traction_control_enabled)
+
+ def test_parses_vector_field(self):
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ self.assertEqual(w.suspension_axis.x, 0.0)
+ self.assertEqual(w.suspension_axis.y, 0.0)
+ self.assertEqual(w.suspension_axis.z, -1.0)
+
+ def test_handles_empty_value_between_keys(self):
+ # `axle_type:` with no value, immediately followed by `offset: ...`
+ # — empty values must be skipped, not crash. The recorder spelling
+ # (`axle_type`) is aliased to the Chaos binding spelling
+ # (`axel_type`); neither should end up set when the value is empty.
+ line = " axle_type: offset: (0.0, 0.0, 0.0) wheel_radius: 35.5"
+ w = recorder_chaos._parse_wheel_line(line)
+ self.assertEqual(w.wheel_radius, 35.5)
+ self.assertFalse(hasattr(w, "axle_type"))
+ self.assertFalse(hasattr(w, "axel_type"))
+
+ def test_skips_runtime_only_fields(self):
+ # `wheel_index`, `location`, `old_location`, `velocity` aren't on
+ # WheelPhysicsControl — they should be silently dropped.
+ w = recorder_chaos._parse_wheel_line(self.SAMPLE)
+ for attr in ("wheel_index", "location", "old_location", "velocity",
+ "external_torque_combine_method", "lateral_slip_graph",
+ "sweep_shape", "sweep_type"):
+ self.assertFalse(hasattr(w, attr),
+ "runtime-only field {!r} should not be set".format(attr))
+
+ def test_skips_unknown_fields(self):
+ # An unknown field shouldn't crash and shouldn't be applied.
+ line = " future_field: 7.5 wheel_radius: 35.5"
+ w = recorder_chaos._parse_wheel_line(line)
+ self.assertEqual(w.wheel_radius, 35.5)
+ self.assertFalse(hasattr(w, "future_field"))
+
+
+# ---------------------------------------------------------------------------
+# Full Physics Control block walker (driven by _MockParser)
+# ---------------------------------------------------------------------------
+
+
+class ChaosPhysicsBlockTests(unittest.TestCase):
+ """`parse_chaos_physics_block` — full Physics Control events block walker."""
+
+ BLOCK_HEAD = " Id: 49"
+ SCALAR_ROWS = [
+ " max_torque = 550",
+ " max_rpm = 6500",
+ " MOI = 5",
+ " rev_down_rate = 600",
+ " differential_type = ",
+ " front_rear_split = 0.5",
+ " use_gear_auto_box = true",
+ " gear_change_time = 0.1",
+ " final_ratio = 3.21",
+ " change_up_rpm = 4000",
+ " change_down_rpm = 1500",
+ " transmission_efficiency = 0.9",
+ " mass = 1696",
+ " drag_coefficient = 0.3",
+ " center_of_mass = (0.15, 0, 0.35)",
+ " torque_curve = (0, 500) (5000, 500) (0, 500) (1000, 347)",
+ " steering_curve = (0, 1) (10, 0.5) (20, 0.9)",
+ " forward_gear_ratios:",
+ " gear 0: ratio 4",
+ " gear 1: ratio 2.5",
+ " gear 2: ratio 1.912",
+ " reverse_gear_ratios:",
+ " gear 0: ratio 2.943",
+ " wheels:",
+ "wheel #0:",
+ " axle_type: wheel_radius: 35.5 friction_force_multiplier: 3.5 "
+ "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 0",
+ "wheel #1:",
+ " axle_type: wheel_radius: 35.5 friction_force_multiplier: 3.5 "
+ "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 1",
+ # Terminator — a new top-level section.
+ " Traffic Light time events: 0",
+ ]
+
+ def _parse(self):
+ rows = [self.BLOCK_HEAD] + self.SCALAR_ROWS
+ parser = _MockParser(rows)
+ dest = {}
+ recorder_chaos.parse_chaos_physics_block(parser, dest)
+ return dest, parser
+
+ def test_actor_key_present(self):
+ dest, _ = self._parse()
+ self.assertIn(49, dest)
+
+ def test_scalar_fields_populated(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertEqual(pc.max_torque, 550.0)
+ self.assertEqual(pc.max_rpm, 6500.0)
+ self.assertEqual(pc.mass, 1696.0)
+ self.assertEqual(pc.drag_coefficient, 0.3)
+ self.assertEqual(pc.gear_change_time, 0.1)
+ self.assertEqual(pc.final_ratio, 3.21)
+ self.assertEqual(pc.change_up_rpm, 4000.0)
+ self.assertEqual(pc.change_down_rpm, 1500.0)
+ self.assertEqual(pc.front_rear_split, 0.5)
+
+ def test_use_gear_auto_box_remapped_to_chaos_attr(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ # Recorder field is `use_gear_auto_box`; Chaos attr is
+ # `use_automatic_gears`.
+ self.assertTrue(pc.use_automatic_gears)
+
+ def test_differential_type_empty_value_does_not_crash(self):
+ # Empty `differential_type = ` line must be skipped, not raise.
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertFalse(hasattr(pc, "differential_type"))
+
+ def test_empty_bool_value_does_not_coerce_to_false(self):
+ # An empty bool value (e.g. `use_gear_auto_box = `) must skip, not
+ # set the alias target to False (which would happen if the empty
+ # guard ran after the bool branches).
+ rows = [
+ " Id: 7",
+ " use_gear_auto_box = ",
+ " use_sweep_wheel_collision = ",
+ " Traffic Light time events: 0",
+ ]
+ parser = _MockParser(rows)
+ dest = {}
+ recorder_chaos.parse_chaos_physics_block(parser, dest)
+ pc = dest[7]
+ self.assertFalse(hasattr(pc, "use_automatic_gears"))
+ self.assertFalse(hasattr(pc, "use_sweep_wheel_collision"))
+
+ def test_moi_recorder_field_is_not_aliased_to_rev_up_moi(self):
+ # `MOI` is a server-internal artifact; Chaos `rev_up_moi` is a
+ # different quantity. No silent remap.
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertFalse(hasattr(pc, "rev_up_moi"))
+ self.assertFalse(hasattr(pc, "moi"))
+
+ def test_center_of_mass_parsed_as_vector(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertEqual(pc.center_of_mass.x, 0.15)
+ self.assertEqual(pc.center_of_mass.z, 0.35)
+
+ def test_torque_and_steering_curves_parsed_as_vector2d_lists(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertEqual(len(pc.torque_curve), 4)
+ self.assertEqual(pc.torque_curve[0].x, 0.0)
+ self.assertEqual(pc.torque_curve[0].y, 500.0)
+ self.assertEqual(len(pc.steering_curve), 3)
+
+ def test_forward_and_reverse_gear_ratios_parsed_as_lists(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertEqual(pc.forward_gear_ratios, [4.0, 2.5, 1.912])
+ self.assertEqual(pc.reverse_gear_ratios, [2.943])
+
+ def test_wheels_list_populated(self):
+ dest, _ = self._parse()
+ pc = dest[49]
+ self.assertEqual(len(pc.wheels), 2)
+ self.assertEqual(pc.wheels[0].wheel_radius, 35.5)
+ self.assertEqual(pc.wheels[0].friction_force_multiplier, 3.5)
+ # max_hand_brake_torque (recorder) → max_handbrake_torque (Chaos attr)
+ self.assertEqual(pc.wheels[0].max_handbrake_torque, 2000.0)
+
+ def test_walker_exits_at_block_terminator(self):
+ # After the loop, frame_row should sit at the next top-level section.
+ _, parser = self._parse()
+ self.assertTrue(parser.frame_row.startswith(" Traffic Light time events"))
+
+ def test_empty_block_does_not_crash(self):
+ # `Physics Control events: 0` — caller advanced past the header,
+ # so the walker sees the next section straight away and exits.
+ parser = _MockParser([" Traffic Light time events: 0"])
+ dest = {}
+ recorder_chaos.parse_chaos_physics_block(parser, dest)
+ self.assertEqual(dest, {})
+
+
+# ---------------------------------------------------------------------------
+# End-to-end MetricsParser / Osc2TraceParser on the captured 0.10.0 dump.
+# Confirms the section-skip handlers (Vehicle door animations / Weathers /
+# Walkers Bones) plus the IS_UE5 branch wire up correctly.
+# ---------------------------------------------------------------------------
+
+
+class MetricsParserUE5FixtureTests(unittest.TestCase):
+ """Run the full parser on a real 0.10.0 recorder dump (Town10HD_Opt)."""
+
+ @classmethod
+ def setUpClass(cls):
+ with open(os.path.join(_FIXTURE_DIR, "recorder_ue5_town10.txt")) as f:
+ cls.text = f.read()
+
+ def test_metrics_parser_reaches_physics_control(self):
+ sim, actors, frames = MetricsParser(self.text).parse_recorder_info()
+ self.assertEqual(sim["map"], "Town10HD_Opt")
+ self.assertGreater(len(frames), 0)
+ # The Physics Control block lives in Frame 1 (index 0).
+ self.assertIn(49, frames[0]["events"]["physics_control"])
+
+ def test_metrics_parser_populates_chaos_scalars(self):
+ _, _, frames = MetricsParser(self.text).parse_recorder_info()
+ pc = frames[0]["events"]["physics_control"][49]
+ self.assertEqual(pc.max_torque, 550.0)
+ self.assertEqual(pc.max_rpm, 6500.0)
+ self.assertEqual(pc.mass, 1696.0)
+ self.assertTrue(pc.use_automatic_gears)
+ self.assertEqual(len(pc.wheels), 4)
+ # Each wheel carries the Chaos friction multiplier.
+ for w in pc.wheels:
+ self.assertEqual(w.friction_force_multiplier, 3.5)
+
+ def test_metrics_parser_walks_past_vehicle_door_animations(self):
+ # Without the UE5 section-skip handler the walker would stall on
+ # `Vehicle door animations` and never reach Positions; positions
+ # being non-empty proves the handler fired.
+ _, _, frames = MetricsParser(self.text).parse_recorder_info()
+ self.assertGreater(len(frames[0]["actors"]), 0,
+ "section-skip handler missed `Vehicle door animations`")
+
+ def test_metrics_parser_still_reports_vehicle_controls(self):
+ # Vehicle animations is downstream of the new sections — its data
+ # ending up in frame_state['actors'][id]['control'] confirms the
+ # walker traversed past every skip-handler in order.
+ _, _, frames = MetricsParser(self.text).parse_recorder_info()
+ any_control = any(
+ "control" in d for fr in frames for d in fr["actors"].values()
+ )
+ self.assertTrue(any_control)
+
+ def test_osc2_trace_parser_matches_metrics_parser_shape(self):
+ sim_a, _, frames_a = MetricsParser(self.text).parse_recorder_info()
+ sim_b, _, frames_b = Osc2TraceParser(self.text).parse_recorder_info()
+ self.assertEqual(sim_a["map"], sim_b["map"])
+ self.assertEqual(len(frames_a), len(frames_b))
+ # Same actor seen in both physics_control dicts.
+ self.assertEqual(
+ set(frames_a[0]["events"]["physics_control"].keys()),
+ set(frames_b[0]["events"]["physics_control"].keys()),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()