Build a what-if history editor
You'll build tails.whatif-history, a companion addon for Progressive Career. It adds a page to the launcher with a season picker, the season's grid and a ratings table. A player puts Alonso in the 2017 Mercedes, raises his pace, and the next Progressive career races that history.
Progressive registers each season's grid and ratings as registry records. Your addon layers those records, so it never changes Progressive's files.
The folder
tails.whatif-history/
├── swofty.json
├── registry.json
├── launcher/ the launcher page, in Rust
│ ├── Cargo.toml
│ └── src/main.rs
├── bin/
│ ├── whatif built from launcher/ for Linux
│ └── whatif.exe and for Windows
├── native/ the in-game code
│ ├── build.sh
│ ├── whatif.dll
│ └── src/whatif.cpp
└── test/ unit tests for swofty dev test
├── register_test.cpp
└── storage/profile/whatif.jsonThe addon has two halves. The launcher page saves the player's edits for the active profile. The DLL reads them when the game starts and layers Progressive's seasons.
swofty.json
requires makes Progressive a dependency, so it loads first and the launcher stops Play when it's missing. The launcher block adds the page and asks the launcher to run the page's build step before each Play.
registry.json names the two registries the addon layers, with the major version it was written against.
Both files are in templates/std/whatif in the launcher's repository, with the rest of this addon.
{
"schema_version": 1,
"id": "tails.whatif-history",
"name": "What If History",
"description": "Your own starting grids and driver ratings for Progressive Career, per profile.",
"version": "1.0.0",
"author": "Tails",
"license": "MIT",
"kind": ["native"],
"category": "career",
"compat": { "game": "f1_25", "launcher": ">=1.6.0" },
"requires": [ { "id": "swofty.progressive" } ],
"operations": { "native": [ { "dll": "native/whatif.dll" } ] },
"launcher": {
"program": { "windows": "bin/whatif.exe", "linux": "bin/whatif" },
"sections": [ { "id": "history", "title": "What if history", "icon": "history" } ],
"build": true
}
}{
"requires": [
{ "registry": "career.season", "version": "^1" },
{ "registry": "career.driver_ratings", "version": "^1" }
]
}What Progressive registers
Each Progressive season is a career::Season record, swofty.progressive/2016 to swofty.progressive/2026. Its teams are keyed by slot, and each team has two seats. Each driver's ratings for a season are a career::DriverRatings record, keyed by year and driver:
career.season swofty.progressive/2017
teams[slot 1] team constructor/mercedes, seats [game/lewis_hamilton, game/valtteri_bottas]
teams[slot 2] team constructor/ferrari, seats [game/sebastian_vettel, game/kimi_raikkonen]
career.driver_ratings swofty.progressive/2017/game/lewis_hamilton
season swofty.progressive/2017, experience 94, racecraft 93, awareness 90, pace 95See Career for the full records.
The page
The page shows a tab per Progressive season, with the season's grid and ratings from the last session's registry/effective.json in the launcher's data folder, which the core writes each time a session loads. Before the game has run once, it shows the tabs 2016 to 2026 and asks the player to start the game once.
An edited cell writes the edit into the addon's state for the active profile with set_state. The build step writes that state into the addon's folder in the game, where the DLL reads it, and writes an empty file for a profile with no edits. Each profile has its own edits, so a "what if" profile and a real-history profile sit side by side.
The Host type and the message loop are the launcher plugin template's.
// SPDX-License-Identifier: MIT
use std::io::{BufRead, Lines, StdinLock, StdoutLock, Write};
use std::path::PathBuf;
use serde_json::{Map, Value, json};
const API: u64 = 1;
const SECTION: &str = "history";
const PROGRESSIVE: &str = "swofty.progressive/";
const TARGET: &str = "swofty/addons/tails.whatif-history/profile/whatif.json";
const RATINGS: [&str; 4] = ["experience", "racecraft", "awareness", "pace"];
struct Host {
lines: Lines<StdinLock<'static>>,
out: StdoutLock<'static>,
next: u64,
}
impl Host {
fn send(&mut self, message: &Value) {
writeln!(self.out, "{message}")
.and_then(|()| self.out.flush())
.ok();
}
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next;
self.next += 1;
self.send(&json!({ "id": id, "method": method, "params": params }));
let line = match self.lines.next() {
Some(Ok(line)) => line,
_ => std::process::exit(0),
};
let reply: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
match reply.get("error") {
Some(e) => Err(e.to_string()),
None => Ok(reply["result"].clone()),
}
}
}
struct Page {
cache_dir: PathBuf,
vanilla: bool,
season: String,
}
// This profile's edits, the same shape as profile/whatif.json:
// { "seats": { "2017": { "1": ["game/fernando_alonso", "game/valtteri_bottas"] } },
// "ratings": { "2017": { "game/fernando_alonso": { "pace": 96 } } } }
fn edits(host: &mut Host) -> Value {
match host.call("get_state", Value::Null) {
Ok(v) if v.is_object() => v,
_ => json!({ "seats": {}, "ratings": {} }),
}
}
// The values the core applied in the last session, or null before the game has run once
fn last_session(host: &mut Host) -> Value {
host.call("registry_dir", Value::Null)
.ok()
.and_then(|d| d.as_str().map(|d| format!("{d}/effective.json")))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(Value::Null)
}
fn seasons(last: &Value, mine: &Value) -> Vec<String> {
let mut years: Vec<String> = last["career.season"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(id, _)| id.strip_prefix(PROGRESSIVE).map(str::to_owned))
.chain(
mine["seats"]
.as_object()
.into_iter()
.flatten()
.map(|(y, _)| y.clone()),
)
.collect();
if years.is_empty() {
years = (2016..=2026).map(|y| y.to_string()).collect();
}
years.sort();
years.dedup();
years
}
fn object(v: &mut Value) -> &mut Map<String, Value> {
if !v.is_object() {
*v = json!({});
}
v.as_object_mut().expect("an object")
}
impl Page {
fn view(&mut self, host: &mut Host) -> Value {
let mine = edits(host);
let last = last_session(host);
let years = seasons(&last, &mine);
if !years.contains(&self.season) {
self.season = years.iter().find(|y| *y == "2017").unwrap_or(&years[0]).clone();
}
let year = self.season.as_str();
let season = &last["career.season"][format!("{PROGRESSIVE}{year}")]["value"];
let seat_edits = &mine["seats"][year];
let grid: Vec<Value> = season["teams"]
.as_array()
.into_iter()
.flatten()
.map(|team| {
let slot = team["slot"].to_string();
let seats = seat_edits.get(&slot).unwrap_or(&team["seats"]);
let edited = seat_edits.get(&slot).is_some();
let seat = |i: usize| {
let mut cell = json!({ "text": seats[i].as_str().unwrap_or(""),
"edit": { "text": { "placeholder": "game/<driver>" } } });
if edited {
cell["tone"] = json!("accent");
}
cell
};
json!({ "id": slot, "cells": [team["team"].as_str().unwrap_or("(no team)"), seat(0), seat(1)] })
})
.collect();
let prefix = format!("{PROGRESSIVE}{year}/");
let ratings: Vec<Value> = last["career.driver_ratings"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(id, r)| Some((id.strip_prefix(&prefix)?, &r["value"])))
.map(|(driver, r)| {
let mut cells = vec![json!(driver)];
for field in RATINGS {
let value = mine["ratings"][year][driver].get(field).unwrap_or(&r[field]);
cells.push(json!({ "text": value.to_string(),
"edit": { "number": { "min": 0, "max": 100, "step": 1 } } }));
}
json!({ "id": driver, "cells": cells })
})
.collect();
let tabs: Vec<Value> = years.iter().map(|y| json!({ "id": y, "label": y })).collect();
let mut blocks = vec![json!({ "tabs": { "id": "season", "active": year, "items": tabs } })];
if grid.is_empty() {
blocks.push(json!({ "empty": { "title": format!("No {year} grid yet"),
"text": "Start the game once with Progressive Career on, and its grids show here." } }));
} else {
blocks.push(json!({ "table": { "id": "grid", "rows": grid, "columns": [
{ "key": "team", "title": "Team" }, { "key": "seat1", "title": "Seat 1" }, { "key": "seat2", "title": "Seat 2" } ] } }));
}
if !ratings.is_empty() {
blocks.push(json!({ "table": { "id": "ratings", "rows": ratings, "search": { "placeholder": "Search drivers" },
"columns": [ { "key": "driver", "title": "Driver", "sort": true },
{ "key": "experience", "title": "Experience" }, { "key": "racecraft", "title": "Racecraft" },
{ "key": "awareness", "title": "Awareness" }, { "key": "pace", "title": "Pace", "sort": true } ] } }));
}
json!({
"subtitle": if self.vanilla { "The vanilla profile can't keep edits" } else { "Your grids for Progressive Career" },
"actions": [ { "id": "reset", "label": format!("Reset {year}"), "icon": "undo" } ],
"body": { "blocks": blocks },
"footer": "Edits apply in this profile from the next Play. A career keeps the grid of every season it has started."
})
}
fn event(&mut self, host: &mut Host, event: &Value) -> Result<Value, String> {
if let Some(year) = event["tab"]["tab"].as_str() {
self.season = year.to_owned();
return Ok(json!("reload"));
}
let mut mine = edits(host);
let year = self.season.clone();
if event["click"]["id"] == "reset" {
object(&mut mine["seats"]).remove(&year);
object(&mut mine["ratings"]).remove(&year);
} else if let Some(submit) = event.get("submit") {
let (Some(form), Some(row)) = (submit["form"].as_str(), submit["row"].as_str()) else {
return Ok(json!("none"));
};
let values = &submit["values"];
if form == "grid" {
let slot: i64 = row.parse().map_err(|_| format!("{row} isn't a slot"))?;
let last = last_session(host);
let team = last["career.season"][format!("{PROGRESSIVE}{year}")]["value"]["teams"]
.as_array()
.into_iter()
.flatten()
.find(|t| t["slot"] == slot)
.cloned()
.unwrap_or(Value::Null);
let current = mine["seats"][&year].get(row).unwrap_or(&team["seats"]).clone();
let pick = |key: &str, i: usize| {
values
.get(key)
.unwrap_or(¤t[i])
.as_str()
.unwrap_or("")
.trim()
.to_owned()
};
let seats = [pick("seat1", 0), pick("seat2", 1)];
if seats.iter().any(|s| !s.is_empty() && !s.contains('/')) {
return Err("a driver is an id such as game/fernando_alonso".into());
}
object(
object(&mut mine["seats"])
.entry(year.clone())
.or_insert(json!({})),
)
.insert(row.to_owned(), json!(seats));
} else if form == "ratings" {
let fields = object(
object(&mut mine["ratings"])
.entry(year.clone())
.or_insert(json!({})),
)
.entry(row.to_owned())
.or_insert(json!({}));
for field in RATINGS {
if let Some(n) = values.get(field).and_then(Value::as_f64) {
if !(0.0..=100.0).contains(&n) {
return Err(format!("{field} goes from 0 to 100"));
}
object(fields).insert(field.to_owned(), json!(n));
}
}
} else {
return Ok(json!("none"));
}
} else {
return Ok(json!("none"));
}
host.call("set_state", mine)?;
Ok(json!("reload"))
}
// The build step: put this profile's edits where the DLL reads them, an empty file when it has none
fn build(&self, host: &mut Host, params: &Value) -> Result<Value, String> {
let file_ops = params["features"]["stable"]
.as_array()
.is_some_and(|f| f.iter().any(|k| k == "file"));
if !file_ops {
return Ok(
json!({ "warnings": ["this loader can't put files; the what-if edits are left out"] }),
);
}
let mine = edits(host);
std::fs::create_dir_all(&self.cache_dir).map_err(|e| e.to_string())?;
std::fs::write(self.cache_dir.join("whatif.json"), mine.to_string()).map_err(|e| e.to_string())?;
let seasons = mine["seats"].as_object().map_or(0, Map::len);
Ok(json!({
"ops": [{ "op": "file.put", "source": { "root": "cache", "path": "whatif.json" }, "target": TARGET }],
"lines": [format!("{seasons} season{} edited", if seasons == 1 { "" } else { "s" })]
}))
}
}
fn main() {
let mut host = Host {
lines: std::io::stdin().lock().lines(),
out: std::io::stdout().lock(),
next: 0,
};
let mut page = Page {
cache_dir: PathBuf::new(),
vanilla: false,
season: "2017".into(),
};
while let Some(Ok(line)) = host.lines.next() {
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
let params = &message["params"];
let method = message["method"].as_str().unwrap_or_default();
let Some(id) = message["id"].as_u64() else {
if method == "profile" {
page.vanilla = params["vanilla"].as_bool().unwrap_or(false);
}
continue;
};
let result = match method {
"hello" => {
page.cache_dir = PathBuf::from(params["cache_dir"].as_str().unwrap_or_default());
page.vanilla = params["profile"]["vanilla"].as_bool().unwrap_or(false);
Ok(json!({ "api": API }))
}
"view" if params["section"] == SECTION => Ok(page.view(&mut host)),
"event" if params["section"] == SECTION => page.event(&mut host, ¶ms["event"]),
"build" => page.build(&mut host, params),
other => Err(format!("unknown method {other}")),
};
let reply = match result {
Ok(result) => json!({ "id": id, "result": result }),
Err(error) => json!({ "id": id, "error": error }),
};
host.send(&reply);
}
}launcher/Cargo.toml needs one dependency, serde_json = "1". The page's state and the file the build step writes have the same shape:
{
"seats": {
"2017": { "1": ["game/fernando_alonso", "game/valtteri_bottas"] }
},
"ratings": {
"2017": { "game/fernando_alonso": { "pace": 96 } }
}
}See Host calls and The build reply for get_state, set_state, game_dir and the build step.
Apply the edits in the game
SWOFTY_REGISTER runs at every game start, before the game loads its database. It reads the file the build step wrote and adds one layer per edited season and one per edited driver.
Each layer has Scope::profile: it applies while this profile plays. Progressive's seasons and ratings are snapshot registries, so a career copies each season's grid and ratings when that season starts. A new career gets the edits from its first season. A running career gets them for every season it hasn't reached yet, and keeps what it already started.
A layer only changes the fields it sets. Progressive's mid-season swaps, team names and everything else in the season still come from Progressive.
The DLL reads the page's file with swofty::json, the SDK's JSON reader.
// SPDX-License-Identifier: MIT
#include <array>
#include <charconv>
#include <map>
#include <string>
#include <utility>
#include <swofty/json.hpp>
#include <swofty/registry.hpp>
#include <swofty/storage.hpp>
#include <swofty/systems/career.hpp>
using namespace swofty;
using Driver = Ref<drivers::Identity>;
constexpr std::pair<const char *, float career::DriverRatings::*> kRatings[] = {
{"experience", &career::DriverRatings::experience},
{"racecraft", &career::DriverRatings::racecraft},
{"awareness", &career::DriverRatings::awareness},
{"pace", &career::DriverRatings::pace},
};
static const json::Object &members(const json::Value &v)
{
static const json::Object none;
const json::Object *o = v.if_object();
return o ? *o : none;
}
static int slot_number(std::string_view key)
{
int n = 0;
auto [end, err] = std::from_chars(key.data(), key.data() + key.size(), n);
return err == std::errc() && end == key.data() + key.size() ? n : 0;
}
SWOFTY_REGISTER
{
// the page's edits for this profile, put here by its build step
std::string text;
if (storage::read("profile/whatif.json", text) != storage::Result::ok)
return; // nothing edited in this profile
const json::Value edits = json::parse(text);
// { "seats": { "2017": { "1": ["game/fernando_alonso", "game/valtteri_bottas"] } } }
for (const auto &[year, slots] : members(edits["seats"])) {
std::map<int, std::array<Driver, 2>> grid;
for (const auto &[slot, seats] : members(slots))
if (int n = slot_number(slot))
grid[n] = {Driver(seats[0].as_string()), Driver(seats[1].as_string())};
career::seasons.layer(career::seasons["swofty.progressive/" + year], [grid](career::Season &s) {
for (const auto &[slot, seats] : grid)
if (career::SeasonTeam *team = s.teams.find(slot)) // a slot Progressive doesn't have is skipped
for (int seat = 0; seat < 2; ++seat)
if (!seats[seat].empty())
team->seats[seat] = seats[seat];
}, Scope::profile);
}
// { "ratings": { "2017": { "game/fernando_alonso": { "pace": 96 } } } }
for (const auto &[year, drivers] : members(edits["ratings"]))
for (const auto &[driver, fields] : members(drivers))
career::ratings.layer(career::ratings["swofty.progressive/" + year + "/" + driver],
[fields](career::DriverRatings &r) {
for (const auto &[name, field] : kRatings)
if (const json::Value *v = fields.find(name); v && v->is_number())
r.*field = static_cast<float>(v->as_double());
}, Scope::profile);
}
SWOFTY_ADDON()The Without code tab is a fixed preset: the same layers as a data file, with no launcher page. It applies in every profile where the addon is on. Ship one or the other, not both.
Test it without the game
swofty dev test builds each test/*.cpp for your computer and runs it against the fake core, which runs your SWOFTY_REGISTER and records every layer. The test gives the DLL a storage folder with the sample file above (test/whatif_test.hpp holds that stand-in and a Progressive 2017 season), runs the registration, and checks each layer's target, scope and edit:
// SPDX-License-Identifier: MIT
#include "../native/src/whatif.cpp"
#include "whatif_test.hpp"
static void the_2017_edit()
{
Run run("test/storage/");
auto seasons = run.core.layers("career.season");
check(seasons.size() == 1, "one layer on career.season");
check(seasons.size() == 1 && seasons[0]->id == "swofty.progressive/2017", "on Progressive's 2017 season");
check(seasons.size() == 1 && seasons[0]->scope == SWOFTY_SCOPE_PROFILE, "in the profile scope");
if (seasons.size() == 1) {
auto v = run.core.apply(*seasons[0], json::parse(kSeason2017));
check(v.has_value(), "the season edit runs");
if (v) {
const json::Value &mercedes = (*v)["teams"][0];
check(mercedes["seats"][0].as_string() == "game/fernando_alonso", "Alonso takes the first Mercedes seat");
check(mercedes["seats"][1].as_string() == "game/valtteri_bottas", "Bottas keeps the second");
check(mercedes["team"].as_string() == "constructor/mercedes", "the team stays Progressive's");
check((*v)["teams"][1]["seats"][0].as_string() == "game/sebastian_vettel", "Ferrari is untouched");
check((*v)["progressive"]["ratings_source"].as_string() == "official",
"Progressive's own data rides through");
}
auto other = run.core.apply(*seasons[0], json::parse(R"({ "year": 2017, "teams": [ { "slot": 2,
"team": "constructor/ferrari", "seats": ["game/sebastian_vettel", "game/kimi_raikkonen"] } ] })"));
check(other && (*other)["teams"].size() == 1 && (*other)["teams"][0]["slot"].as_int() == 2,
"a slot the season doesn't have is skipped, not added");
}
auto ratings = run.core.layers("career.driver_ratings");
check(ratings.size() == 1 && ratings[0]->id == "swofty.progressive/2017/game/fernando_alonso",
"one layer on Alonso's 2017 ratings");
check(ratings.size() == 1 && ratings[0]->scope == SWOFTY_SCOPE_PROFILE, "in the profile scope");
if (ratings.size() == 1) {
auto r = run.core.apply(*ratings[0], json::parse(kAlonso2017));
check(r && (*r)["pace"].as_double() == 96, "his pace becomes 96");
check(r && (*r)["racecraft"].as_double() == 93, "his other ratings stay");
}
if (!run.core.rules()) {
std::printf("skip effective values and origins: no registry engine linked\n");
return;
}
auto season = run.core.effective(swofty::career::seasons, swofty::career::seasons["swofty.progressive/2017"]);
check(season && season->teams.find(1) && season->teams.find(1)->seats[0].id() == "game/fernando_alonso",
"the effective 2017 season has Alonso in the Mercedes");
check(season && season->teams.find(2) && season->teams.find(2)->seats[0].id() == "game/sebastian_vettel",
"and Ferrari as Progressive has it");
check(run.core.origin("career.season", "swofty.progressive/2017", "/teams/slot=1/seats/0") == "tails.whatif-history",
"the seat's origin is this addon");
check(run.core.origin("career.season", "swofty.progressive/2017", "/teams/slot=2/seats/0") == "swofty.progressive",
"Ferrari's seat is still Progressive's");
auto alonso = run.core.effective("career.driver_ratings", "swofty.progressive/2017/game/fernando_alonso");
check(alonso && (*alonso)["pace"].as_double() == 96, "the effective 2017 ratings have his pace at 96");
check(run.core.origin("career.driver_ratings", "swofty.progressive/2017/game/fernando_alonso", "/pace") ==
"tails.whatif-history",
"and the pace's origin is this addon");
}
int main()
{
the_2017_edit();
return failures ? 1 : 0;
}$ swofty dev test
ok one layer on career.season
ok on Progressive's 2017 season
ok in the profile scope
ok Alonso takes the first Mercedes seat
...
ok register_test
3 tests, 0 failedTry it
Build the page for each system you list in launcher.program, and the DLL with native/build.sh:
(cd launcher && cargo build --release && cp target/release/whatif ../bin/)
native/build.sh
swofty dev pack
swofty addons install ./tails.whatif-history
swofty addons enable swofty.progressive
swofty addons enable tails.whatif-history- Open What if history in the launcher and pick 2017.
- Put
game/fernando_alonsoin the Mercedes' first seat, and set his pace to 96. - Press Play, or run
swofty run --apply, and start a Progressive career in 2017.
swofty/loader.log:
addon whatif.dll: calling SwoftyAddonRegister (API v9) at the early stage
[registry] register: tails.whatif-history 2 layer(s)registry/effective.json shows each changed field with tails.whatif-history as its origin:
"swofty.progressive/2017": {
"status": "ok",
"value": { "year": 2017, "teams": [ { "slot": 1, "team": "constructor/mercedes", "seats": ["game/fernando_alonso", "game/valtteri_bottas"] } ] },
"origins": { "/teams/slot=1/seats/0": "tails.whatif-history" },
"owner": "swofty.progressive",
"snapshot": "1.0"
}If another addon changes the same seat, registry/conflicts.json lists both, and the later one in load order wins.
A career copies each season's grid and ratings when that season starts. A career already in 2017 keeps the grid it started 2017 with. A career still in 2016 races the edit when it reaches 2017.
When Progressive changes or is missing
The page keeps the player's edits by record id, and the DLL checks each one against what Progressive registers:
- A season Progressive no longer has: the layer targets a record that doesn't exist, so the core skips it and logs it.
- A slot Progressive removed:
s.teams.find(slot)returns nothing, and that seat edit is skipped. - A driver that no longer exists: the seat's reference can't resolve, the core rejects that edit and logs it, and the seat keeps Progressive's driver.
- A rating record that's gone: skipped, like a missing season.
- Progressive turned off:
requiresstops Play, and the launcher says Progressive is needed. Careers made with the edits also record Progressive as a dependency, and a guard stops them loading without it.
Publish
Package the addon with its DLL and both page binaries, and publish it: see Packaging and publishing. Leave the player's state out: it lives in their profile.