Car physics and handling
Experimental
Live car physics (cars::live, layers, car.profile) is experimental in 1.7.0. Nothing in it has run in game yet, and its shape can change before it is stable. A field whose game code has no independent verification yet stays off and logs <field> off: needs <row>.
Every car on track is built from its handling file: the .vtf in the car's package (teams/ferrari/handling/ferrari.vtf). The file has one section per part of the car, such as Engine, F1Aero or F1WingDamage, and each section is a list of named values. Your addon changes those values in three ways:
- Layer the handling record. The change reaches every car built from that handling file, from the next session.
- Change a car live. Through
cars::live, during a session, on one car or many, with the car's team and driver in hand. - Edit an upgrade block. The R&D upgrades are blocks inside the same file, so changing one changes what R&D does.
A damage addon shows all three. It offers Light, Normal and Heavy damage in ADDON SETTINGS, and a change reaches the next race with no restart. A Live option goes further: it changes the cars on track at once.
#include <swofty/registry.hpp>
#include <swofty/systems/cars.hpp>
using namespace swofty;
SWOFTY_REGISTER
{
auto f1 = tagged<cars::Handling>(cars::handling.tag("c/f1_cars"));
// Every F1 car built from now on: next session, every year.
cars::handling.layer(f1, [](cars::Handling &h) {
auto level = option<std::string>("wing_damage"); // "light", "normal", "heavy"
if (level == "heavy") {
h.wing_damage.speed_threshold = 2.0f;
h.wing_damage.damage_scale = 0.000175f;
h.wing_damage.full_detach_scale = 0.4f;
}
});
}The parts of a car
A built car has 23 parts, one per section of its handling file. cars::Handling has a member for each, named after the section, and each part has a field for every value in that section, named after the key. Here are the parts most addons change:
namespace swofty::cars {
struct WingDamage { // F1WingDamage: the front wing
float speed_threshold; // impacts below this speed do nothing (stock 3)
float damage_scale; // damage per unit of impact (stock 0.0001)
float chassis_damage_multiple; // share of a body hit that reaches the wing (stock 0.3)
float side_detach_scale; // damage at which one side breaks off (stock 0.5)
float full_detach_scale; // damage at which the wing comes off (stock 1)
};
struct SuspensionDamage { // F1SuspensionDamage
float speed_threshold; // stock 7
float front_damage_scale; // stock 0.001
float rear_damage_scale; // stock 0.0001
float wheel_detach_scale; // stock 1
float parts_detach_scale; // at least 1.5 x wheel_detach_scale: the game raises a lower value
float linear_fracture_limit; // stock 50
float angular_fracture_limit; // stock 30
};
struct Engine { // Engine
Curve revs_power_spline; // power in W against engine speed in rad/s (2025 Ferrari: 550 kW at 1191.5 rad/s)
float idle_rotation_rate; // rad/s
float limiter_rotation_rate; // rad/s
float mass, inertia;
float wear_ice_rate, wear_tc_rate, wear_mguh_rate, wear_ce_rate; // power unit wear per second of use
// the throttle maps, engine braking, temperatures and the rest of the section
};
struct FuelEcu { // FuelECU: one value per fuel mode (lean, standard, rich, max)
std::array<float, 4> fuel_burn_scale, fuel_power_scale, fuel_revs_scale, temperature_scale;
};
struct Ers { // EnergyRecoverySystem
float max_deploy_power; // W (2025: 126.3 kW, 2026: 350 kW)
float energy_store_capacity; // J (4 MJ)
float exhaust_power_coefficient, overtake_power_efficiency, max_brake_torque_mguk;
std::array<ErsMode, 4> modes; // none, medium, overtake, hotlap: deploy per gear
};
struct F1Aero { // F1Aero
float front_coeff_lift_multiplier, rear_coeff_lift_multiplier;
float body_drag_coeff_min, body_drag_coeff_max;
Curve front_coeff_lift_vs_ride_height, rear_coeff_lift_vs_ride_height;
// wing settings, DRS, slipstream, brake duct and radiator terms, ride-height clamps
};
struct Chassis { // Chassis
float mass; // kg
float moi_x, moi_y, moi_z; // moments of inertia
float com_y, com_z; // centre of mass
};
}The other parts work the same way: gearbox (ratios R, N, 1st to 9th, final drive, shift times), fuel_tank, brake_ecu (brake torques, ABS and traction control curves), steering, core_aero (slipstreaming), differential, suspension (springs, dampers, anti-roll bars, camber and toe, and the tyre and brake records per axle), turbo, tuning (the setup ranges), assists, plank, tyre_damage and body_damage.
A key the handling file doesn't have keeps the game's default.
When a change reaches the car
The game reads a handling file once when the file loads. It then builds each car from it when the car is put on track, which happens at every session load. A handling layer applies at that build, so:
- A layer reaches every car built after it, in every year, without a restart. Changing an option reaches the next session.
- A car that's already on track keeps its values until it's built again. Use
cars::liveto change it now. - The game rebuilds a car's parts from the file during some events, such as handing a car from the player to the AI. Your layer is part of every rebuild, so it survives them.
- With Equal Performance on, every car is built from the base car's file, so a layer on the base car reaches all of them.
The core logs each build:
[cars] built vehicle 0 (game/ferrari_2025, team game/ferrari, driver game/charles_leclerc): 23 parts, 3 layers
[cars] vehicle 0 handling: F1WingDamage /speed_threshold 3 -> 2 (william09leo.heavier-damage-realism)Changing a car live
cars::live gives you each car on track during a session: its index and the game's car. car.control() reads who drives it, human or AI, and its controller flags, fresh on every call (see Who drives a car), and car.is_ai() reads whether the AI drives it right now (the player's car too, while the AI has it).
Read and write a value with get, set and scale, by Field or by its Handling path. The core finds the car's part, decodes and re-encodes the game's scrambled values, and refuses a value outside the field's range with Result::invalid:
#include <swofty/systems/cars.hpp>
using namespace swofty;
using cars::live::Field;
for (cars::live::Car &car : cars::live::cars()) {
if (car.is_ai())
continue;
car.scale(Field::steering_lock, 1.2f); // 20 % more steering lock, next frame
car.set("fuel_tank.fuel_mass", 50.0f); // kg, up to the tank's capacity
car.set(Field::wing_full_detach_scale, 0.6f); // the wing comes off sooner, next hit
}A write lasts until the game rebuilds, resets or replays the car's parts. To keep a value, make a layer.
Fields
steering.max_steering_lock rad, 0.01 .. 1.5708
gearbox.max_clutch_torque multiple of engine torque, 0 .. 100
gearbox.final_drive_ratio 1 .. 20, rebuilds the ratio table
gearbox.gear_ratio_1 .. gear_ratio_9 0.1 .. 20, rebuilds the ratio table
differential.diff_on_throttle 0 .. 1, until the setup is applied again
differential.diff_off_throttle 0 .. 1
turbo.max_boost bar; no effect on a stock car
fuel_tank.fuel_mass kg, up to the tank's capacity
engine.vtf_limiter_rotation_rate rad/s, 100 .. 2500, re-runs the derived limiter
f1_wing_damage.* speed_threshold, damage_scale, chassis_damage_multiple, side_detach_scale, full_detach_scale
f1_suspension_damage.* speed_threshold, front/rear_damage_scale, wheel/parts_detach_scale, linear/angular_fracture_limit
fuel_ecu.fuel_burn_scale.<mix> 0 .. 10, fuel flow of lean, standard, rich or max; a write re-runs the active mix
fuel_ecu.fuel_power_scale.<mix> 0 .. 4, engine power
fuel_ecu.fuel_revs_scale.<mix> 0.5 .. 2, divides the power spline's rev lookup and scales the limiter
fuel_ecu.temperature_scale.<mix> 0 .. 4
aero.lift_scale 0 .. 10, multiplies the body lift, read every step
aero.drag_scale 0 .. 10, multiplies the body drag
aero.slipstream_drag 0 .. 1, the air density the body drag sees in a full tow (lower: a stronger tow); read every step
aero.slipstream_front_wing 0 .. 1, the same for the front wing
aero.slipstream_rear_wing 0 .. 1, the same for the rear wing
aero.slipstream_lift 0 .. 1, the same for the body lift
aero.slipstream_car_width 0.1 .. 10, the width of the wake the car leaves
engine.power_scale 0 .. 4, the engine drive torque, written to no game data
engine.limiter_offset_rpm negative: the torque is cut that many rpm under the limiter
ai.grip_scale 0 .. 4, the AI's table grip input, dry weather only
ai.blocking.fraction_of_follow_speed 0 .. 4, per car, layers only: the blocking evaluator's fraction once the race start is over (stock 0.975)
ai.blocking.fraction_of_follow_speed_at_race_start 0 .. 4, the same before it (stock 0.875)
ai.blocking.max_blocking_distance 0 .. 1000, per car, layers only: how far behind an attacker still counts as a threat, after the race start
ai.blocking.max_blocking_distance_at_race_start 0 .. 1000, the same before it
ai.<group>.<key> 43 more driver config slots, see the table in AI
tyres.front.grip_scale off: the tyre solver never reads GripMultiplier
tyres.rear.grip_scale
chassis.ballast_kg -300 .. 600, added to the VTF mass, then the chassis reset (fuel mass drops out until the next fuel update)
brakes.bias 0 .. 1, rewritten when a setup is applied
brakes.pressure 0 .. 4
suspension.front.camber .toe .anti_roll .ride_height_offset
suspension.rear.camber .toe .anti_roll .ride_height_offsetengine.power_scale, engine.limiter_offset_rpm and ai.grip_scale are the core's own handlers: nothing is written to the car, and a car with no layer on them runs the game's code unchanged. The suspension fields are copied into the solver every frame, so a write reaches the car on the next frame.
The ai.<group>.<key> fields are the per-car AI driver config slots: blocking, alongside, divebomb_defense, offset_variation, overtake and space_seeking, 59 in all, listed with their kind and range in AI. They are per car and take layers only (set answers Result::unavailable). The core never writes the game's database cell: it points the car's slot in its AI record at an own cell holding the composed value, and puts the game's pointer back when the car's last layer on the field goes. The base of a layer is the game's value for that car. get returns the value the AI reads. The fields answer Result::unavailable until the AI system is built and has stopped growing, and a layer made earlier applies then. A session change forgets every swap and leaves the old session's records alone; the owned cells are not freed. The _at_race_start pair is read while the race start state machine is before its last state, the plain pair after it.
A curve field's own cell is a copy of the game's curve record made flat: step 0, an all-zero sample table, and min, last value and max set to the composed value, so every x evaluates to it. get on a curve field returns the curve at x = 0. ai.space_seeking.car_in_front_needed and ai.space_seeking.total_queue_needed are int cells: the composed value is rounded to the nearest integer. A cell whose stock value does not fit its kind (a denormal or non-finite float, an int outside 0 to 65536, a curve record with a non-finite scale, offset, step, last value, min or max) is refused and left alone. A field turns on once its AiDriverConfig.<group>_<key> KB row has an independent agree verify row; the 43 added with alongside, divebomb_defense, offset_variation, overtake and space_seeking have none yet.
A car's index is the game's vehicle id: dense from 0 in the order the cars were built, the safety car included. A Car from cars() also carries the game's car, and a call on it answers Result::late once that car is gone from its slot. set runs on the game's flow thread, so call it from a frame, setting or event handler; from another thread it answers Result::late. The values are read from the build the core's field table was mapped on: on any other build of the game cars::live::available() is false and cars() is empty.
Layers
A layer is a value the core keeps and applies again after every car.built, car.reset, R&D upgrade replay and setup apply, so it outlives the game's own resets. It is removed when the Layer is destroyed:
using namespace swofty;
using cars::live::Cars;
using cars::live::Layer;
static Layer power, lift, fuel;
SWOFTY_REGISTER
{
power = Layer::scale(Cars::all(), "engine.power_scale", 0.9725f, /*priority*/ 0);
lift = Layer::scale(Cars::ai(), "aero.lift_scale", 0.9f, 10);
fuel = Layer::set(Cars::player(), "fuel_tank.fuel_mass", 60.0f);
}Layers of one field compose by priority, lowest first, then in the order they were made: set replaces the value, scale multiplies it and add adds to it. The base is the game's value after its last reset (1 for engine.power_scale and ai.grip_scale, 0 for engine.limiter_offset_rpm). A composed value outside the field's range is clamped.
Cars::all() reaches every car, those built later too. Cars::ai(), Cars::player() and Cars::car(car) name the cars on track when the selector is made. Layer::result() is Result::unavailable when the field is off.
The C table (SwoftyCarOps version 2) has the same calls:
const SwoftyCarOps *ops = SwoftyCoreCarOps();
uint32_t field = 0, token = 0;
ops->field("aero.drag_scale", &field);
ops->layer("my.addon", SWOFTY_CAR_ALL, field, SWOFTY_CAR_OP_SCALE, 0.96f, 0, &token);
ops->unlayer(token);Car profiles
car.profile records put layers on the cars a scope matches, from data, with no code. The core reads them at car.built, car.reset, the upgrade replay and whenever the session changes, drops its earlier profile layers for the car and adds the matching profiles' again. A career freezes the season's profiles at season start. A box names none: a profile's within selects the box, as a rules.drs record does.
{
"car.profile": {
"race_engine": {
"within": [{ "data_group": "f1_career_1990" }],
"when": ["practice", "race"],
"power_scale": 0.9725,
"limiter_offset_rpm": -300,
"ai_grip_scale": 0.87
}
}
}That record is all of F1 1990's two native DLLs: the race engine (power x0.9725 and a cut 300 rpm under the limiter outside qualifying) and the AI's grip (x0.87 outside qualifying, in the dry).
namespace swofty::cars {
struct Profile {
json::Value within; // selector array: content, data_group, setup_mode, vehicle_package, ...
std::vector<events::SessionType> when; // empty: every session
CarSelect cars; // indices; vehicle_package, vehicle and team wait for the car identity
bool ai_only = false, player_only = false;
int priority = 0;
float power_scale = 1, limiter_offset_rpm = 0, lift_scale = 1, drag_scale = 1;
float grip_scale = 1, ai_grip_scale = 1;
std::optional<uint32_t> fuel_modes; // off until the fuel mode call is verified
json::Value extra; // every other key is a live field path
};
}Every key the record does not name is a live field path. A number sets it; an object with set, scale or add composes:
{
"car.profile": {
"heavy_aero_cars": {
"when": ["race"],
"ai_only": true,
"aero.drag_scale": { "scale": 1.05 },
"brakes.bias": 0.56,
"chassis.ballast_kg": { "add": 12 }
}
}
}Profiles compose by priority, then by record id. A profile whose cars names a vehicle package, vehicle or team is skipped and logged until the car's identity is mapped.
Events
events::on<cars::events::Built>().after([](const cars::events::Built &e, const events::Context &) {});
events::on<cars::events::Reset>().after([](const cars::events::Reset &e, const events::Context &) {});
events::on<cars::events::UpgradesReplayed>().after([](const cars::events::UpgradesReplayed &e, const events::Context &) {
float progress = e.progress; // the development progress the block was replayed at
});
events::on<cars::events::SetupApplied>().after([](const cars::events::SetupApplied &e, const events::Context &) {});
events::on<cars::events::PartsRegister>().after([](const cars::events::PartsRegister &e, const events::Context &) {
e.add(cars::Part{.node = "frontwing_fl_03", .detach_at = 0.9f}); // one more piece of the front wing
});
events::on<cars::events::Damage>().after([](const cars::events::Damage &e, const events::Context &) {
if (e.part == cars::events::Damage::front_wing_left)
log("car %u lost %.2f of its left front wing", e.car.index(), e.amount);
});
events::on<sessions::events::Pit>().after([](const sessions::events::Pit &e, const events::Context &) {
log("car %u: pit status %u -> %u", e.car, e.previous, e.status);
});
events::on<sessions::events::PitStop>().after([](const sessions::events::PitStop &e, const events::Context &) {
log("car %u has made %u stop(s)", e.car, e.stops);
});car.builtfires once for every car the game builds: at session load and when a restart builds the cars again.car.resetfires when the game re-reads a car's parts without building it again: during loading (twice for every car) and when it hands a car to the AI or back (T120).car.upgrades.replayedfires after the game replays an R&D upgrade block onto a car. The replay resetsaero.lift_scale,aero.drag_scaleand the fuel mode.car.setup.appliedfires after a setup is written to the car: the differential, suspension, brakes and centre of mass.car.parts.registerfires after the game has registered the front wing's own 11 pieces, once per car (not on a reset or an upgrade replay).addregisters one more piece on that wing:nodeis the model node,detach_atthe multiplierkthe piece comes off at (it comes off whenktimes the wing's full detach scale is at most the left and right damage together), and a node the car's model doesn't have adds nothing.debrisis stored, not applied.car.damagefires after the front wing takes a hit that adds damage:partisfront_wing_leftorfront_wing_rightandamountthe damage the hit added to that side. It fires on the next frame, like the events above.session.pitfires when a car's pit status changes, with the status it had and the one the game stored.session.pit_stopfires on the change where the game adds one to the car's stop count. A stop's stationary time and tyre change are off: they need a row for the PitStopInstance's car.
The game builds cars on a loading thread, so the core holds each event and fires it on the next frame, on the flow thread, where set works. The core applies its layers and profiles to the car first, so a handler sees the layered values. A car already gone from its slot by then gets none.
Reset::upgrade is always false from the core; the upgrade replay is its own event.
A suspension hit has no event yet. swofty/game.hpp has the experimental binding Car_SuspensionDamage_OnCollision(susp_damage, contact), which the game calls for every impact on a car: susp_damage is the car's F1SuspensionDamage system and contact the game's contact record, whose first field is the car that was hit. The function reads the speed threshold and the front and rear damage scales on every hit, so a change made before orig applies to that hit. The game calls it with the car's collision lock held, so keep the handler short.
#include <swofty/game.hpp>
#include <swofty/mixin.hpp>
#include <atomic>
std::atomic<uint32_t> suspension_hits[32];
SWOFTY_WRAP(Car_SuspensionDamage_OnCollision, void, void *susp_damage, void *contact)
{
auto *car = *static_cast<swofty::game::CarLive **>(contact); // the car that was hit
suspension_hits[car->vehicle_id % 32]++;
orig(susp_damage, contact); // the game adds this hit's damage
}R&D upgrades
An R&D upgrade isn't a number in an array. Each upgrade type is a block inside the handling file, such as radUpgradeEngine or radUpgradeFrontDownforce, with the same sections and keys as the rest of the file. When the career fits an upgrade, the game reads the block on top of the car's values, scaled by the development progress p from 0 to 1:
*: the value is multiplied by(v - 1) * p + 1.+and-:v * pis added or taken away./: the value is divided by(v - 1) * p + 1.- No operator, or
=: the value is set tov, whatever the progress.
A block applies once per car build. The stock 2025 blocks change, among others:
radUpgradeEngine Engine/RevsPowerSpline +, FuelTank/FuelBsfcMin x0.9
radUpgradeFrontDownforce F1Aero/FrontCoeffLiftMultiplier x1.26, FrontWingCoL1 x1.1
radUpgradeDrag F1Aero/BodyDragCoeffMin x0.87, BodyDragCoeffMax x0.89
radUpgradeWeightReduction Chassis/Mass -18
radUpgradeTyreWear Suspension Tyre/UpgradeWearFactor x0.825
radUpgradeWearICE Engine/WearICERate x0.5A block is a list of changes, each a section, a key, an operator and a value:
namespace swofty::cars {
struct UpgradeChange { std::string section, key; char op; float value; }; // op: '*', '+', '-', '/', '='
struct RadUpgrade { std::string name; std::vector<UpgradeChange> changes; };
}Edit a block with the same layer, or add a new one for a department of your own:
cars::handling.layer(f1, [](cars::Handling &h) {
cars::RadUpgrade &engine = h.upgrade("radUpgradeEngine"); // the block, created if it's missing
engine.set("FuelTank", "FuelBsfcMin", '*', 0.85f); // a bigger fuel saving at full progress
engine.set("Engine", "WearICERate", '*', 0.8f);
});Setup
The setup screen's sliders pick a point between a minimum and a maximum from the tuning part, then write it into the differential, suspension, brakes, engine braking, centre of mass and fuel load. The wing sliders are the exception: their range is the wing's own setting count in f1_aero. Widen or move a range with a layer:
cars::handling.layer(f1, [](cars::Handling &h) {
h.tuning.differential_on_throttle_multiple_min = 0.4f; // a more open differential at the low end
h.tuning.brake_bias_max = 0.7f;
});AI cars
AI cars use the same handling file, the same physics and the same R&D upgrades as the player. The difficulty setting changes how the AI drives, not its car: there's no hidden grip, power or drag boost. The AI-only values are in the handling file, so you can change them like any other:
brake_ecu: the AI's stability control (asc_strength_for_ai, off in 2025) andbrake_migration_torque_ai.suspensiontyres: the AI's slip limits and grip factors on a wet or fast tyre.- An extra tyre wear factor the player's car doesn't have.
- AI cars run their tyre contact every third physics step, where the player's runs every step.
For how the AI drives, see AI.
Readiness
The parts, their fields and how they're stored are mapped from the game's code and checked independently. An in-game trace confirmed that cars are built once at session load and torn down when it ends, that the game rebuilds every car twice while a session loads and rebuilds one car when control of it changes, and the live values of engine speed, fuel, ERS, tyre wear, mass and the aero coefficients. Still to confirm in game: DRS, the fuel-mix and setup controls, career R&D upgrades, and how a car's index matches its participant. The cars::live fields are mapped from the game's code and checked on fake cars in the core's host test; none of them is tested in game yet. Their offsets are those of the game build the bindings document was made for: on any other build, a game patch included, the core's same_build() check turns cars::live off until the KB is re-derived for that build.
These fields are on; each rests on a verified row:
engine.power_scale, engine.limiter_offset_rpm, ai.grip_scale
aero.lift_scale, aero.drag_scale, aero.slipstream_*
brakes.bias, brakes.pressure
suspension.front/rear.camber, toe, anti_roll, ride_height_offset
chassis.ballast_kg
car.parts.register, car.upgrades.replayed, car.setup.appliedA ballast change rebuilds the car's mass without the fuel; the fuel mass comes back at the next fuel update.
These stay off:
tyres.*.grip_scale the tyre solver (Car_Suspension_SolveWheels) never reads CarSuspTyreType.grip_multiplier;
only the AI snapshot (0x1405ee660) and force feedback do, so a write changes neither the car's grip nor the lap time
refit_tyres returns unavailable until a car-to-tyre-component lookup is boundCredit: the front wing damage, suspension damage, fuel ECU and ERS layouts were cross-checked against findings shared by Will (william09leo), the author of Heavier Damage.
The author of Terminal Damage reports four f1_suspension_damage.* layers working in game; see Suspension damage layers. That report isn't an independent verification.
Fuel mixes
Each car's FuelECU holds four values per mix (lean, standard, rich, max): fuel flow, engine power, a rev factor and a temperature factor. The fuel_ecu.* fields above write one value; the core then runs the car's active mix again so the engine and the fuel flow follow. Which mixes the MFD offers is the game's own: a mix is selectable while its availability entry is 0.
using namespace swofty;
static cars::live::Layer rich_power = cars::live::Layer::set(cars::live::Cars::all(), "fuel_ecu.fuel_power_scale.rich", 1.05f);
SWOFTY_ON_LOAD
{
cars::live::unlock_fuel_mixes("my.addon", uint32_t(cars::live::FuelMix::rich));
}set_fuel_mix(car, mix) switches a car to a mix through the game's own mode switch (the one the core uses after a fuel_ecu.* write). It answers Result::invalid for a mix the MFD does not offer the car, Result::late off the flow thread and Result::unavailable on a core older than car ops version 4; set_fuel_mix_available() tells ahead. The game resets the mix to Standard at a qualifying lap start; re-apply the player's choice on the lap event:
using namespace swofty;
static std::optional<cars::live::FuelMix> chosen;
SWOFTY_ON_LOAD
{
events::on<sessions::events::Lap>().after([](const sessions::events::Lap &l, const events::Context &) {
if (chosen)
cars::live::set_fuel_mix(l.car.index(), *chosen);
});
}A change the game makes cannot be told from the player's: the core has no mix-changed event, so the addon records the mix it wants (here chosen, taken from fuel_mix(car) while the player drives) and re-applies it.
unlock_fuel_mixes clears the availability entries of the mixes in the mask for every car, every frame, until the mask is 0 again. fuel_mix(car) reads a car's current mix. The availability table (g_EngineModeAvail, KB notes in car.md) has no independent verify row; the core reads it through checked memory and answers Result::unavailable on any other build.
Handling records
Experimental
Applying vehicle.handling records at build is experimental (launcher 1.7.0). The records, the typed sections and the launcher's build step can change before they are stable.
The launcher applies the vehicle.handling records in an addon's data files to the handling files the build ships. The change is in the file before the game starts, so every car built from it has it, in every session, with no code running in game. A GearBox change, a removed ERS and a raised suspension in one file:
{
"vehicle.handling": {
"ferrari_1990": {
"vtf": { "stock": "2025_asset_groups/f1_2026_vehicle_package/teams/ferrari/wep/ferrari.erp" },
"gearbox": {
"final_drive_ratio": 3.9,
"ratios": [3.0, 2.2, 1.7, 1.3, 1.0, 0.8],
"up_speed_multiple": [0.8, 0.9, 0.95, 0.96, 0.96],
"down_speed_multiple": [0.6, 0.7, 0.8, 0.85, 0.9]
},
"ers": null,
"suspension": {
"front": { "rest_length": 0.083, "min_height": 0.01, "bump_stop_height": 0.035 },
"rear": { "rest_length": 0.2, "min_height": 0.01, "bump_stop_height": 0.045 }
},
"edits": [
{ "path": "suspension.front.anti_roll_constant", "op": "scale", "value": 0.85 },
{ "path": "suspension.rear.anti_roll_constant", "op": "scale", "value": 1.45 },
{ "path": "chassis.mass", "op": "set", "value": 620 }
],
"chassis": { "ffb_scale": 1.3, "tyre_physics_group": "F1_1990" },
"f1_aero": { "drs": { "hardware": false }, "slipstream": { "drag": 0.65, "front_wing": 0.6 } },
"turbo": { "mode": "off" }
}
}
}handling swofty.f1-1990/ferrari_1990 -> ferrari.vtf: 112 change(s)
handling ferrari.vtf: GearBox: 6 gears, ratios past the top gear 0, 4 shift-speed multiple(s) past it removed
handling ferrari.vtf: EnergyRecoverySystem: section removed, 3 part(s) dropped from the radUpgrade blocks
handling ferrari.vtf: SuspensionSystem/RestLength: 0.1 -> 0.2
handling ferrari.vtf: Chassis/Mass: 771 -> 620The build logs each change per handling file in the build log. A key or section the file doesn't have is a warning on the build and nothing else.
Which file a record changes
The record's vtf names the archive. Every VTF in it is changed, or only the ones vtf.resources lists.
{ "vtf": { "stock": "2025_asset_groups/f1_2026_vehicle_package/teams/ferrari/wep/ferrari.erp" } }
{ "vtf": "cars/handling/ferrari_handling.erp" }
{ "vtf": { "erp": "cars/handling/ferrari_handling.erp", "resources": ["ferrari.vtf"] } }stock: the game's archive. The build ships a changed copy.- A path in your addon: the archive your
file.putop puts in the game folder. The build changes the copy it puts. - A record that names no
vtfchanges nothing and the build warns.
Layers apply in load order as a merge patch: "extends" on your own records, a key with a / for a layer on another addon's record, and "$select" entries with ids, where and when. when reads the addon's options at build, so an option changes the file at the next Play. tag and in entries are skipped at build. Layers written in native code run in game, after the build, and don't reach the file.
A vehicle record with a tyre_group and a handling sets chassis.tyre_physics_group on that handling record when the record doesn't set it.
Typed sections
Each field maps to the VTF element of the same name in snake_case: front_coeff_lift_multiplier is FrontCoeffLiftMultiplier. The fields whose element is named differently:
gearbox.final_drive_ratio GearBox/FinalDriveRatio
gearbox.ratios GearRatio1st .. 9th
gearbox.up_speed_multiple GearUpSpeedMultiple1st ..
gearbox.down_speed_multiple GearDnSpeedMultiple2nd ..
turbo.mode Engine/TurboMode (off, full)
turbo.max_boost Turbo/MaxBoost
differential.drive_type Differential/DriveType (rwd, fwd, awd = 4WD)
suspension.front.rest_x SuspensionSystem/Suspension name=Front/LocalRestPositionX
suspension.front.tyre_radius SuspensionSystem/Tyre name=Front/TyreRadius
suspension.front.grip_multiplier SuspensionSystem/Tyre name=Front/GripMultiplier
suspension.front_tyre.<key> any key of the Tyre element, front_brake.<key> of the Brake element
f1_aero.slipstream.drag F1Aero/m_aeroSlipStreamAirDensityScaleDrag
f1_aero.slipstream.car_width F1Aero/m_aeroSlipStreamCarWidth
f1_aero.drs.angle F1Aero/DRSRotationAngleRadians
ers.max_deploy_power EnergyRecoverySystem/Common/MaxDeployPower
tuning.chassis_com_z F1Tuning/ChassisCOMZMin and ChassisCOMZMaxA section holds any other key of its VTF section too: engine.wear_ice_rate, fuel_tank.fuel_bsfc_min, steering.max_steering_lock. A curve is an array of [x, y] pairs and replaces the spline's vertices:
{ "engine": { "limiter_rotation_rate": 1180, "revs_power_spline": [[0, 0], [314.2, 34562], [1151.9, 415319], [1570.8, 285922]] } }tuning fields are Range pairs, { "min": 0.1, "max": 1.0 }. differential_on_throttle_multiple_min and brake_bias_max from 1.6.0 still work.
Gear count
gearbox.ratios.size() is the gear count. The build writes the ratios, writes 0 for the gears past the top one and removes the shift-speed multiples past it, the layout of the EA F2 car. up_speed_multiple and down_speed_multiple hold one value per shift: up for gears 1 to n-1, down for gears 2 to n.
{ "gearbox": { "ratios": [3.1, 2.3, 1.8, 1.4, 1.1, 0.9] } }Removing a section
"ers": null removes the EnergyRecoverySystem section and the ERS parts of every radUpgrade block. The blocks stay, empty. In a layer, ers is the one field where null removes the section instead of removing the field from the record. std::optional<Ers> ers is nullopt in code for the same.
remove takes any section name and drops its parts from the radUpgrade blocks the same way:
{ "remove": ["turbo", "ers"] }f1_aero.drs.hardware: false removes the DRS keys from F1Aero, sets the flap angle, open speed and the DRS wing factors to the safety car's values and empties radUpgradeDRS.
Set, scale and add
edits run after the typed sections. path is section.key, or section.front.key for an axle. op is set, scale or add:
{ "edits": [
{ "path": "fuel_tank.fuel_bsfc_min", "op": "scale", "value": 0.9 },
{ "path": "suspension.rear.rest_length", "op": "add", "value": 0.03 },
{ "path": "brake_ecu.brake_front", "op": "scale", "value": 0.74 }
] }rad_upgrades records change the radUpgrade blocks the same way: { "name": "radUpgradeEngine", "changes": [{ "section": "fuel_tank", "key": "fuel_bsfc_min", "op": "*", "value": 0.9 }] }.
The centre of mass
The centre of mass is at -com_z. The game's static axle loads put (-com_z - rear_z) / wheelbase of the weight on the front axle, so the stock 2026 com_z of +0.171 on axles at +1.7 and -1.7 m is 45 % on the front. cars::front_weight(handling) reads it from the record:
cars::Handling h;
h.suspension.front.rest_z = 1.7f;
h.suspension.rear.rest_z = -1.7f;
h.chassis.com_z = 0.171f;
float front = cars::front_weight(h); // 0.4497For 41 % on the front axle, -com_z = 0.41 * wheelbase + rear_z, so on those axles com_z = -(0.41 * 3.4 - 1.7) = 0.306.
Vehicles
A vehicle record sets three more things:
{
"vehicle": {
"ferrari_1990": {
"donor": "game/ferrari_2025",
"handling": "swofty.f1-1990/ferrari_1990",
"drs_capable": false,
"engine": "game/cosworth_f1_v8",
"tyre_group": "F1_1990"
}
}
}drs_capableis written into the vehicle row at boot. Absent keeps the donor's.engineis the vehicle row's engine, anentrants.engineslabel. Apower::Supplierrecord (power.supplier) can name its donor and limiter; the core writes no engine row from it yet, so rev lights stay the donor's.tyre_groupis the ChassisTyrePhysicsGroupof the car's handling file, one group per team. The group's compounds are the tyre files' work.
Tyres
swofty/systems/tyres.hpp holds the first slice, as records the core stores and applies nothing from: tyre.physical (a compound with its group, code, class and grip factors), tyre.visual, tyre.class, tyre.allocation and tyre.rules. What reaches the game today is the group above.
tyres::physical.add("f1_1990_soft", tyres::Physical{ .group = "F1_1990", .code = "c5", .race_grip = 0.96f });What isn't applied
- Native-code layers on
vehicle.handlingreach the game after the build. - Fields the 2026 stock file has no element for:
fuel_tank.fuel_mass,ers.exhaust_power_coefficient,ers.overtake_power_efficiency,turbo.mode: lag_spoof. The build warns. ers.modes,damage_scale,classand the radUpgrade splines.- The ConvexHull vertices.
- Archives written by
erp.*ops. Put the archive withfile.put.
Template
templates/std/car-physics is a working addon with all three layers: a car.profile record (F1 1990's race engine and AI grip), a vehicle.handling record (six gears, no ERS) and live layers made on car.built, with a car.parts.register handler. Its test runs on the host against a fake car table:
static Layer lift, ballast;
void make_layers()
{
if (!lift.ok())
lift = Layer::scale(Cars::all(), "aero.lift_scale", 0.95f, 0);
if (!ballast.ok())
ballast = Layer::add(Cars::player(), "chassis.ballast_kg", 12.0f, 0);
}
SWOFTY_REGISTER
{
events::on<cars::events::Built>().after([](const cars::events::Built &, const events::Context &) { make_layers(); });
}