Convert the game data into editable assets

assets/ now holds every track as a 16-colour indexed PNG (its palette is
the track palette) plus a text file of lap gates and height map, and
every vehicle as a PNG sprite sheet plus a text file with its name and
handling (moved out of the code). The build embeds assets/ instead of
unpacking the ADFs, so the disks are no longer needed to build either.

skids extract converts original disks to assets and checks each file
reads back identically; benchmark results from the assets match the
disks exactly. Assets win over disk data with the same name, and
--data assets picks up edits without a rebuild. assets/README.md
describes how to edit everything.
This commit is contained in:
2026-10-19 19:38:08 +00:00
parent ea6948db31
commit 15ba71587c
80 changed files with 3721 additions and 288 deletions
+5 -13
View File
@@ -81,7 +81,6 @@ enum Screen {
pub struct App {
pub data: GameData,
tracks: Vec<(usize, usize)>,
pub settings: Settings,
screen: Screen,
pub frame: Frame,
@@ -96,7 +95,6 @@ pub struct App {
impl App {
pub fn new(data: GameData, audio: Audio) -> App {
let tracks = data.track_list();
let cars = [
data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0),
data.vehicles
@@ -106,7 +104,6 @@ impl App {
];
App {
data,
tracks,
settings: Settings {
players: 1,
track: 0,
@@ -150,8 +147,7 @@ impl App {
laps: usize,
seed: u32,
) -> Result<Race, String> {
let (f, s) = self.tracks[track_index];
let track = self.data.load_track(f, s)?;
let track = self.data.load_track(track_index)?;
let nv = self.data.vehicles.len();
let mut setup = Vec::new();
for (p, &car) in cars.iter().enumerate().take(humans) {
@@ -182,8 +178,7 @@ impl App {
fn thumb(&mut self, i: usize) -> Option<&Vec<u32>> {
if !self.thumbs.contains_key(&i) {
let (f, s) = self.tracks[i];
let t = self.data.load_track(f, s).ok()?;
let t = self.data.load_track(i).ok()?;
self.thumbs
.insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H));
}
@@ -275,7 +270,7 @@ impl App {
if delta != 0 {
match item {
Item::Players => s.players = if s.players == 1 { 2 } else { 1 },
Item::Track => s.track = wrap(s.track, self.tracks.len(), delta),
Item::Track => s.track = wrap(s.track, self.data.tracks.len(), delta),
Item::Laps => {
let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1);
s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)];
@@ -340,7 +335,7 @@ impl App {
.as_ref()
.is_none_or(|d| d.phase == Phase::Done || d.race_time() > 75.0);
if finished {
self.demo_track = (self.demo_track + 7) % self.tracks.len();
self.demo_track = (self.demo_track + 7) % self.data.tracks.len();
self.demo = self
.build_race(self.demo_track, 0, [0, 0], 4, 2, self.tick as u32 + 99)
.ok()
@@ -414,10 +409,7 @@ impl App {
let label = match item {
Item::Start => "START RACE".to_string(),
Item::Players => format!("PLAYERS < {} >", s.players),
Item::Track => format!("TRACK < {} >", {
let (fi, si) = self.tracks[s.track];
self.data.track_files[fi].track_name(si)
}),
Item::Track => format!("TRACK < {} >", self.data.tracks[s.track].name),
Item::Laps => format!("LAPS < {} >", s.laps),
Item::Car1 => format!("P1 CAR < {} >", self.data.vehicles[s.cars[0]].name),
Item::Car2 => format!("P2 CAR < {} >", self.data.vehicles[s.cars[1]].name),
+137
View File
@@ -0,0 +1,137 @@
//! Indexed-colour PNG reading and writing for the editable assets.
use super::palette::Rgb;
pub struct Indexed {
pub w: usize,
pub h: usize,
pub pixels: Vec<u8>,
/// The PNG's own palette, if it had one.
pub palette: Option<Vec<Rgb>>,
}
/// Writes an 8-bit indexed PNG. With `transparent_zero`, index 0 is marked
/// fully transparent so sprite sheets show their backgrounds as clear.
pub fn write_indexed(
w: usize,
h: usize,
palette: &[Rgb],
transparent_zero: bool,
pixels: &[u8],
) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
{
let mut enc = png::Encoder::new(&mut out, w as u32, h as u32);
enc.set_color(png::ColorType::Indexed);
enc.set_depth(png::BitDepth::Eight);
enc.set_palette(
palette
.iter()
.flat_map(|c| [c.0, c.1, c.2])
.collect::<Vec<u8>>(),
);
if transparent_zero {
enc.set_trns(vec![0u8]);
}
let mut wr = enc.write_header().map_err(|e| e.to_string())?;
wr.write_image_data(pixels).map_err(|e| e.to_string())?;
}
Ok(out)
}
/// Reads a PNG as palette indices. Indexed images keep their indices; images
/// an editor has converted to RGB(A) are mapped to the nearest colour in
/// `fallback`, with fully transparent pixels becoming index 0.
pub fn read_indexed(bytes: &[u8], fallback: &[Rgb]) -> Result<Indexed, String> {
let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
dec.set_transformations(png::Transformations::STRIP_16);
let mut reader = dec.read_info().map_err(|e| e.to_string())?;
let info = reader.info();
let (w, h) = (info.width as usize, info.height as usize);
let own_palette = info.palette.as_ref().map(|p| {
p.chunks_exact(3)
.map(|c| Rgb(c[0], c[1], c[2]))
.collect::<Vec<_>>()
});
let mut buf = vec![0; reader.output_buffer_size().ok_or("image too large")?];
let frame = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
let line = frame.line_size;
let mut pixels = Vec::with_capacity(w * h);
match frame.color_type {
png::ColorType::Indexed => {
let bits = frame.bit_depth as usize;
for y in 0..h {
let row = &buf[y * line..(y + 1) * line];
for x in 0..w {
let bit = x * bits;
let byte = row[bit / 8];
let shift = 8 - bits - (bit % 8);
pixels.push((byte >> shift) & ((1u16 << bits) - 1) as u8);
}
}
return Ok(Indexed {
w,
h,
pixels,
palette: own_palette,
});
}
png::ColorType::Rgb
| png::ColorType::Rgba
| png::ColorType::Grayscale
| png::ColorType::GrayscaleAlpha => {
if frame.bit_depth != png::BitDepth::Eight {
return Err("unsupported bit depth; save as 8-bit".into());
}
let ch = frame.color_type.samples();
for y in 0..h {
let row = &buf[y * line..(y + 1) * line];
for x in 0..w {
let p = &row[x * ch..(x + 1) * ch];
let (c, alpha) = match ch {
1 => (Rgb(p[0], p[0], p[0]), 255),
2 => (Rgb(p[0], p[0], p[0]), p[1]),
3 => (Rgb(p[0], p[1], p[2]), 255),
_ => (Rgb(p[0], p[1], p[2]), p[3]),
};
pixels.push(if alpha < 128 { 0 } else { nearest(c, fallback) });
}
}
}
}
Ok(Indexed {
w,
h,
pixels,
palette: None,
})
}
fn nearest(c: Rgb, pal: &[Rgb]) -> u8 {
let d = |p: &Rgb| {
let (dr, dg, db) = (
c.0 as i32 - p.0 as i32,
c.1 as i32 - p.1 as i32,
c.2 as i32 - p.2 as i32,
);
dr * dr * 3 + dg * dg * 4 + db * db * 2
};
(0..pal.len()).min_by_key(|&i| d(&pal[i])).unwrap_or(0) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indexed_round_trip() {
let pal: Vec<Rgb> = (0..16).map(|i| Rgb(i * 16, i * 8, 255 - i * 16)).collect();
let px: Vec<u8> = (0..64u32).map(|i| (i * 7 % 16) as u8).collect();
let png = write_indexed(8, 8, &pal, false, &px).unwrap();
let back = read_indexed(&png, &pal).unwrap();
assert_eq!((back.w, back.h), (8, 8));
assert_eq!(back.pixels, px);
assert_eq!(back.palette.unwrap(), pal);
}
}
+133 -38
View File
@@ -1,12 +1,15 @@
//! Locates and decodes the original game data.
//!
//! The default build embeds the data from the ADF images it was built
//! alongside (see `build.rs`), so no disks are needed at run time. Extra or
//! replacement data can still be supplied as ADF images, the LHA archives
//! from them, or an extracted tree with `track/`, `vehicles/` and `palettes/`.
//! Files on disk override the built-in ones.
//! The game's data lives in `assets/` as editable files: per-track PNG and
//! text, per-vehicle sprite sheet and settings (see `track.rs` and
//! `vehicle.rs`). The default build embeds that folder (see `build.rs`), so
//! nothing is needed at run time. The original Amiga data is still read as
//! ADF images, LHA archives, or `track/` and `vehicles/` files; `skids
//! extract` converts it to assets. When both exist, assets win, so edits are
//! never shadowed by the disks they came from.
pub mod embedded;
pub mod image;
pub mod palette;
pub mod track;
pub mod vehicle;
@@ -97,14 +100,22 @@ impl FileSet {
}
}
/// Reduces a path to `dir/name` if `dir` is one the game uses.
/// Reduces a path to the key the game looks files up by: `dir/name` for
/// the original data and palettes, `tracks/<name>/<file>` for track assets.
fn logical_key(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
if parts.len() < 2 {
let parts: Vec<String> = path
.split(['/', '\\'])
.filter(|s| !s.is_empty())
.map(str::to_lowercase)
.collect();
let n = parts.len();
if n >= 3 && parts[n - 3] == "tracks" {
return Some(format!("tracks/{}/{}", parts[n - 2], parts[n - 1]));
}
if n < 2 {
return None;
}
let dir = parts[parts.len() - 2].to_lowercase();
let name = parts[parts.len() - 1].to_lowercase();
let (dir, name) = (&parts[n - 2], &parts[n - 1]);
matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}"))
}
@@ -117,7 +128,20 @@ pub fn unpack(d: &[u8]) -> Result<Vec<u8>, crm::CrmError> {
}
}
pub enum TrackSource {
/// `tracks/<name>/` asset files.
Asset { png: String, txt: String },
/// A section of an original `TRACKn` file.
Packed { file: usize, section: usize },
}
pub struct TrackEntry {
pub name: String,
pub source: TrackSource,
}
pub struct GameData {
pub tracks: Vec<TrackEntry>,
pub track_files: Vec<TrackFile>,
pub vehicles: Vec<Vehicle>,
pub files: FileSet,
@@ -133,6 +157,26 @@ impl GameData {
}
let mut warnings = files.warnings.clone();
let mut tracks = Vec::new();
for key in files.files.keys() {
if let Some(rest) = key.strip_prefix("tracks/") {
if let Some(name) = rest.strip_suffix("/track.png") {
let txt = format!("tracks/{name}/track.txt");
if files.files.contains_key(&txt) {
tracks.push(TrackEntry {
name: name.to_uppercase(),
source: TrackSource::Asset {
png: key.clone(),
txt,
},
});
} else {
warnings.push(format!("{key}: no track.txt next to it, skipped"));
}
}
}
}
let mut track_files = Vec::new();
for (key, data) in &files.files {
if let Some(name) = key.strip_prefix("track/") {
@@ -143,25 +187,59 @@ impl GameData {
}
}
track_files.sort_by_key(|t| (t.number, t.name.clone()));
let mut vehicles = Vec::new();
for (key, data) in &files.files {
if let Some(name) = key.strip_prefix("vehicles/") {
match Vehicle::parse(name, data) {
Ok(Some(v)) => vehicles.push(v),
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
Err(e) => warnings.push(format!("{key}: {e}")),
for (file, tf) in track_files.iter().enumerate() {
for section in 0..tf.sections.len() {
let name = tf.track_name(section);
if !tracks.iter().any(|t| t.name == name) {
tracks.push(TrackEntry {
name,
source: TrackSource::Packed { file, section },
});
}
}
}
vehicles.sort_by_key(|v| vehicle::display_order(&v.id));
tracks.sort_by_key(|t| natural_key(&t.name));
if track_files.is_empty() || vehicles.is_empty() {
let mut vehicles: Vec<Vehicle> = Vec::new();
for (key, data) in &files.files {
let Some(id) = key
.strip_prefix("vehicles/")
.and_then(|n| n.strip_suffix(".png"))
else {
continue;
};
let txt = files
.get(&format!("vehicles/{id}.txt"))
.map(String::from_utf8_lossy);
match Vehicle::from_assets(id, data, txt.as_deref()) {
Ok(v) => vehicles.push(v),
Err(e) => warnings.push(format!("{key}: {e}")),
}
}
for (key, data) in &files.files {
let Some(name) = key
.strip_prefix("vehicles/")
.filter(|n| n.ends_with(".aga"))
else {
continue;
};
if vehicles.iter().any(|v| format!("{}.aga", v.id) == name) {
continue;
}
match Vehicle::parse(name, data) {
Ok(Some(v)) => vehicles.push(v),
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
Err(e) => warnings.push(format!("{key}: {e}")),
}
}
vehicles.sort_by_key(|v| (vehicle::display_order(&v.id), v.id.clone()));
if tracks.is_empty() || vehicles.is_empty() {
return Err(format!(
"no usable game data (found {} track files, {} vehicles).\n\
This build has no data built in: point --data at a folder containing the \
Super Skidmarks ADF images, or rebuild with the images next to Cargo.toml.{}",
track_files.len(),
"no usable game data (found {} tracks, {} vehicles).\n\
This build has no data built in: point --data at the assets folder or \
the Super Skidmarks ADF images, or rebuild with assets/ in place.{}",
tracks.len(),
vehicles.len(),
if warnings.is_empty() {
String::new()
@@ -171,6 +249,7 @@ impl GameData {
));
}
Ok(GameData {
tracks,
track_files,
vehicles,
files,
@@ -178,23 +257,39 @@ impl GameData {
})
}
/// Flattened (file, section) list of every playable track.
pub fn track_list(&self) -> Vec<(usize, usize)> {
let mut v = Vec::new();
for (fi, tf) in self.track_files.iter().enumerate() {
for si in 0..tf.sections.len() {
v.push((fi, si));
pub fn load_track(&self, i: usize) -> Result<Track, String> {
let entry = &self.tracks[i];
let mut t = match &entry.source {
TrackSource::Asset { png, txt } => {
let txt = String::from_utf8_lossy(self.files.get(txt).unwrap_or_default());
Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt)
.map_err(|e| format!("{}: {e}", entry.name))?
}
}
v
}
pub fn load_track(&self, file: usize, section: usize) -> Result<Track, String> {
let tf = &self.track_files[file];
let mut t = tf.decode(section)?;
if let Some(p) = palette::override_for(&self.files, &tf.name, section) {
TrackSource::Packed { file, section } => self.track_files[*file].decode(*section)?,
};
if let Some(p) = palette::override_for(&self.files, &entry.name) {
t.palette = p;
}
Ok(t)
}
}
/// Sort key that orders TRACK2-10 after TRACK2-9.
fn natural_key(name: &str) -> Vec<(String, u64)> {
let mut out = Vec::new();
let mut text = String::new();
let mut num = String::new();
for c in name.chars() {
if c.is_ascii_digit() {
num.push(c);
} else {
if !num.is_empty() {
out.push((std::mem::take(&mut text), num.parse().unwrap_or(0)));
num.clear();
}
text.push(c);
}
}
out.push((text, num.parse().unwrap_or(0)));
out
}
+9 -6
View File
@@ -6,8 +6,9 @@
//! ordered dark-to-light, with 0-7 used for scenery and 8-15 for the driving
//! surface, so two ramps give a faithful-looking result.
//!
//! Real palettes can be dropped in as text files (16 lines of `RRGGBB`,
//! `#` comments allowed) next to the game data:
//! Each track asset's PNG carries its own palette, so the usual way to
//! recolour a track is to edit that. Palettes can also be dropped in as text
//! files (16 lines of `RRGGBB`, `#` comments allowed), which win over both:
//! palettes/track1-3.pal one track
//! palettes/track1.pal all six tracks in TRACK1
//! palettes/default.pal every track
@@ -100,11 +101,13 @@ pub fn parse(text: &str) -> Option<[Rgb; 16]> {
(n == 16).then_some(out)
}
pub fn override_for(files: &FileSet, track_file: &str, section: usize) -> Option<[Rgb; 16]> {
let base = track_file.to_lowercase();
/// A palette file for a track named like `TRACK1-3`, most specific first.
pub fn override_for(files: &FileSet, track: &str) -> Option<[Rgb; 16]> {
let name = track.to_lowercase();
let group = name.split('-').next().unwrap_or(&name).to_string();
[
format!("palettes/{base}-{}.pal", section + 1),
format!("palettes/{base}.pal"),
format!("palettes/{name}.pal"),
format!("palettes/{group}.pal"),
"palettes/default.pal".into(),
]
.iter()
+134
View File
@@ -18,6 +18,7 @@
//! only lands on the road on 14 of the 24 tracks, so the grid is placed from
//! the gates instead.
use super::image;
use super::palette::{self, Rgb};
use super::unpack;
use crate::formats::{be16, be32};
@@ -210,6 +211,139 @@ impl Track {
on_screen(sx, sy, 0.0) && self.on_road(sx, sy)
}
/// Loads an editable asset: `track.png` (16-colour indexed; its palette
/// is the track palette) and `track.txt` (gates and height map).
pub fn from_assets(name: &str, png: &[u8], txt: &str) -> Result<Track, String> {
let img = image::read_indexed(png, &palette::TRACK_DEFAULT)?;
if (img.w, img.h) != (W, H) {
return Err(format!(
"track.png is {}x{}, expected {W}x{H}",
img.w, img.h
));
}
if let Some(bad) = img.pixels.iter().find(|&&p| p > 15) {
return Err(format!(
"track.png uses palette index {bad}; tracks may only use 0-15"
));
}
let mut pal = palette::TRACK_DEFAULT;
if let Some(own) = &img.palette {
for (dst, src) in pal.iter_mut().zip(own) {
*dst = *src;
}
}
let mut gates = Vec::new();
let mut start = (0.0, 0.0);
let mut flags = 0;
let mut height = Vec::with_capacity(HM_W * HM_H);
let mut in_height = false;
for (n, line) in txt.lines().enumerate() {
let err = |m: &str| format!("track.txt line {}: {m}", n + 1);
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut words = line.split_whitespace();
let first = words.next().unwrap_or("");
let nums = |w: std::str::SplitWhitespace| -> Result<Vec<f32>, String> {
w.map(|x| {
x.parse::<f32>()
.map_err(|_| err(&format!("`{x}` is not a number")))
})
.collect()
};
if in_height {
for x in line.split_whitespace() {
height.push(
x.parse::<u16>()
.map_err(|_| err(&format!("bad height `{x}`")))?,
);
}
continue;
}
match first {
"gate" => {
let v = nums(words)?;
if v.len() != 4 {
return Err(err("gate needs x1 y1 x2 y2"));
}
gates.push(Gate {
a: (v[0], v[1]),
b: (v[2], v[3]),
});
}
"start" => {
let v = nums(words)?;
if v.len() != 2 {
return Err(err("start needs x y"));
}
start = (v[0], v[1]);
}
"flags" => {
flags = words
.next()
.and_then(|x| x.parse().ok())
.ok_or_else(|| err("bad flags"))?
}
"height" => in_height = true,
other => return Err(err(&format!("unknown entry `{other}`"))),
}
}
if gates.len() < 2 {
return Err("track.txt needs at least two gates".into());
}
if height.len() != HM_W * HM_H {
return Err(format!(
"track.txt height map has {} values, expected {}",
height.len(),
HM_W * HM_H
));
}
Ok(Track {
name: name.to_string(),
pixels: img.pixels,
palette: pal,
height,
gates,
start,
flags,
})
}
pub fn to_png(&self) -> Result<Vec<u8>, String> {
image::write_indexed(W, H, &self.palette, false, &self.pixels)
}
pub fn to_text(&self) -> String {
let mut t = format!(
"# Skids track {}. Lines starting with # are comments.\n\
#\n\
# The picture is track.png: 1024x512, 16 colours. Its palette is the\n\
# track's palette. Colours 8-15 are road (full grip), 0-7 are scenery.\n\
#\n\
# gate x1 y1 x2 y2: lap gates in race order, in world units. Gate 0 is\n\
# the start/finish line; the grid forms up behind it. World to picture:\n\
# picture x = wx - wy + 512, picture y = (wx + wy) * 31/64 - 252\n",
self.name
);
for g in &self.gates {
t.push_str(&format!("gate {} {} {} {}\n", g.a.0, g.a.1, g.b.0, g.b.1));
}
t.push_str(&format!(
"\n# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
# height: 64 rows of 64 values, ground height x 256. Each value covers\n\
# 16x8 pixels of the picture.\nheight\n",
self.start.0, self.start.1, self.flags
));
for row in self.height.chunks(HM_W) {
let line: Vec<String> = row.iter().map(|v| v.to_string()).collect();
t.push_str(&line.join(" "));
t.push('\n');
}
t
}
pub fn rgb(&self) -> Vec<u32> {
let pal: Vec<u32> = self.palette.iter().map(|c| c.to_u32()).collect();
self.pixels.iter().map(|&i| pal[i as usize]).collect()
+259 -1
View File
@@ -1,5 +1,12 @@
//! Vehicle sprite banks (`vehicles/*.aga`).
//! Vehicles: sprite banks and handling.
//!
//! Editable assets (`assets/vehicles/<id>.png` + `<id>.txt`): the PNG is a
//! sheet of 32 headings across by 25 tilt blocks down, each cell 64x32, with
//! palette indices 0 (transparent), 1 (tyres/shadow), 2 (trim), 3 (body).
//! Its colours are only a preview; cars are recoloured per player. The text
//! file holds the display name and handling.
//!
//! Original data (`vehicles/*.aga`):
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes.
//! Each frame is a 64x32 AGA sprite with two bitplanes stored per line
//! (8 bytes plane 0, 8 bytes plane 1), giving three colours plus transparent.
@@ -8,6 +15,8 @@
//! Heading frame 12 faces screen-right and frames advance clockwise in
//! 11.25 degree steps.
use super::image;
use super::palette::{self, Rgb};
use super::unpack;
pub const SPR_W: usize = 64;
@@ -18,9 +27,115 @@ pub const LEVEL: usize = 12;
const FRAME_BYTES: usize = 512;
const BANK_BYTES: usize = TILTS * HEADINGS * FRAME_BYTES;
pub const SHEET_W: usize = SPR_W * HEADINGS;
pub const SHEET_H: usize = SPR_H * TILTS;
/// Handling. The retail executable is not available, so these values are a
/// reconstruction; edit them in the vehicle's `.txt` asset.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spec {
/// Top speed on tarmac, world pixels per second.
pub top: f32,
pub accel: f32,
pub brake: f32,
/// Lateral grip: how quickly sideways motion is killed (1/s).
pub grip: f32,
/// Steering rate at speed (rad/s).
pub turn: f32,
pub mass: f32,
/// Speed and grip multiplier off the road.
pub offroad: f32,
}
pub const BASE_SPEC: Spec = Spec {
top: 200.0,
accel: 170.0,
brake: 320.0,
grip: 5.0,
turn: 3.6,
mass: 1.0,
offroad: 0.55,
};
/// Starting values used when importing the original data.
pub fn default_spec(id: &str) -> Spec {
let b = BASE_SPEC;
match id {
"f1" => Spec {
top: 240.0,
accel: 210.0,
grip: 6.5,
turn: 3.7,
mass: 0.9,
offroad: 0.45,
..b
},
"porsche" => Spec {
top: 225.0,
accel: 190.0,
grip: 5.2,
turn: 3.6,
..b
},
"mini" => Spec {
top: 190.0,
accel: 200.0,
grip: 5.8,
turn: 4.4,
mass: 0.8,
..b
},
"vw" => Spec {
top: 185.0,
accel: 160.0,
grip: 4.6,
turn: 3.6,
mass: 1.0,
..b
},
"midget" => Spec {
top: 205.0,
accel: 185.0,
grip: 4.0,
turn: 4.0,
mass: 0.85,
offroad: 0.6,
..b
},
"truck" => Spec {
top: 195.0,
accel: 140.0,
grip: 4.2,
turn: 3.0,
mass: 1.8,
offroad: 0.75,
..b
},
"police" => Spec {
top: 215.0,
accel: 180.0,
grip: 5.0,
turn: 3.5,
mass: 1.2,
..b
},
"cow" => Spec {
top: 170.0,
accel: 150.0,
grip: 7.0,
turn: 4.6,
mass: 1.4,
offroad: 0.9,
..b
},
_ => b,
}
}
pub struct Vehicle {
pub id: String,
pub name: String,
pub spec: Spec,
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
pub frames: Vec<u8>,
}
@@ -51,11 +166,124 @@ impl Vehicle {
let id = file.trim_end_matches(".aga").to_lowercase();
Ok(Some(Vehicle {
name: display_name(&id),
spec: default_spec(&id),
id,
frames,
}))
}
/// Loads an editable asset: sprite sheet PNG plus optional settings text.
pub fn from_assets(id: &str, png: &[u8], txt: Option<&str>) -> Result<Vehicle, String> {
let img = image::read_indexed(png, &preview_palette())?;
if (img.w, img.h) != (SHEET_W, SHEET_H) {
return Err(format!(
"sheet is {}x{}, expected {SHEET_W}x{SHEET_H}",
img.w, img.h
));
}
if let Some(bad) = img.pixels.iter().find(|&&p| p > 3) {
return Err(format!("uses palette index {bad}; cars may only use 0-3"));
}
let mut frames = vec![0u8; TILTS * HEADINGS * SPR_W * SPR_H];
for tilt in 0..TILTS {
for hd in 0..HEADINGS {
let f = (tilt * HEADINGS + hd) * SPR_W * SPR_H;
for y in 0..SPR_H {
let src = (tilt * SPR_H + y) * SHEET_W + hd * SPR_W;
frames[f + y * SPR_W..f + (y + 1) * SPR_W]
.copy_from_slice(&img.pixels[src..src + SPR_W]);
}
}
}
let mut v = Vehicle {
id: id.to_string(),
name: display_name(id),
spec: default_spec(id),
frames,
};
if let Some(txt) = txt {
v.apply_settings(txt)?;
}
Ok(v)
}
pub fn sheet_png(&self) -> Result<Vec<u8>, String> {
let mut px = vec![0u8; SHEET_W * SHEET_H];
for tilt in 0..TILTS {
for hd in 0..HEADINGS {
let fr = self.frame(tilt, hd);
for y in 0..SPR_H {
let dst = (tilt * SPR_H + y) * SHEET_W + hd * SPR_W;
px[dst..dst + SPR_W].copy_from_slice(&fr[y * SPR_W..(y + 1) * SPR_W]);
}
}
}
image::write_indexed(SHEET_W, SHEET_H, &preview_palette(), true, &px)
}
pub fn settings_text(&self) -> String {
let s = &self.spec;
format!(
"# Skids vehicle settings. Lines starting with # are comments.\n\
# The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\
name {name}\n\
# top speed on tarmac, world pixels per second\n\
top {top}\n\
# acceleration and braking, world pixels per second squared\n\
accel {accel}\n\
brake {brake}\n\
# sideways grip (higher = less sliding)\n\
grip {grip}\n\
# steering rate, radians per second\n\
turn {turn}\n\
# weight in collisions\n\
mass {mass}\n\
# speed and grip multiplier off the road\n\
offroad {offroad}\n",
id = self.id,
name = self.name,
top = s.top,
accel = s.accel,
brake = s.brake,
grip = s.grip,
turn = s.turn,
mass = s.mass,
offroad = s.offroad
)
}
fn apply_settings(&mut self, txt: &str) -> Result<(), String> {
for (n, line) in txt.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
let Some((key, val)) = line.split_once(char::is_whitespace) else {
if line.is_empty() {
continue;
}
return Err(format!("line {}: expected `key value`", n + 1));
};
let val = val.trim();
if key == "name" {
self.name = val.to_uppercase();
continue;
}
let num: f32 = val
.parse()
.map_err(|_| format!("line {}: `{val}` is not a number", n + 1))?;
let s = &mut self.spec;
match key {
"top" => s.top = num,
"accel" => s.accel = num,
"brake" => s.brake = num,
"grip" => s.grip = num,
"turn" => s.turn = num,
"mass" => s.mass = num.max(0.05),
"offroad" => s.offroad = num,
_ => return Err(format!("line {}: unknown setting `{key}`", n + 1)),
}
}
Ok(())
}
pub fn frame(&self, tilt: usize, heading: usize) -> &[u8] {
let f = tilt.min(TILTS - 1) * HEADINGS + heading % HEADINGS;
&self.frames[f * SPR_W * SPR_H..(f + 1) * SPR_W * SPR_H]
@@ -73,6 +301,13 @@ pub fn tilt_block(pitch: i32, roll: i32) -> usize {
((roll.clamp(-2, 2) + 2) * 5 + (pitch.clamp(-2, 2) + 2)) as usize
}
/// Colours stored in sprite sheets so they preview sensibly in an editor.
fn preview_palette() -> Vec<Rgb> {
let p = palette::car_palette(0);
let rgb = |c: u32| Rgb((c >> 16) as u8, (c >> 8) as u8, c as u8);
vec![Rgb(0x30, 0x48, 0x30), rgb(p[1]), rgb(p[2]), rgb(p[3])]
}
fn display_name(id: &str) -> String {
match id {
"vw" => "BEETLE".into(),
@@ -101,6 +336,29 @@ mod tests {
assert_eq!(heading_frame(-PI + FRAC_PI_4), 4);
}
#[test]
fn settings_round_trip() {
let mut v = Vehicle {
id: "f1".into(),
name: "X".into(),
spec: BASE_SPEC,
frames: vec![0; TILTS * HEADINGS * SPR_W * SPR_H],
};
v.spec.grip = 6.25;
v.name = "FORMULA 1".into();
let txt = v.settings_text();
let mut w = Vehicle {
id: "f1".into(),
name: String::new(),
spec: BASE_SPEC,
frames: Vec::new(),
};
w.apply_settings(&txt).unwrap();
assert_eq!(w.spec, v.spec);
assert_eq!(w.name, "FORMULA 1");
assert!(w.apply_settings("grip fast").is_err());
}
#[test]
fn level_block() {
assert_eq!(tilt_block(0, 0), LEVEL);
+5 -102
View File
@@ -1,111 +1,14 @@
//! Vehicle handling.
//!
//! The retail executable is not available, so handling is a reconstruction:
//! an arcade model with separate forward and lateral friction, which gives
//! The retail executable is not available, so handling is a reconstruction
//! (per-car values live in the vehicle assets): an arcade model with separate forward and lateral friction, which gives
//! the long power-slides the series is known for. Physics runs on the
//! isometric ground plane (see `data::track::to_screen`).
use crate::data::track::{self, Track};
use crate::data::vehicle;
use crate::data::vehicle::{self, Spec};
use std::f32::consts::{PI, TAU};
#[derive(Clone, Copy, Debug)]
pub struct Spec {
/// Top speed on tarmac, world pixels per second.
pub top: f32,
pub accel: f32,
pub brake: f32,
/// Lateral grip: how quickly sideways motion is killed (1/s).
pub grip: f32,
/// Steering rate at speed (rad/s).
pub turn: f32,
pub mass: f32,
/// Speed and grip multiplier off the road.
pub offroad: f32,
}
pub fn spec_for(id: &str) -> Spec {
let base = Spec {
top: 200.0,
accel: 170.0,
brake: 320.0,
grip: 5.0,
turn: 3.6,
mass: 1.0,
offroad: 0.55,
};
match id {
"f1" => Spec {
top: 240.0,
accel: 210.0,
grip: 6.5,
turn: 3.7,
mass: 0.9,
offroad: 0.45,
..base
},
"porsche" => Spec {
top: 225.0,
accel: 190.0,
grip: 5.2,
turn: 3.6,
..base
},
"mini" => Spec {
top: 190.0,
accel: 200.0,
grip: 5.8,
turn: 4.4,
mass: 0.8,
..base
},
"vw" => Spec {
top: 185.0,
accel: 160.0,
grip: 4.6,
turn: 3.6,
mass: 1.0,
..base
},
"midget" => Spec {
top: 205.0,
accel: 185.0,
grip: 4.0,
turn: 4.0,
mass: 0.85,
offroad: 0.6,
..base
},
"truck" => Spec {
top: 195.0,
accel: 140.0,
grip: 4.2,
turn: 3.0,
mass: 1.8,
offroad: 0.75,
..base
},
"police" => Spec {
top: 215.0,
accel: 180.0,
grip: 5.0,
turn: 3.5,
mass: 1.2,
..base
},
"cow" => Spec {
top: 170.0,
accel: 150.0,
grip: 7.0,
turn: 4.6,
mass: 1.4,
offroad: 0.9,
..base
},
_ => base,
}
}
pub const RADIUS: f32 = 11.0;
const SLOPE_GRAVITY: f32 = 110.0;
/// Cap on slope acceleration so the steep banking on some tracks cannot pin
@@ -143,10 +46,10 @@ pub struct Car {
}
impl Car {
pub fn new(vehicle: usize, id: &str, team: usize, x: f32, y: f32, heading: f32) -> Car {
pub fn new(vehicle: usize, spec: Spec, team: usize, x: f32, y: f32, heading: f32) -> Car {
Car {
vehicle,
spec: spec_for(id),
spec,
team,
x,
y,
+1 -1
View File
@@ -100,7 +100,7 @@ impl Race {
let ((px, py), heading) = behind(&path, 18.0 + row * 30.0);
let (rx, ry) = (-heading.sin(), heading.cos());
let (x, y) = (px + rx * side * 12.0, py + ry * side * 12.0);
let car = Car::new(s.vehicle, &vehicles[s.vehicle].id, s.team, x, y, heading);
let car = Car::new(s.vehicle, vehicles[s.vehicle].spec, s.team, x, y, heading);
let line = 0.3 + 0.4 * rand01(&mut rng);
let pace = 0.86 + 0.12 * rand01(&mut rng);
entrants.push(Entrant {
+46 -51
View File
@@ -23,7 +23,7 @@ skids - Super Skidmarks revival
USAGE:
skids [--data DIR] [--scale 1|2|4] [--mute] play
skids info [--data DIR] list what was found in the data
skids extract [--data DIR] --out DIR write PNGs of every track and car
skids extract [--data DIR] [--out DIR] convert the data to editable assets (default ./assets)
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu]
simulate a race (or the menu) and save a frame
skids bench [--data DIR] [--seconds S] race computer cars on every track
@@ -165,11 +165,14 @@ fn info(data: &GameData, dir: Option<&Path>) {
(n, None) => println!("Data: built in ({n} files)"),
}
println!("Tracks:");
for (f, s) in data.track_list() {
let tf = &data.track_files[f];
match tf.decode(s) {
Ok(t) => println!(" {:<10} {:>2} gates", t.name, t.gates.len()),
Err(e) => println!(" {:<10} ERROR {e}", tf.track_name(s)),
for (i, entry) in data.tracks.iter().enumerate() {
let from = match entry.source {
data::TrackSource::Asset { .. } => "asset",
data::TrackSource::Packed { .. } => "original data",
};
match data.load_track(i) {
Ok(t) => println!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()),
Err(e) => println!(" {:<10} ERROR {e}", entry.name),
}
}
println!("Vehicles:");
@@ -178,54 +181,46 @@ fn info(data: &GameData, dir: Option<&Path>) {
}
}
/// Writes every track and vehicle as editable assets, then reads each one
/// back to check the conversion is lossless.
fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
use data::{track, vehicle};
let out = out.ok_or("extract needs --out DIR")?;
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
for (f, s) in data.track_list() {
let t = data.load_track(f, s)?;
let mut img = t.rgb();
// Overlay the lap gates so the track data can be checked by eye.
for g in &t.gates {
let steps = 64;
for i in 0..=steps {
let k = i as f32 / steps as f32;
let (x, y) =
track::to_screen(g.a.0 + (g.b.0 - g.a.0) * k, g.a.1 + (g.b.1 - g.a.1) * k);
if (0.0..track::W as f32).contains(&x) && (0.0..track::H as f32).contains(&y) {
img[y as usize * track::W + x as usize] = 0xff2020;
}
}
use data::{Track, Vehicle};
let out = out.unwrap_or(Path::new("assets"));
let write = |path: PathBuf, bytes: &[u8]| -> Result<(), String> {
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))
};
for i in 0..data.tracks.len() {
let t = data.load_track(i)?;
let dir = out.join("tracks").join(t.name.to_lowercase());
let (png, txt) = (t.to_png()?, t.to_text());
let back = Track::from_assets(&t.name, &png, &txt)?;
let same = back.pixels == t.pixels
&& back.palette == t.palette
&& back.height == t.height
&& back
.gates
.iter()
.zip(&t.gates)
.all(|(a, b)| a.a == b.a && a.b == b.b)
&& back.gates.len() == t.gates.len();
if !same {
return Err(format!("{}: asset does not round-trip", t.name));
}
let path = out.join(format!("{}.png", t.name.to_lowercase()));
render::save_png(&path, track::W, track::H, &img).map_err(|e| e.to_string())?;
println!("wrote {}", path.display());
write(dir.join("track.png"), &png)?;
write(dir.join("track.txt"), txt.as_bytes())?;
println!("wrote {}", dir.display());
}
for (vi, v) in data.vehicles.iter().enumerate() {
// Sheet: 32 headings across, 25 tilt blocks down.
let (w, h) = (
vehicle::SPR_W * vehicle::HEADINGS,
vehicle::SPR_H * vehicle::TILTS,
);
let pal = data::palette::car_palette(vi);
let mut img = vec![0x203018u32; w * h];
for tilt in 0..vehicle::TILTS {
for hd in 0..vehicle::HEADINGS {
let fr = v.frame(tilt, hd);
for y in 0..vehicle::SPR_H {
for x in 0..vehicle::SPR_W {
let i = fr[y * vehicle::SPR_W + x];
if i != 0 {
img[(tilt * vehicle::SPR_H + y) * w + hd * vehicle::SPR_W + x] =
pal[i as usize];
}
}
}
}
for v in &data.vehicles {
let (png, txt) = (v.sheet_png()?, v.settings_text());
let back = Vehicle::from_assets(&v.id, &png, Some(&txt))?;
if back.frames != v.frames || back.spec != v.spec || back.name != v.name {
return Err(format!("{}: asset does not round-trip", v.id));
}
let path = out.join(format!("car-{}.png", v.id));
render::save_png(&path, w, h, &img).map_err(|e| e.to_string())?;
println!("wrote {}", path.display());
let dir = out.join("vehicles");
write(dir.join(format!("{}.png", v.id)), &png)?;
write(dir.join(format!("{}.txt", v.id)), txt.as_bytes())?;
println!("wrote {}", dir.join(&v.id).display());
}
Ok(())
}
@@ -293,7 +288,7 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
"{:<10} {:>5} {:>8} {:>9} {:>8}",
"TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD"
);
for ti in 0..app.data.track_list().len() {
for ti in 0..app.data.tracks.len() {
let mut race = app.build_race(ti, 0, [0, 0], 4, 99, ti as u32 + 1)?;
let (mut road, mut samples) = (0usize, 0usize);
for _ in 0..frames {