Add Rust revival engine for Super Skidmarks

Cross-platform (Windows/macOS/Linux) engine that plays the original
Amiga data straight from the ADF images:

- ADF (OFS/FFS), LHA and CrunchMania (CrM!/CrM2) readers
- Track decoding: 24 tracks of 1024x512 16-colour planar art, height
  maps and lap gates on an isometric ground plane
- Vehicle decoding: 8 cars, 32 headings x 25 tilt frames of 64x32
  AGA sprites
- Reconstructed handling, AI, race rules, menus and synthesised sound
  (disk 1 with the executable, palettes and audio is not in the set)
- info/extract/bench/trace/render tools, format notes, CI builds
This commit is contained in:
2026-10-19 18:31:17 +00:00
parent f8ba2ecf4d
commit f88ba23282
22 changed files with 5656 additions and 0 deletions
+692
View File
@@ -0,0 +1,692 @@
//! Front end: menu, race screens and results.
use crate::audio::Audio;
use crate::data::{palette, track, vehicle, GameData};
use crate::game::car::Controls;
use crate::game::{Driver, EntrantSetup, Phase, Race};
use crate::render::{self, Frame, Rect, SCREEN_H, SCREEN_W};
use minifb::Key;
use std::collections::HashMap;
const THUMB_W: usize = 128;
const THUMB_H: usize = 64;
const CPU_NAMES: [&str; 8] = [
"ACE", "BLITZ", "DUSTY", "NITRO", "ROCKET", "SPUD", "TURBO", "ZIPPY",
];
const LAP_CHOICES: [usize; 5] = [1, 3, 5, 7, 10];
const WHITE: u32 = 0xffffff;
const YELLOW: u32 = 0xffd840;
const GREY: u32 = 0x9098a0;
const RED: u32 = 0xff5040;
#[derive(Default)]
pub struct Input {
pub held: Vec<Key>,
pub pressed: Vec<Key>,
}
impl Input {
pub fn down(&self, k: Key) -> bool {
self.held.contains(&k)
}
pub fn hit(&self, k: Key) -> bool {
self.pressed.contains(&k)
}
fn controls(&self, up: Key, down: Key, left: Key, right: Key) -> Controls {
let axis = |neg: Key, pos: Key| (self.down(pos) as i32 - self.down(neg) as i32) as f32;
Controls {
throttle: self.down(up) as i32 as f32,
brake: self.down(down) as i32 as f32,
steer: axis(left, right),
}
}
}
pub struct Settings {
pub players: usize,
pub track: usize,
pub laps: usize,
pub cars: [usize; 2],
pub opponents: usize,
}
#[derive(Clone, Copy, PartialEq)]
enum Item {
Start,
Players,
Track,
Laps,
Car1,
Car2,
Opponents,
Quit,
}
enum Screen {
Menu {
cursor: usize,
},
Race {
race: Box<Race>,
cams: [(f32, f32); 2],
paused: bool,
},
Results {
race: Box<Race>,
},
}
pub struct App {
pub data: GameData,
tracks: Vec<(usize, usize)>,
pub settings: Settings,
screen: Screen,
pub frame: Frame,
audio: Audio,
demo: Option<Box<Race>>,
demo_track: usize,
thumbs: HashMap<usize, Vec<u32>>,
tick: u64,
pub quit: bool,
pub status: Option<String>,
}
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
.iter()
.position(|v| v.id == "mini")
.unwrap_or(0),
];
App {
data,
tracks,
settings: Settings {
players: 1,
track: 0,
laps: 3,
cars,
opponents: 3,
},
screen: Screen::Menu { cursor: 0 },
frame: Frame::new(),
audio,
demo: None,
demo_track: 0,
thumbs: HashMap::new(),
tick: 0,
quit: false,
status: None,
}
}
fn items(&self) -> Vec<Item> {
let mut v = vec![
Item::Start,
Item::Players,
Item::Track,
Item::Laps,
Item::Car1,
];
if self.settings.players == 2 {
v.push(Item::Car2);
}
v.extend([Item::Opponents, Item::Quit]);
v
}
pub fn build_race(
&self,
track_index: usize,
humans: usize,
cars: [usize; 2],
opponents: usize,
laps: usize,
seed: u32,
) -> Result<Race, String> {
let (f, s) = self.tracks[track_index];
let track = self.data.load_track(f, s)?;
let nv = self.data.vehicles.len();
let mut setup = Vec::new();
for (p, &car) in cars.iter().enumerate().take(humans) {
setup.push(EntrantSetup {
vehicle: car,
driver: Driver::Human(p),
team: p,
name: format!("PLAYER {}", p + 1),
});
}
let mut rng = seed.wrapping_mul(2654435761) | 1;
for i in 0..opponents.min(8 - humans) {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
setup.push(EntrantSetup {
vehicle: rng as usize % nv,
driver: Driver::Cpu,
team: humans + i,
name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(),
});
}
// Humans start at the back, as is tradition.
let back = humans.min(setup.len());
setup.rotate_left(back);
Ok(Race::new(track, &self.data.vehicles, &setup, laps, seed))
}
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()?;
self.thumbs
.insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H));
}
self.thumbs.get(&i)
}
pub fn update(&mut self, input: &Input) {
self.tick += 1;
match &mut self.screen {
Screen::Menu { .. } => self.update_menu(input),
Screen::Race { race, paused, .. } => {
if input.hit(Key::Escape) {
self.audio.silence();
self.screen = Screen::Menu { cursor: 0 };
return;
}
if input.hit(Key::P) {
*paused = !*paused;
}
if *paused {
self.audio.silence();
return;
}
let two = self.settings.players == 2;
let p1 = if two {
input.controls(Key::Up, Key::Down, Key::Left, Key::Right)
} else {
let a = input.controls(Key::Up, Key::Down, Key::Left, Key::Right);
let b = input.controls(Key::W, Key::S, Key::A, Key::D);
Controls {
throttle: a.throttle.max(b.throttle),
brake: a.brake.max(b.brake),
steer: (a.steer + b.steer).clamp(-1.0, 1.0),
}
};
let p2 = input.controls(Key::W, Key::S, Key::A, Key::D);
race.step(&[p1, p2]);
for (i, e) in race
.entrants
.iter()
.filter(|e| matches!(e.driver, Driver::Human(_)))
.enumerate()
{
let c = &e.car;
let skid = if c.on_road {
((c.slip - 50.0) / 100.0).clamp(0.0, 1.0)
} else {
0.0
};
self.audio.set_voice(i, true, c.rpm, skid, c.bump);
}
if race.phase == Phase::Done {
self.audio.silence();
let Screen::Race { race, .. } =
std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 })
else {
unreachable!()
};
self.screen = Screen::Results { race };
}
}
Screen::Results { .. } => {
if input.hit(Key::Enter) || input.hit(Key::Escape) || input.hit(Key::Space) {
self.screen = Screen::Menu { cursor: 0 };
}
}
}
}
fn update_menu(&mut self, input: &Input) {
self.step_demo();
let items = self.items();
let Screen::Menu { cursor } = &mut self.screen else {
return;
};
let mut cur = (*cursor).min(items.len() - 1);
if input.hit(Key::Up) {
cur = (cur + items.len() - 1) % items.len();
}
if input.hit(Key::Down) {
cur = (cur + 1) % items.len();
}
*cursor = cur;
let delta = input.hit(Key::Right) as i32 - input.hit(Key::Left) as i32;
let item = items[cur];
let s = &mut self.settings;
let nv = self.data.vehicles.len();
let wrap = |v: usize, n: usize, d: i32| (v as i32 + d).rem_euclid(n as i32) as usize;
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::Laps => {
let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1);
s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)];
}
Item::Car1 => s.cars[0] = wrap(s.cars[0], nv, delta),
Item::Car2 => s.cars[1] = wrap(s.cars[1], nv, delta),
Item::Opponents => s.opponents = wrap(s.opponents, 8 - s.players + 1, delta),
_ => {}
}
s.opponents = s.opponents.min(8 - s.players);
}
if input.hit(Key::Escape) {
self.quit = true;
}
if input.hit(Key::Enter) || input.hit(Key::Space) {
match item {
Item::Quit => self.quit = true,
Item::Start
| Item::Track
| Item::Car1
| Item::Car2
| Item::Laps
| Item::Players
| Item::Opponents => {
let s = &self.settings;
match self.build_race(
s.track,
s.players,
s.cars,
s.opponents,
s.laps,
self.tick as u32,
) {
Ok(r) => {
let cams = [(0.0, 0.0); 2];
let mut screen = Screen::Race {
race: Box::new(r),
cams,
paused: false,
};
if let Screen::Race { race, cams, .. } = &mut screen {
for (i, cam) in cams.iter_mut().enumerate() {
if let Some(e) =
race.entrants.iter().find(|e| e.driver == Driver::Human(i))
{
*cam = e.car.screen_pos();
}
}
}
self.screen = screen;
}
Err(e) => self.status = Some(e),
}
}
}
}
}
fn step_demo(&mut self) {
let finished = self
.demo
.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 = self
.build_race(self.demo_track, 0, [0, 0], 4, 2, self.tick as u32 + 99)
.ok()
.map(Box::new);
}
if let Some(d) = &mut self.demo {
d.step(&[]);
}
}
pub fn draw(&mut self) {
let take = std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 });
match &take {
Screen::Menu { cursor } => self.draw_menu(*cursor),
Screen::Race { race, paused, .. } => {
let mut cams = match &take {
Screen::Race { cams, .. } => *cams,
_ => unreachable!(),
};
draw_race(
&mut self.frame,
&self.data,
race,
&mut cams,
self.settings.players,
);
if *paused {
self.frame
.shade(Rect::new(0, 100, SCREEN_W as i32, 40), 120);
self.frame.text_centered(160, 108, "PAUSED", YELLOW, 2);
self.frame
.text_centered(160, 126, "P: RESUME ESC: QUIT RACE", WHITE, 1);
}
self.screen = take;
if let Screen::Race { cams: c, .. } = &mut self.screen {
*c = cams;
}
return;
}
Screen::Results { race } => draw_results(&mut self.frame, &self.data, race),
}
self.screen = take;
}
fn draw_menu(&mut self, cursor: usize) {
let items = self.items();
let f = &mut self.frame;
if let Some(d) = &self.demo {
let leader = d.standings()[0];
let mut cam = [d.entrants[leader].car.screen_pos(); 2];
draw_world(
f,
&self.data,
d,
Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32),
&mut cam[0],
true,
1.0,
);
f.shade(Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32), 110);
} else {
f.clear(0x101820);
}
f.text_centered(160, 10, "SKIDS", YELLOW, 4);
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1);
let cursor = cursor.min(items.len() - 1);
let s = &self.settings;
for (i, item) in items.iter().enumerate() {
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::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),
Item::Opponents => format!("OPPONENTS < {} >", s.opponents),
Item::Quit => "QUIT".to_string(),
};
let y = 64 + i as i32 * 14;
let colour = if i == cursor { YELLOW } else { WHITE };
if i == cursor {
f.text_shadow(8, y, ">", RED, 1);
}
f.text_shadow(18, y, &label, colour, 1);
}
// Track preview.
let track = s.track;
let px = SCREEN_W as i32 - THUMB_W as i32 - 10;
if let Some(th) = self.thumb(track).cloned() {
let f = &mut self.frame;
f.frame_rect(
Rect::new(px - 2, 62, THUMB_W as i32 + 4, THUMB_H as i32 + 4),
WHITE,
);
f.image(&th, THUMB_W, THUMB_H, px, 64);
}
// Car preview, slowly turning.
let f = &mut self.frame;
let s = &self.settings;
let which = if items[cursor] == Item::Car2 { 1 } else { 0 };
let v = &self.data.vehicles[s.cars[which]];
let heading = (self.tick / 4) as usize % vehicle::HEADINGS;
let pal = palette::car_palette(which);
let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, 150);
f.fill(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), 0x203018);
f.frame_rect(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), WHITE);
let clip = Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32);
f.sprite_shadow(v.frame(vehicle::LEVEL, heading), cx + 3, cy + 3, clip);
f.sprite(v.frame(vehicle::LEVEL, heading), &pal, cx, cy, clip);
f.text_centered(px + THUMB_W as i32 / 2, 182, &v.name, WHITE, 1);
let help = if self.settings.players == 2 {
"P1: ARROWS P2: W A S D"
} else {
"DRIVE: ARROWS OR W A S D"
};
f.text_centered(160, 222, help, GREY, 1);
f.text_centered(
160,
236,
"UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO",
GREY,
1,
);
if let Some(st) = &self.status {
f.text_centered(160, 200, st, RED, 1);
}
}
}
pub fn draw_race(
f: &mut Frame,
data: &GameData,
race: &Race,
cams: &mut [(f32, f32); 2],
players: usize,
) {
f.clear(0);
let views: Vec<Rect> = if players == 2 {
vec![
Rect::new(0, 0, SCREEN_W as i32, 127),
Rect::new(0, 129, SCREEN_W as i32, 127),
]
} else {
vec![Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32)]
};
for (p, view) in views.iter().enumerate() {
let Some(ei) = race
.entrants
.iter()
.position(|e| e.driver == Driver::Human(p))
else {
continue;
};
let e = &race.entrants[ei];
// Look ahead in the direction of travel.
let target = track::to_screen(e.car.x + e.car.vx * 0.3, e.car.y + e.car.vy * 0.3);
cams[p].0 += (target.0 - cams[p].0) * 0.12;
cams[p].1 += (target.1 - cams[p].1) * 0.12;
draw_world(f, data, race, *view, &mut cams[p], false, 1.0);
draw_hud(f, race, ei, *view);
}
if players == 2 {
f.fill(Rect::new(0, 127, SCREEN_W as i32, 2), 0x000000);
}
if let Some(n) = race.countdown() {
f.text_centered(160, 110, &n.to_string(), YELLOW, 5);
} else if race.race_time() < 1.0 {
f.text_centered(160, 110, "GO!", YELLOW, 5);
}
}
/// Draws the track and cars into `view`, centred on `cam` (screen-space track pixel).
pub fn draw_world(
f: &mut Frame,
data: &GameData,
race: &Race,
view: Rect,
cam: &mut (f32, f32),
_demo: bool,
_zoom: f32,
) {
let cx =
(cam.0 - view.w as f32 / 2.0).clamp(0.0, (track::W as i32 - view.w).max(0) as f32) as i32;
let cy =
(cam.1 - view.h as f32 / 2.0).clamp(0.0, (track::H as i32 - view.h).max(0) as f32) as i32;
f.blit_track(&race.ground, view, cx, cy);
for p in &race.particles {
let x = view.x + p.x as i32 - cx;
let y = view.y + p.y as i32 - cy;
if x >= view.x && x < view.x + view.w && y >= view.y && y < view.y + view.h {
f.put(x, y, p.colour);
}
}
let mut order: Vec<usize> = (0..race.entrants.len()).collect();
order.sort_by(|&a, &b| {
race.entrants[a]
.car
.screen_pos()
.1
.total_cmp(&race.entrants[b].car.screen_pos().1)
});
for i in order {
let e = &race.entrants[i];
let c = &e.car;
let v = &data.vehicles[c.vehicle];
let (sx, sy) = c.screen_pos();
let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2);
let y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2);
let pix = v.frame(c.tilt, vehicle::heading_frame(c.heading));
f.sprite_shadow(pix, x + 3, y + 2, view);
f.sprite(pix, &palette::car_palette(c.team), x, y, view);
if let Driver::Human(p) = e.driver {
let tag = format!("P{}", p + 1);
let tx = x + 32 - 5;
let ty = y - 2;
if ty > view.y && ty < view.y + view.h - 8 {
f.text_shadow(tx, ty, &tag, if p == 0 { 0xff8070 } else { 0x80a0ff }, 1);
}
}
}
}
fn draw_hud(f: &mut Frame, race: &Race, ei: usize, view: Rect) {
let e = &race.entrants[ei];
let n = race.track.gates.len();
let lap = (e.laps_done(n) + 1).min(race.laps);
let standings = race.standings();
let pos = standings.iter().position(|&i| i == ei).unwrap_or(0) + 1;
let (x, y) = (view.x + 6, view.y + 5);
f.text_shadow(x, y, &format!("LAP {}/{}", lap, race.laps), WHITE, 1);
f.text_shadow(
x,
y + 10,
&format!("POS {}/{}", ordinal(pos), race.entrants.len()),
YELLOW,
1,
);
let t = e.finished.unwrap_or_else(|| race.race_time());
let ts = fmt_time(t);
f.text_shadow(
view.x + view.w - 6 - (ts.len() as i32 * 6),
y,
&ts,
WHITE,
1,
);
if let Some(l) = e.last_lap {
let ls = format!("LAST {}", fmt_time(l));
f.text_shadow(
view.x + view.w - 6 - (ls.len() as i32 * 6),
y + 10,
&ls,
GREY,
1,
);
}
if e.finished.is_some() {
let finish_pos = race.finish_order.iter().position(|&i| i == ei).unwrap_or(0) + 1;
f.text_centered(
view.x + view.w / 2,
view.y + view.h / 2 - 20,
&format!("FINISHED {}", ordinal(finish_pos)),
YELLOW,
2,
);
}
}
fn draw_results(f: &mut Frame, data: &GameData, race: &Race) {
f.clear(0x101820);
f.text_centered(160, 16, "RESULTS", YELLOW, 3);
f.text_centered(160, 44, &race.track.name, GREY, 1);
let n = race.track.gates.len();
f.text_shadow(16, 64, "POS DRIVER CAR TIME BEST", GREY, 1);
let leader_passed = race.entrants.iter().map(|e| e.passed).max().unwrap_or(0);
for (row, &i) in race.standings().iter().enumerate() {
let e = &race.entrants[i];
let time = match e.finished {
Some(t) => fmt_time(t),
None => {
let behind = leader_passed.saturating_sub(e.passed).div_ceil(n);
if behind > 0 {
format!("+{behind} LAP")
} else {
"RUNNING".into()
}
}
};
let best = e.best_lap.map(fmt_time).unwrap_or_else(|| "-".into());
let car = &data.vehicles[e.car.vehicle].name;
let line = format!(
"{:<4} {:<10} {:<11} {:<8} {}",
ordinal(row + 1),
e.name,
truncate(car, 11),
time,
best
);
let colour = if matches!(e.driver, Driver::Human(_)) {
YELLOW
} else {
WHITE
};
f.text_shadow(16, 80 + row as i32 * 12, &line, colour, 1);
}
f.text_centered(160, 236, "PRESS ENTER", GREY, 1);
}
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
pub fn ordinal(n: usize) -> String {
let suffix = match (n % 10, n % 100) {
(1, x) if x != 11 => "ST",
(2, x) if x != 12 => "ND",
(3, x) if x != 13 => "RD",
_ => "TH",
};
format!("{n}{suffix}")
}
pub fn fmt_time(t: f32) -> String {
let tenths = (t * 10.0).round() as u32;
format!("{}:{:02}.{}", tenths / 600, (tenths / 10) % 60, tenths % 10)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formatting() {
assert_eq!(ordinal(1), "1ST");
assert_eq!(ordinal(12), "12TH");
assert_eq!(ordinal(23), "23RD");
assert_eq!(fmt_time(83.46), "1:23.5");
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Procedural sound: an engine note per human player, tyre squeal and knocks.
//!
//! The retail samples are on disk 1, which is not in this dump, so everything
//! is synthesised. Parameters are passed to the audio thread through atomics.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
pub const VOICES: usize = 2;
#[derive(Default)]
struct VoiceParams {
rpm: AtomicU32,
skid: AtomicU32,
bump: AtomicU32,
active: AtomicU32,
}
#[derive(Default)]
pub struct Shared {
voices: [VoiceParams; VOICES],
master: AtomicU32,
}
fn store(a: &AtomicU32, v: f32) {
a.store(v.to_bits(), Ordering::Relaxed);
}
fn load(a: &AtomicU32) -> f32 {
f32::from_bits(a.load(Ordering::Relaxed))
}
pub struct Audio {
shared: Arc<Shared>,
#[cfg(feature = "audio")]
_stream: Option<cpal::Stream>,
}
impl Audio {
pub fn new(enabled: bool) -> Audio {
let shared = Arc::new(Shared::default());
store(&shared.master, 0.5);
#[cfg(feature = "audio")]
{
let stream = if enabled {
backend::open(shared.clone())
} else {
None
};
Audio {
shared,
_stream: stream,
}
}
#[cfg(not(feature = "audio"))]
{
let _ = enabled;
Audio { shared }
}
}
pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32, bump: f32) {
let v = &self.shared.voices[i];
store(&v.active, if active { 1.0 } else { 0.0 });
store(&v.rpm, rpm);
store(&v.skid, skid);
// Knocks are latched until the audio thread consumes them.
if bump > load(&v.bump) {
store(&v.bump, bump);
}
}
pub fn silence(&self) {
for i in 0..VOICES {
self.set_voice(i, false, 0.0, 0.0, 0.0);
}
}
}
/// Synth state for one voice, run on the audio thread.
#[derive(Default)]
struct Synth {
phase: f32,
sub_phase: f32,
rpm: f32,
skid: f32,
knock: f32,
noise: u32,
lp: f32,
skid_lp: f32,
level: f32,
}
impl Synth {
fn sample(&mut self, p: &VoiceParams, rate: f32) -> f32 {
let target_level = load(&p.active);
self.level += (target_level - self.level) * 0.0008;
self.rpm += (load(&p.rpm) - self.rpm) * 0.002;
self.skid += (load(&p.skid) - self.skid) * 0.004;
let b = load(&p.bump);
if b > 0.0 {
self.knock = self.knock.max(b);
store(&p.bump, 0.0);
}
self.noise ^= self.noise << 13;
self.noise ^= self.noise >> 17;
self.noise ^= self.noise << 5;
let white = (self.noise as f32 / u32::MAX as f32) * 2.0 - 1.0;
// Engine: a buzzy pulse with a sub-octave, low-passed.
let freq = 38.0 + self.rpm * 150.0;
self.phase = (self.phase + freq / rate).fract();
self.sub_phase = (self.sub_phase + freq * 0.5 / rate).fract();
let pulse = if self.phase < 0.3 { 1.0 } else { -0.45 };
let sub = if self.sub_phase < 0.5 { 0.5 } else { -0.5 };
let raw = pulse + sub + white * 0.15;
self.lp += (raw - self.lp) * (0.06 + self.rpm * 0.12);
let engine = self.lp * 0.32;
// Tyres: band-limited noise.
self.skid_lp += (white - self.skid_lp) * 0.35;
let squeal = (white - self.skid_lp) * self.skid.min(1.0) * 0.22;
let knock = white * self.knock * 0.6;
self.knock *= 0.9985;
(engine + squeal + knock) * self.level
}
}
#[cfg(feature = "audio")]
mod backend {
use super::{load, Shared, Synth, VOICES};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
use std::sync::Arc;
pub fn open(shared: Arc<Shared>) -> Option<cpal::Stream> {
let host = cpal::default_host();
let device = host.default_output_device()?;
let config = device.default_output_config().ok()?;
let fmt = config.sample_format();
let config: cpal::StreamConfig = config.into();
let stream = match fmt {
cpal::SampleFormat::F32 => build::<f32>(&device, &config, shared),
cpal::SampleFormat::I16 => build::<i16>(&device, &config, shared),
cpal::SampleFormat::U16 => build::<u16>(&device, &config, shared),
_ => None,
}?;
stream.play().ok()?;
Some(stream)
}
fn build<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
shared: Arc<Shared>,
) -> Option<cpal::Stream>
where
T: SizedSample + FromSample<f32>,
{
let rate = config.sample_rate.0 as f32;
let channels = config.channels as usize;
let mut synths: [Synth; VOICES] = Default::default();
for (i, s) in synths.iter_mut().enumerate() {
s.noise = 0x1234_5678 ^ (i as u32 * 0x9e37_79b9);
}
device
.build_output_stream(
config,
move |data: &mut [T], _| {
let master = load(&shared.master);
for frame in data.chunks_mut(channels) {
let mut l = 0.0;
let mut r = 0.0;
for (i, s) in synths.iter_mut().enumerate() {
let v = s.sample(&shared.voices[i], rate);
// Player one leans left, player two right.
let pan = if i == 0 { 0.35 } else { 0.65 };
l += v * (1.0 - pan) * 2.0;
r += v * pan * 2.0;
}
let (l, r) = ((l * master).clamp(-1.0, 1.0), (r * master).clamp(-1.0, 1.0));
for (c, out) in frame.iter_mut().enumerate() {
let v = if channels == 1 {
(l + r) * 0.5
} else if c % 2 == 0 {
l
} else {
r
};
*out = T::from_sample(v);
}
}
},
|err| eprintln!("audio stream error: {err}"),
None,
)
.ok()
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Locates and decodes the original game data.
//!
//! Data can be supplied as the original ADF images, as the LHA archives taken
//! off them, or as an already-extracted directory tree containing `track/` and
//! `vehicles/`. Nothing from the original game is embedded in this program.
pub mod palette;
pub mod track;
pub mod vehicle;
use crate::formats::{adf::Adf, crm, lha};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub use track::{Track, TrackFile};
pub use vehicle::Vehicle;
/// All game files found, keyed by `dir/name` in lower case
/// (e.g. `track/track1`, `vehicles/f1.aga`).
#[derive(Default)]
pub struct FileSet {
pub files: BTreeMap<String, Vec<u8>>,
pub warnings: Vec<String>,
}
impl FileSet {
pub fn scan(root: &Path) -> FileSet {
let mut fs_ = FileSet::default();
fs_.scan_dir(root, 0);
fs_
}
fn scan_dir(&mut self, dir: &Path, depth: usize) {
let Ok(rd) = fs::read_dir(dir) else {
self.warnings
.push(format!("cannot read directory {}", dir.display()));
return;
};
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for path in entries {
if path.is_dir() {
if depth < 4 {
self.scan_dir(&path, depth + 1);
}
} else if let Ok(data) = fs::read(&path) {
let name = path.to_string_lossy().to_string();
self.add(&name, data);
}
}
}
/// Adds a file, unpacking ADF images and LHA archives recursively.
pub fn add(&mut self, path: &str, data: Vec<u8>) {
let lower = path.to_lowercase();
if lower.ends_with(".adf") {
match Adf::open(data).and_then(|a| a.files()) {
Ok(files) => {
for f in files {
self.add(&format!("{path}/{}", f.path), f.data);
}
}
Err(e) => self.warnings.push(format!("{path}: {e}")),
}
} else if lower.ends_with(".lha") || lower.ends_with(".lzh") {
match lha::parse(&data) {
Ok(entries) => {
for e in entries {
match e.data {
Some(d) => self.add(&e.path, d),
None => self.warnings.push(format!(
"{path}: {} uses unsupported method {}",
e.path, e.method
)),
}
}
}
Err(e) => self.warnings.push(format!("{path}: {e}")),
}
} else if let Some(key) = logical_key(path) {
self.files.insert(key, data);
}
}
pub fn get(&self, key: &str) -> Option<&[u8]> {
self.files.get(key).map(|v| v.as_slice())
}
}
/// Reduces a path to `dir/name` if `dir` is one the game uses.
fn logical_key(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
if parts.len() < 2 {
return None;
}
let dir = parts[parts.len() - 2].to_lowercase();
let name = parts[parts.len() - 1].to_lowercase();
matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}"))
}
/// Unpacks CrunchMania data, passing plain data through untouched.
pub fn unpack(d: &[u8]) -> Result<Vec<u8>, crm::CrmError> {
if crm::is_crunched(d) {
crm::decrunch(d)
} else {
Ok(d.to_vec())
}
}
pub struct GameData {
pub track_files: Vec<TrackFile>,
pub vehicles: Vec<Vehicle>,
pub files: FileSet,
pub warnings: Vec<String>,
}
impl GameData {
pub fn load(root: &Path) -> Result<GameData, String> {
let files = FileSet::scan(root);
let mut warnings = files.warnings.clone();
let mut track_files = Vec::new();
for (key, data) in &files.files {
if let Some(name) = key.strip_prefix("track/") {
match TrackFile::parse(name, data) {
Ok(t) => track_files.push(t),
Err(e) => warnings.push(format!("{key}: {e}")),
}
}
}
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}")),
}
}
}
vehicles.sort_by_key(|v| vehicle::display_order(&v.id));
if track_files.is_empty() || vehicles.is_empty() {
return Err(format!(
"no usable game data under {} (found {} track files, {} vehicles).\n\
Point --data at a folder containing the Super Skidmarks ADF images.{}",
root.display(),
track_files.len(),
vehicles.len(),
if warnings.is_empty() {
String::new()
} else {
format!("\n {}", warnings.join("\n "))
}
));
}
Ok(GameData {
track_files,
vehicles,
files,
warnings,
})
}
/// 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));
}
}
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) {
t.palette = p;
}
Ok(t)
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Colour palettes.
//!
//! The track and vehicle files carry no colour tables; the retail game sets
//! them from its main executable, which lives on disk 1. Until that disk is
//! available we use a reconstructed palette. Indices on the retail tracks are
//! 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:
//! palettes/track1-3.pal one track
//! palettes/track1.pal all six tracks in TRACK1
//! palettes/default.pal every track
use super::FileSet;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
pub const fn to_u32(self) -> u32 {
((self.0 as u32) << 16) | ((self.1 as u32) << 8) | self.2 as u32
}
pub fn scale(self, f: f32) -> Rgb {
let s = |c: u8| ((c as f32 * f).round().clamp(0.0, 255.0)) as u8;
Rgb(s(self.0), s(self.1), s(self.2))
}
pub fn mix(self, o: Rgb, t: f32) -> Rgb {
let m = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
Rgb(m(self.0, o.0), m(self.1, o.1), m(self.2, o.2))
}
}
pub const TRACK_DEFAULT: [Rgb; 16] = [
// scenery: deep shadow through sunlit grass
Rgb(0x0c, 0x1a, 0x0e),
Rgb(0x18, 0x2c, 0x14),
Rgb(0x24, 0x40, 0x1a),
Rgb(0x34, 0x56, 0x20),
Rgb(0x48, 0x6c, 0x28),
Rgb(0x60, 0x84, 0x34),
Rgb(0x84, 0x9c, 0x4c),
Rgb(0xb0, 0xb8, 0x7c),
// driving surface: tarmac, kerbs and markings
Rgb(0x3a, 0x36, 0x34),
Rgb(0x50, 0x4c, 0x48),
Rgb(0x66, 0x62, 0x5c),
Rgb(0x7c, 0x78, 0x70),
Rgb(0x94, 0x90, 0x86),
Rgb(0xae, 0xaa, 0x9e),
Rgb(0xcc, 0xc8, 0xbc),
Rgb(0xf0, 0xee, 0xe4),
];
/// Per-player body colours for the three-colour vehicle sprites
/// (index 1 = tyres and shadow, 2 = trim, 3 = bodywork).
pub const TEAM_COLOURS: [Rgb; 8] = [
Rgb(0xe0, 0x30, 0x28),
Rgb(0x30, 0x60, 0xe8),
Rgb(0xf0, 0xc8, 0x20),
Rgb(0x30, 0xb0, 0x48),
Rgb(0xe0, 0x60, 0xd0),
Rgb(0x20, 0xc0, 0xc8),
Rgb(0xf0, 0x80, 0x20),
Rgb(0xe8, 0xe8, 0xe8),
];
pub fn car_palette(team: usize) -> [u32; 4] {
let c = TEAM_COLOURS[team % TEAM_COLOURS.len()];
[
0,
Rgb(0x10, 0x10, 0x14).to_u32(),
c.scale(0.7).to_u32(),
c.mix(Rgb(0xff, 0xff, 0xff), 0.35).to_u32(),
]
}
pub fn parse(text: &str) -> Option<[Rgb; 16]> {
let mut out = [Rgb(0, 0, 0); 16];
let mut n = 0;
for line in text.lines() {
let line = line
.split('#')
.next()
.unwrap_or("")
.trim()
.trim_start_matches("0x");
if line.is_empty() {
continue;
}
if n == 16 || line.len() != 6 {
return None;
}
let v = u32::from_str_radix(line, 16).ok()?;
out[n] = Rgb((v >> 16) as u8, (v >> 8) as u8, v as u8);
n += 1;
}
(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();
[
format!("palettes/{base}-{}.pal", section + 1),
format!("palettes/{base}.pal"),
"palettes/default.pal".into(),
]
.iter()
.find_map(|k| {
files
.get(k)
.and_then(|d| parse(&String::from_utf8_lossy(d)))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_palette_file() {
let mut text = String::from("# test\n");
for i in 0..16 {
text.push_str(&format!("{:02x}{:02x}{:02x}\n", i, i * 2, i * 3));
}
let p = parse(&text).unwrap();
assert_eq!(p[15], Rgb(15, 30, 45));
assert!(parse("ffffff\n").is_none());
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Track files (`track/TRACKn`).
//!
//! File layout:
//! 0x00 7 x BE32 end offsets. Section 0 runs from 0x1c to the first offset
//! and holds the selector thumbnails; sections 1..6 are tracks.
//!
//! Each track section (281240 bytes once decrunched):
//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels
//! 0x2000 8192 bytes, unused (always zero on the retail disks)
//! 0x4000 BE16 gate count, start x, start y (screen pixels), flags
//! 0x4008 gates: x1 y1 x2 y2 BE16, world coordinates in 1/32 units
//! 0x4A98 four 1024x512 bitplanes (65536 bytes each), giving 16 colours
//!
//! The world is an isometric ground plane rotated 45 degrees to the picture:
//! screen x = wx - wy + 512, screen y = (wx + wy) * 31/64 - 252. The constants
//! were fitted so that 96% of gate samples over all 24 tracks land on road
//! pixels (against 40-70% for a plain 2:1 squash). The stored start point
//! only lands on the road on 14 of the 24 tracks, so the grid is placed from
//! the gates instead.
use super::palette::{self, Rgb};
use super::unpack;
use crate::formats::{be16, be32};
pub const W: usize = 1024;
pub const H: usize = 512;
pub const HM_W: usize = 64;
pub const HM_H: usize = 64;
pub const CELL_W: f32 = (W / HM_W) as f32;
pub const CELL_H: f32 = (H / HM_H) as f32;
const SECTION_SIZE: usize = 0x44a98;
const GATES_AT: usize = 0x4000;
const PLANES_AT: usize = 0x4a98;
const PLANE_SIZE: usize = W * H / 8;
const SECTIONS: usize = 7;
const ISO_X0: f32 = 512.0;
const ISO_Y_SCALE: f32 = 31.0 / 64.0;
const ISO_Y0: f32 = -252.0;
pub fn to_screen(wx: f32, wy: f32) -> (f32, f32) {
(wx - wy + ISO_X0, (wx + wy) * ISO_Y_SCALE + ISO_Y0)
}
pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool {
sx >= margin && sy >= margin && sx < W as f32 - margin && sy < H as f32 - margin
}
pub struct TrackFile {
pub name: String,
pub number: u32,
/// Packed track sections (index 0 = first playable track).
pub sections: Vec<Vec<u8>>,
}
#[derive(Clone, Copy, Debug)]
pub struct Gate {
pub a: (f32, f32),
pub b: (f32, f32),
}
impl Gate {
pub fn mid(&self) -> (f32, f32) {
((self.a.0 + self.b.0) * 0.5, (self.a.1 + self.b.1) * 0.5)
}
}
pub struct Track {
pub name: String,
/// Palette indices, W x H.
pub pixels: Vec<u8>,
pub palette: [Rgb; 16],
/// Height samples in 8.8 fixed point.
pub height: Vec<u16>,
/// Gates in world coordinates.
pub gates: Vec<Gate>,
/// Start position and flags as stored. Kept for reference: the start
/// point does not line up with the road reliably, and the flags' meaning
/// is unknown.
#[allow(dead_code)]
pub start: (f32, f32),
#[allow(dead_code)]
pub flags: u16,
}
impl TrackFile {
pub fn parse(name: &str, d: &[u8]) -> Result<TrackFile, String> {
if d.len() < SECTIONS * 4 {
return Err("file too short".into());
}
let ends: Vec<usize> = (0..SECTIONS).map(|i| be32(d, i * 4) as usize).collect();
let mut start = SECTIONS * 4;
let mut sections = Vec::new();
for (i, &end) in ends.iter().enumerate() {
if end < start || end > d.len() {
return Err(format!("section {i} has a bad offset"));
}
if i > 0 {
sections.push(d[start..end].to_vec());
}
start = end;
}
let number = name
.trim_start_matches(|c: char| !c.is_ascii_digit())
.parse()
.unwrap_or(u32::MAX);
Ok(TrackFile {
name: name.to_string(),
number,
sections,
})
}
pub fn track_name(&self, section: usize) -> String {
format!("{}-{}", self.name.to_uppercase(), section + 1)
}
pub fn decode(&self, section: usize) -> Result<Track, String> {
let raw = unpack(&self.sections[section]).map_err(|e| e.to_string())?;
Track::from_section(self.track_name(section), &raw)
}
}
impl Track {
pub fn from_section(name: String, d: &[u8]) -> Result<Track, String> {
if d.len() < SECTION_SIZE {
return Err(format!(
"track section is {} bytes, expected {SECTION_SIZE}",
d.len()
));
}
let height = (0..HM_W * HM_H).map(|i| be16(d, i * 2)).collect();
let count = be16(d, GATES_AT) as usize;
let start = (be16(d, GATES_AT + 2) as f32, be16(d, GATES_AT + 4) as f32);
let flags = be16(d, GATES_AT + 6);
if count == 0 || GATES_AT + 8 + count * 8 > PLANES_AT {
return Err(format!("implausible gate count {count}"));
}
let gates = (0..count)
.map(|i| {
let o = GATES_AT + 8 + i * 8;
let w = |k: usize| be16(d, o + k * 2) as f32 / 32.0;
Gate {
a: (w(0), w(1)),
b: (w(2), w(3)),
}
})
.collect();
let planes = &d[PLANES_AT..PLANES_AT + 4 * PLANE_SIZE];
let mut pixels = vec![0u8; W * H];
for (p, plane) in planes.chunks_exact(PLANE_SIZE).enumerate() {
for (byte_i, &byte) in plane.iter().enumerate() {
if byte == 0 {
continue;
}
let base = byte_i * 8;
for bit in 0..8 {
if byte & (0x80 >> bit) != 0 {
pixels[base + bit] |= 1 << p;
}
}
}
}
Ok(Track {
name,
pixels,
palette: palette::TRACK_DEFAULT,
height,
gates,
start,
flags,
})
}
/// Ground height (in height units) at a screen position, bilinear.
pub fn height_at(&self, sx: f32, sy: f32) -> f32 {
let fx = (sx / CELL_W - 0.5).clamp(0.0, (HM_W - 1) as f32);
let fy = (sy / CELL_H - 0.5).clamp(0.0, (HM_H - 1) as f32);
let (x0, y0) = (fx as usize, fy as usize);
let (x1, y1) = ((x0 + 1).min(HM_W - 1), (y0 + 1).min(HM_H - 1));
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let h = |x: usize, y: usize| self.height[y * HM_W + x] as f32 / 256.0;
let top = h(x0, y0) * (1.0 - tx) + h(x1, y0) * tx;
let bot = h(x0, y1) * (1.0 - tx) + h(x1, y1) * tx;
top * (1.0 - ty) + bot * ty
}
/// Height at a world position.
pub fn height_world(&self, wx: f32, wy: f32) -> f32 {
let (sx, sy) = to_screen(wx, wy);
self.height_at(sx, sy)
}
pub fn pixel(&self, sx: f32, sy: f32) -> u8 {
let x = (sx as isize).clamp(0, W as isize - 1) as usize;
let y = (sy as isize).clamp(0, H as isize - 1) as usize;
self.pixels[y * W + x]
}
/// The upper half of the palette is used for the driving surface: the
/// fourth bitplane traces the road on every retail track.
pub fn on_road(&self, sx: f32, sy: f32) -> bool {
self.pixel(sx, sy) & 8 != 0
}
pub fn on_road_world(&self, wx: f32, wy: f32) -> bool {
let (sx, sy) = to_screen(wx, wy);
on_screen(sx, sy, 0.0) && self.on_road(sx, sy)
}
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()
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Vehicle sprite banks (`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.
//!
//! Tilt block = roll * 5 + pitch, with 2 meaning level on both axes.
//! Heading frame 12 faces screen-right and frames advance clockwise in
//! 11.25 degree steps.
use super::unpack;
pub const SPR_W: usize = 64;
pub const SPR_H: usize = 32;
pub const HEADINGS: usize = 32;
pub const TILTS: usize = 25;
pub const LEVEL: usize = 12;
const FRAME_BYTES: usize = 512;
const BANK_BYTES: usize = TILTS * HEADINGS * FRAME_BYTES;
pub struct Vehicle {
pub id: String,
pub name: String,
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
pub frames: Vec<u8>,
}
impl Vehicle {
/// Returns `Ok(None)` for banks that are present but blank.
pub fn parse(file: &str, d: &[u8]) -> Result<Option<Vehicle>, String> {
let raw = unpack(d).map_err(|e| e.to_string())?;
if raw.len() < BANK_BYTES {
return Err(format!("{} bytes, expected {BANK_BYTES}", raw.len()));
}
if raw.iter().all(|&b| b == 0) {
return Ok(None);
}
let mut frames = vec![0u8; TILTS * HEADINGS * SPR_W * SPR_H];
for (f, src) in raw[..BANK_BYTES].chunks_exact(FRAME_BYTES).enumerate() {
let dst = &mut frames[f * SPR_W * SPR_H..(f + 1) * SPR_W * SPR_H];
for y in 0..SPR_H {
let line = &src[y * 16..y * 16 + 16];
for x in 0..SPR_W {
let mask = 0x80 >> (x & 7);
let p0 = (line[x >> 3] & mask != 0) as u8;
let p1 = (line[8 + (x >> 3)] & mask != 0) as u8;
dst[y * SPR_W + x] = p0 | (p1 << 1);
}
}
}
let id = file.trim_end_matches(".aga").to_lowercase();
Ok(Some(Vehicle {
name: display_name(&id),
id,
frames,
}))
}
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]
}
}
/// Heading frame for a world-space angle (radians, 0 = +wx). World +wx runs
/// down-right on screen, so screen-right (frame 12) is world angle -45 degrees.
pub fn heading_frame(angle: f32) -> usize {
let steps = (angle + std::f32::consts::FRAC_PI_4) / (std::f32::consts::TAU / HEADINGS as f32);
(12 + steps.round() as i32).rem_euclid(HEADINGS as i32) as usize
}
pub fn tilt_block(pitch: i32, roll: i32) -> usize {
((roll.clamp(-2, 2) + 2) * 5 + (pitch.clamp(-2, 2) + 2)) as usize
}
fn display_name(id: &str) -> String {
match id {
"vw" => "BEETLE".into(),
"f1" => "FORMULA 1".into(),
other => other.to_uppercase(),
}
}
pub fn display_order(id: &str) -> usize {
const ORDER: [&str; 8] = [
"mini", "vw", "porsche", "f1", "midget", "truck", "police", "cow",
];
ORDER.iter().position(|&o| o == id).unwrap_or(ORDER.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_frames() {
use std::f32::consts::{FRAC_PI_4, PI};
assert_eq!(heading_frame(-FRAC_PI_4), 12);
assert_eq!(heading_frame(FRAC_PI_4), 20);
assert_eq!(heading_frame(PI - FRAC_PI_4), 28);
assert_eq!(heading_frame(-PI + FRAC_PI_4), 4);
}
#[test]
fn level_block() {
assert_eq!(tilt_block(0, 0), LEVEL);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Amiga Disk File reader supporting OFS and FFS volumes (DD and HD images).
use super::{be32, latin1};
use std::collections::HashSet;
use std::fmt;
const BSIZE: usize = 512;
const HASH_SIZE: usize = 72;
const T_HEADER: u32 = 2;
const T_LIST: u32 = 16;
const ST_ROOT: i32 = 1;
const ST_USERDIR: i32 = 2;
const ST_FILE: i32 = -3;
#[derive(Debug)]
pub enum AdfError {
NotDos,
BadBlock(u32),
Corrupt(&'static str),
}
impl fmt::Display for AdfError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AdfError::NotDos => write!(f, "not an AmigaDOS disk image"),
AdfError::BadBlock(b) => write!(f, "block {b} is outside the image"),
AdfError::Corrupt(why) => write!(f, "corrupt filesystem: {why}"),
}
}
}
impl std::error::Error for AdfError {}
pub struct AdfFile {
/// Path inside the volume, `/`-separated.
pub path: String,
pub data: Vec<u8>,
}
pub struct Adf {
data: Vec<u8>,
ffs: bool,
root: u32,
pub volume: String,
}
impl Adf {
pub fn open(data: Vec<u8>) -> Result<Self, AdfError> {
if data.len() < 4 * BSIZE || &data[0..3] != b"DOS" {
return Err(AdfError::NotDos);
}
let ffs = data[3] & 1 != 0;
let root = (data.len() / BSIZE / 2) as u32;
let mut adf = Adf {
data,
ffs,
root,
volume: String::new(),
};
let rb = adf.block(root)?;
if be32(rb, 0) != T_HEADER || be32(rb, BSIZE - 4) as i32 != ST_ROOT {
return Err(AdfError::Corrupt("root block has the wrong type"));
}
adf.volume = name_of(rb);
Ok(adf)
}
fn block(&self, n: u32) -> Result<&[u8], AdfError> {
let start = n as usize * BSIZE;
self.data
.get(start..start + BSIZE)
.ok_or(AdfError::BadBlock(n))
}
/// Every regular file on the volume, depth first.
pub fn files(&self) -> Result<Vec<AdfFile>, AdfError> {
let mut out = Vec::new();
let mut seen = HashSet::new();
self.walk_dir(self.root, "", &mut out, &mut seen, 0)?;
Ok(out)
}
fn walk_dir(
&self,
dir: u32,
prefix: &str,
out: &mut Vec<AdfFile>,
seen: &mut HashSet<u32>,
depth: usize,
) -> Result<(), AdfError> {
if depth > 32 {
return Err(AdfError::Corrupt("directory nesting too deep"));
}
let db = self.block(dir)?;
for slot in 0..HASH_SIZE {
let mut entry = be32(db, 24 + slot * 4);
while entry != 0 {
if !seen.insert(entry) {
return Err(AdfError::Corrupt("loop in hash chain"));
}
let hb = self.block(entry)?;
if be32(hb, 0) != T_HEADER {
return Err(AdfError::Corrupt("hash chain points at a non-header block"));
}
let name = name_of(hb);
let path = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
match be32(hb, BSIZE - 4) as i32 {
ST_USERDIR => self.walk_dir(entry, &path, out, seen, depth + 1)?,
ST_FILE => out.push(AdfFile {
data: self.read_file(entry)?,
path,
}),
_ => {} // links and other oddities are not used by the game disks
}
entry = be32(hb, BSIZE - 16);
}
}
Ok(())
}
fn read_file(&self, header: u32) -> Result<Vec<u8>, AdfError> {
let hb = self.block(header)?;
let size = be32(hb, 324) as usize;
if size > self.data.len() {
return Err(AdfError::Corrupt("file larger than the disk"));
}
// Data block pointers are stored in reverse in each header/extension
// block: slot 71 is the first block.
let mut blocks = Vec::new();
let mut cur = header;
let mut hops = 0;
loop {
let b = self.block(cur)?;
if cur != header && be32(b, 0) != T_LIST {
return Err(AdfError::Corrupt("bad file extension block"));
}
let count = (be32(b, 8) as usize).min(HASH_SIZE);
for i in 0..count {
blocks.push(be32(b, 24 + (HASH_SIZE - 1 - i) * 4));
}
cur = be32(b, BSIZE - 8);
hops += 1;
if cur == 0 || hops > self.data.len() / BSIZE {
break;
}
}
let mut data = Vec::with_capacity(size);
for blk in blocks {
if data.len() >= size {
break;
}
let b = self.block(blk)?;
if self.ffs {
data.extend_from_slice(b);
} else {
let n = (be32(b, 12) as usize).min(BSIZE - 24);
data.extend_from_slice(&b[24..24 + n]);
}
}
if data.len() < size {
return Err(AdfError::Corrupt(
"file data shorter than its header claims",
));
}
data.truncate(size);
Ok(data)
}
}
fn name_of(block: &[u8]) -> String {
let len = (block[BSIZE - 80] as usize).min(30);
latin1(&block[BSIZE - 79..BSIZE - 79 + len])
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a minimal one-file OFS DD image to exercise the reader.
fn tiny_ofs(contents: &[u8]) -> Vec<u8> {
let mut img = vec![0u8; 880 * 2 * BSIZE];
img[0..4].copy_from_slice(b"DOS\0");
let put = |img: &mut Vec<u8>, blk: usize, off: usize, v: u32| {
img[blk * BSIZE + off..blk * BSIZE + off + 4].copy_from_slice(&v.to_be_bytes());
};
let root = 880;
put(&mut img, root, 0, T_HEADER);
put(&mut img, root, BSIZE - 4, ST_ROOT as u32);
let fh = 882;
// Hash slot is irrelevant to the reader; use slot 0.
put(&mut img, root, 24, fh as u32);
put(&mut img, fh, 0, T_HEADER);
put(&mut img, fh, BSIZE - 4, ST_FILE as u32);
let name = b"hello";
img[fh * BSIZE + BSIZE - 80] = name.len() as u8;
img[fh * BSIZE + BSIZE - 79..fh * BSIZE + BSIZE - 79 + name.len()].copy_from_slice(name);
put(&mut img, fh, 324, contents.len() as u32);
let chunks: Vec<&[u8]> = contents.chunks(BSIZE - 24).collect();
put(&mut img, fh, 8, chunks.len() as u32);
for (i, chunk) in chunks.iter().enumerate() {
let db = 883 + i;
put(&mut img, fh, 24 + (HASH_SIZE - 1 - i) * 4, db as u32);
put(&mut img, db, 12, chunk.len() as u32);
img[db * BSIZE + 24..db * BSIZE + 24 + chunk.len()].copy_from_slice(chunk);
}
img
}
#[test]
fn reads_ofs_file() {
let contents: Vec<u8> = (0..1500u32).map(|i| (i * 7) as u8).collect();
let adf = Adf::open(tiny_ofs(&contents)).unwrap();
let files = adf.files().unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "hello");
assert_eq!(files[0].data, contents);
}
#[test]
fn rejects_non_dos() {
assert!(matches!(Adf::open(vec![0; 901120]), Err(AdfError::NotDos)));
}
}
+289
View File
@@ -0,0 +1,289 @@
//! CrunchMania decruncher (`CrM!`, `CrM2` and the delta-sampled `Crm!`/`Crm2`).
//!
//! Header: 4-byte id, 2 unused bytes, BE32 unpacked size, BE32 packed size.
//! The packed stream is read backwards, byte by byte, LSB first, and the
//! output is also written from the end towards the start. The final six
//! bytes of the packed data seed the bit buffer.
//!
//! The bitstream layout follows the description in Teemu Suutari's
//! "ancient" decompression library (BSD-2-Clause).
use super::{be16, be32};
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum CrmError {
NotCrunched,
Truncated,
BadHuffman,
BadReference,
SizeMismatch,
}
impl fmt::Display for CrmError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = match self {
CrmError::NotCrunched => "not CrunchMania data",
CrmError::Truncated => "packed stream ended early",
CrmError::BadHuffman => "invalid Huffman table",
CrmError::BadReference => "back-reference outside the output",
CrmError::SizeMismatch => "output size does not match the header",
};
f.write_str(s)
}
}
impl std::error::Error for CrmError {}
pub fn is_crunched(d: &[u8]) -> bool {
d.len() >= 20 && matches!(&d[0..4], b"CrM!" | b"CrM2" | b"Crm!" | b"Crm2")
}
struct BackBits<'a> {
d: &'a [u8],
start: usize,
pos: usize,
buf: u32,
len: u32,
}
impl BackBits<'_> {
#[inline]
fn bits(&mut self, mut count: u32) -> Result<u32, CrmError> {
let mut ret = 0u32;
let mut shift = 0u32;
while count > 0 {
if self.len == 0 {
if self.pos <= self.start {
return Err(CrmError::Truncated);
}
self.pos -= 1;
self.buf = self.d[self.pos] as u32;
self.len = 8;
}
let take = count.min(self.len);
ret |= (self.buf & ((1 << take) - 1)) << shift;
self.buf >>= take;
self.len -= take;
count -= take;
shift += take;
}
Ok(ret)
}
#[inline]
fn bit(&mut self) -> Result<u32, CrmError> {
self.bits(1)
}
}
/// Canonical-style decoder: codes of each depth are consecutive.
struct Huffman {
max_depth: u32,
first: [u32; 16],
count: [u32; 16],
offset: [usize; 16],
values: Vec<u32>,
}
impl Huffman {
fn read(br: &mut BackBits, code_len: u32) -> Result<Self, CrmError> {
let max_depth = br.bits(4)?;
if max_depth == 0 {
return Err(CrmError::BadHuffman);
}
let mut lengths = [0u32; 16];
for i in 0..max_depth {
lengths[i as usize] = br.bits((i + 1).min(code_len))?;
}
let mut h = Huffman {
max_depth,
first: [0; 16],
count: [0; 16],
offset: [0; 16],
values: Vec::new(),
};
let mut code = 0u32;
for depth in 1..=max_depth {
let di = depth as usize;
h.first[di] = code >> (max_depth - depth);
h.count[di] = lengths[di - 1];
h.offset[di] = h.values.len();
for _ in 0..lengths[di - 1] {
h.values.push(br.bits(code_len)?);
code += 1 << (max_depth - depth);
}
if code > (1 << max_depth) {
return Err(CrmError::BadHuffman);
}
}
Ok(h)
}
#[inline]
fn decode(&self, br: &mut BackBits) -> Result<u32, CrmError> {
let mut code = 0u32;
for depth in 1..=self.max_depth as usize {
code = (code << 1) | br.bit()?;
let rel = code.wrapping_sub(self.first[depth]);
if rel < self.count[depth] {
return Ok(self.values[self.offset[depth] + rel as usize]);
}
}
Err(CrmError::BadHuffman)
}
}
pub fn decrunch(d: &[u8]) -> Result<Vec<u8>, CrmError> {
if !is_crunched(d) {
return Err(CrmError::NotCrunched);
}
let lzh = d[3] == b'2';
let sampled = d[2] == b'm';
let raw = be32(d, 6) as usize;
let packed = be32(d, 10) as usize;
if packed < 6 || packed + 14 > d.len() || raw == 0 || raw > 64 << 20 {
return Err(CrmError::Truncated);
}
let tail = packed + 14 - 6;
let shift = be16(d, tail + 4) as u32;
if shift > 16 {
return Err(CrmError::Truncated);
}
let mut br = BackBits {
d,
start: 14,
pos: tail,
buf: be32(d, tail) >> (16 - shift),
len: shift + 16,
};
let mut out = vec![0u8; raw];
let mut o = raw;
macro_rules! put {
($b:expr) => {{
if o == 0 {
return Err(CrmError::SizeMismatch);
}
o -= 1;
out[o] = $b;
}};
}
macro_rules! copy {
($dist:expr, $count:expr) => {{
let (dist, count) = ($dist as usize, $count as usize);
if dist == 0 || count > o || o + dist > raw {
return Err(CrmError::BadReference);
}
for _ in 0..count {
o -= 1;
out[o] = out[o + dist];
}
}};
}
if lzh {
loop {
let lengths = Huffman::read(&mut br, 9)?;
let distances = Huffman::read(&mut br, 4)?;
let items = br.bits(16)? + 1;
for _ in 0..items {
let sym = lengths.decode(&mut br)?;
if sym & 0x100 != 0 {
put!(sym as u8);
} else {
let count = sym + 3;
let dbits = distances.decode(&mut br)?;
let dist = if dbits == 0 {
br.bits(1)? + 1
} else {
(br.bits(dbits)? | (1 << dbits)) + 1
};
copy!(dist, count);
}
}
if br.bit()? == 0 {
break;
}
}
} else {
const LEN_BASE: [u32; 4] = [0, 2, 6, 22];
const LEN_BITS: [u32; 4] = [1, 2, 4, 8];
const DIST_BASE: [u32; 3] = [0, 32, 544];
const DIST_BITS: [u32; 3] = [5, 9, 14];
while o > 0 {
if br.bit()? != 0 {
put!(br.bits(8)? as u8);
continue;
}
// Length class: 0, 10, 110, 111
let li = if br.bit()? == 0 {
0
} else if br.bit()? == 0 {
1
} else if br.bit()? == 0 {
2
} else {
3
};
let mut count = LEN_BASE[li] + br.bits(LEN_BITS[li])? + 2;
if count == 23 {
// Literal run
let run = if br.bit()? != 0 {
br.bits(5)? + 15
} else {
br.bits(14)? + 15
};
for _ in 0..run {
put!(br.bits(8)? as u8);
}
continue;
}
if count > 23 {
count -= 1;
}
// Distance class: 0 -> 1, 10 -> 0, 11 -> 2
let di = if br.bit()? == 0 {
1
} else if br.bit()? == 0 {
0
} else {
2
};
let dist = DIST_BASE[di] + br.bits(DIST_BITS[di])?;
copy!(dist, count);
}
}
if o != 0 {
return Err(CrmError::SizeMismatch);
}
if sampled {
let mut acc = 0u8;
for b in out.iter_mut() {
acc = acc.wrapping_add(*b);
*b = acc;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_plain_data() {
assert_eq!(decrunch(&[0u8; 64]), Err(CrmError::NotCrunched));
}
#[test]
fn rejects_truncated_header() {
let mut d = b"CrM2\0\0".to_vec();
d.extend_from_slice(&100u32.to_be_bytes());
d.extend_from_slice(&1000u32.to_be_bytes());
d.extend_from_slice(&[0; 16]);
assert_eq!(decrunch(&d), Err(CrmError::Truncated));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! LHA/LZH archive reader (header levels 0, 1 and 2).
//!
//! The game disks only use the `-lh0-` (stored) method because every file is
//! already CrunchMania-packed, so compressed methods are reported rather than
//! decoded.
use super::{latin1, le16, le32};
use std::fmt;
#[derive(Debug)]
pub enum LhaError {
Truncated(usize),
BadHeader(usize),
}
impl fmt::Display for LhaError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LhaError::Truncated(at) => write!(f, "archive truncated at byte {at}"),
LhaError::BadHeader(at) => write!(f, "unrecognised LHA header at byte {at}"),
}
}
}
impl std::error::Error for LhaError {}
pub struct LhaEntry {
/// Path inside the archive, `/`-separated.
pub path: String,
pub method: String,
/// File contents for stored entries, `None` for methods we do not decode.
pub data: Option<Vec<u8>>,
}
pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
let mut out = Vec::new();
let mut p = 0usize;
while p < d.len() && d[p] != 0 {
if p + 22 > d.len() {
return Err(LhaError::Truncated(p));
}
let method = &d[p + 2..p + 7];
if method[0] != b'-' || method[4] != b'-' {
return Err(LhaError::BadHeader(p));
}
let method = latin1(method);
let mut packed = le32(d, p + 7) as usize;
let level = d[p + 20];
let mut name = String::new();
let mut dir = String::new();
let data_start;
match level {
0 | 1 => {
let header_len = d[p] as usize + 2;
let name_len = d[p + 21] as usize;
if p + 22 + name_len > d.len() || p + header_len > d.len() {
return Err(LhaError::Truncated(p));
}
name = latin1(&d[p + 22..p + 22 + name_len]);
let mut q = p + header_len;
if level == 1 {
// Extended headers follow the base header and are counted
// in the packed size.
let mut next = le16(d, q - 2) as usize;
while next != 0 {
if q + next > d.len() || next < 3 {
return Err(LhaError::Truncated(q));
}
read_ext(&d[q..q + next - 2], &mut name, &mut dir);
packed = packed.checked_sub(next).ok_or(LhaError::BadHeader(q))?;
q += next;
next = le16(d, q - 2) as usize;
}
}
data_start = q;
}
2 => {
let header_len = le16(d, p) as usize;
if p + header_len > d.len() || header_len < 26 {
return Err(LhaError::Truncated(p));
}
let mut q = p + 26;
let mut next = le16(d, p + 24) as usize;
while next != 0 {
if q + next > p + header_len || next < 3 {
return Err(LhaError::BadHeader(q));
}
read_ext(&d[q..q + next - 2], &mut name, &mut dir);
q += next;
next = le16(d, q - 2) as usize;
}
data_start = p + header_len;
}
_ => return Err(LhaError::BadHeader(p)),
}
let data_end = data_start + packed;
if data_end > d.len() {
return Err(LhaError::Truncated(data_start));
}
let path = join_path(&dir, &name);
let data = match method.as_str() {
"-lh0-" | "-lz4-" => Some(d[data_start..data_end].to_vec()),
_ => None,
};
if method != "-lhd-" {
out.push(LhaEntry { path, method, data });
}
p = data_end;
}
Ok(out)
}
fn read_ext(ext: &[u8], name: &mut String, dir: &mut String) {
match ext.first() {
Some(1) => *name = latin1(&ext[1..]),
Some(2) => *dir = latin1(&ext[1..]),
_ => {}
}
}
fn join_path(dir: &str, name: &str) -> String {
let norm = |s: &str| -> String {
s.chars()
.map(|c| if c == '\\' || c == '\u{ff}' { '/' } else { c })
.collect::<String>()
.trim_matches('/')
.to_string()
};
let (dir, name) = (norm(dir), norm(name));
if dir.is_empty() {
name
} else {
format!("{dir}/{name}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn level0_entry(name: &str, body: &[u8]) -> Vec<u8> {
let mut h = vec![0u8; 22];
h[2..7].copy_from_slice(b"-lh0-");
h[7..11].copy_from_slice(&(body.len() as u32).to_le_bytes());
h[11..15].copy_from_slice(&(body.len() as u32).to_le_bytes());
h[20] = 0;
h[21] = name.len() as u8;
h.extend_from_slice(name.as_bytes());
h.extend_from_slice(&[0, 0]); // CRC, unchecked
h[0] = (h.len() - 2) as u8;
h.extend_from_slice(body);
h
}
#[test]
fn parses_stored_level0() {
let mut arc = level0_entry("dir\\a.bin", b"hello");
arc.extend(level0_entry("b", b"world!"));
arc.push(0);
let e = parse(&arc).unwrap();
assert_eq!(e.len(), 2);
assert_eq!(e[0].path, "dir/a.bin");
assert_eq!(e[0].data.as_deref(), Some(&b"hello"[..]));
assert_eq!(e[1].data.as_deref(), Some(&b"world!"[..]));
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Readers for the Amiga container formats the game ships in.
//!
//! The original release is a set of ADF floppy images. Each image holds one
//! LHA archive (stored, no compression), and the files inside those archives
//! are individually packed with CrunchMania.
pub mod adf;
pub mod crm;
pub mod lha;
pub(crate) fn be16(d: &[u8], off: usize) -> u16 {
u16::from_be_bytes([d[off], d[off + 1]])
}
pub(crate) fn be32(d: &[u8], off: usize) -> u32 {
u32::from_be_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]])
}
pub(crate) fn le16(d: &[u8], off: usize) -> u16 {
u16::from_le_bytes([d[off], d[off + 1]])
}
pub(crate) fn le32(d: &[u8], off: usize) -> u32 {
u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]])
}
/// Amiga filenames are ISO-8859-1; map each byte straight to its code point.
pub(crate) fn latin1(bytes: &[u8]) -> String {
bytes.iter().map(|&b| b as char).collect()
}
+184
View File
@@ -0,0 +1,184 @@
//! Computer drivers.
//!
//! Each driver follows a line through the gates (at its own offset across
//! the track) by pure pursuit: aim at a point a speed-dependent distance
//! further along the line. Short look-ahead rays nudge the chosen heading
//! away from the grass, and a driver that stops moving backs out.
use super::car::{angle_diff, Car, Controls};
use crate::data::track::{Gate, Track};
const RAYS: i32 = 7;
const RAY_STEP: f32 = 0.13;
#[derive(Clone, Debug)]
pub struct Ai {
/// Where across each gate this driver aims (0 = end a, 1 = end b).
pub line: f32,
/// Fraction of the car's top speed this driver uses.
pub pace: f32,
/// Index of the path segment the car is on (gate i-1 to gate i).
seg: Option<usize>,
stuck_for: f32,
blocked_for: f32,
reversing: f32,
}
impl Ai {
pub fn new(line: f32, pace: f32) -> Ai {
Ai {
line,
pace,
seg: None,
stuck_for: 0.0,
blocked_for: 0.0,
reversing: 0.0,
}
}
fn point(&self, g: &Gate) -> (f32, f32) {
(
g.a.0 + (g.b.0 - g.a.0) * self.line,
g.a.1 + (g.b.1 - g.a.1) * self.line,
)
}
/// Point `dist` along the driving line from the car's projection onto it.
fn pursue(&mut self, car: &Car, gates: &[Gate], next_gate: usize, dist: f32) -> (f32, f32) {
let n = gates.len();
// Search a few segments ahead of the last known one; never jump back,
// so figure-of-eight crossings don't confuse it. Resync to the lap
// counter if we drift too far from it.
let mut seg = self.seg.unwrap_or(next_gate);
let ahead = (next_gate + n - seg) % n;
if ahead > 3 && ahead < n - 1 {
seg = next_gate;
}
let mut best = (f32::MAX, seg, 0.0f32);
for k in 0..4 {
let i = (seg + k) % n;
let a = self.point(&gates[(i + n - 1) % n]);
let b = self.point(&gates[i]);
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = (dx * dx + dy * dy).max(1e-3);
let t = (((car.x - a.0) * dx + (car.y - a.1) * dy) / len2).clamp(0.0, 1.0);
let d = (a.0 + dx * t - car.x).hypot(a.1 + dy * t - car.y);
if d < best.0 - 1.0 {
best = (d, i, t);
}
}
let (_, mut i, t) = best;
self.seg = Some(i);
let mut a = self.point(&gates[(i + n - 1) % n]);
let mut b = self.point(&gates[i]);
let mut remaining = dist + (b.0 - a.0).hypot(b.1 - a.1) * t;
for _ in 0..n {
let len = (b.0 - a.0).hypot(b.1 - a.1);
if remaining <= len {
let k = remaining / len.max(1e-3);
return (a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k);
}
remaining -= len;
i = (i + 1) % n;
a = b;
b = self.point(&gates[i]);
}
b
}
pub fn drive(
&mut self,
car: &Car,
track: &Track,
next_gate: usize,
dt: f32,
racing: bool,
) -> Controls {
if !racing {
return Controls::default();
}
let gates = &track.gates;
let speed = car.speed();
let look = 34.0 + speed * 0.32;
let target = self.pursue(car, gates, next_gate, look);
let far = self.pursue(car, gates, next_gate, look * 2.2);
let want = (target.1 - car.y).atan2(target.0 - car.x);
if self.reversing > 0.0 {
self.reversing -= dt;
let diff = angle_diff(want, car.heading);
return Controls {
throttle: 0.0,
brake: 1.0,
steer: -diff.signum(),
};
}
if speed < 10.0 {
self.stuck_for += dt;
if self.stuck_for > 1.0 {
self.stuck_for = 0.0;
self.reversing = 0.8;
}
} else {
self.stuck_for = 0.0;
}
// Pick the heading closest to the target that keeps us on the road.
let ray_len = 18.0 + speed * 0.25;
let mut best = (f32::MAX, want);
for k in -RAYS..=RAYS {
let cand = want + k as f32 * RAY_STEP;
let mut grass = 0.0;
for step in 1..=4 {
let d = ray_len * step as f32 / 4.0;
if !track.on_road_world(car.x + cand.cos() * d, car.y + cand.sin() * d) {
grass += 1.0 / step as f32;
}
}
let cost = (k as f32 * RAY_STEP).abs() + grass * 0.9;
if cost < best.0 {
best = (cost, cand);
}
}
let diff = angle_diff(best.1, car.heading);
let steer = (diff * 3.0).clamp(-1.0, 1.0);
// Boxed in (nose in the scenery or against the edge): back out.
if best.0 > 1.6 && speed < 30.0 {
self.blocked_for += dt;
if self.blocked_for > 0.5 {
self.blocked_for = 0.0;
self.reversing = 0.7;
}
} else {
self.blocked_for = 0.0;
}
// Slow for what is coming: compare heading with the far probe.
let bend = angle_diff((far.1 - car.y).atan2(far.0 - car.x), car.heading).abs();
let corner_speed = car.spec.top * self.pace * (1.0 - (bend / 1.3).min(0.75));
let (mut throttle, mut brake) = (1.0, 0.0);
if speed > corner_speed {
throttle = 0.0;
if speed > corner_speed + 25.0 {
brake = 0.7;
}
} else if diff.abs() > 0.8 {
throttle = 0.6;
}
if diff.abs() > 2.3 && speed < 35.0 {
// Facing the wrong way: reverse-turn round.
return Controls {
throttle: 0.0,
brake: 1.0,
steer: -steer,
};
}
Controls {
throttle,
brake,
steer,
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! 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 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 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
/// a car against the outside of a bend.
const SLOPE_MAX: f32 = 45.0;
#[derive(Clone, Copy, Default, Debug)]
pub struct Controls {
pub throttle: f32,
pub brake: f32,
/// -1 = left, 1 = right.
pub steer: f32,
}
#[derive(Clone, Debug)]
pub struct Car {
pub vehicle: usize,
pub spec: Spec,
pub team: usize,
pub x: f32,
pub y: f32,
pub vx: f32,
pub vy: f32,
/// World angle, 0 = +x, increasing clockwise on screen.
pub heading: f32,
pub controls: Controls,
/// Sideways speed this frame, used for skid marks and sound.
pub slip: f32,
pub on_road: bool,
pub tilt: usize,
/// Throttle/rpm proxy for the engine sound, 0..1.
pub rpm: f32,
/// Strength of the last collision, decays each frame.
pub bump: f32,
}
impl Car {
pub fn new(vehicle: usize, id: &str, team: usize, x: f32, y: f32, heading: f32) -> Car {
Car {
vehicle,
spec: spec_for(id),
team,
x,
y,
vx: 0.0,
vy: 0.0,
heading,
controls: Controls::default(),
slip: 0.0,
on_road: true,
tilt: vehicle::LEVEL,
rpm: 0.0,
bump: 0.0,
}
}
pub fn screen_pos(&self) -> (f32, f32) {
track::to_screen(self.x, self.y)
}
pub fn speed(&self) -> f32 {
self.vx.hypot(self.vy)
}
pub fn forward(&self) -> (f32, f32) {
(self.heading.cos(), self.heading.sin())
}
pub fn step(&mut self, track: &Track, dt: f32, top_scale: f32) {
self.on_road = track.on_road_world(self.x, self.y);
let surf = if self.on_road { 1.0 } else { self.spec.offroad };
let c = self.controls;
let (fx, fy) = self.forward();
let (rx, ry) = (-fy, fx);
let mut vf = self.vx * fx + self.vy * fy;
let mut vr = self.vx * rx + self.vy * ry;
let top = self.spec.top * surf * top_scale;
if c.throttle > 0.0 && vf < top {
let headroom = (1.0 - (vf / top).max(0.0).powi(2)).max(0.05);
vf += self.spec.accel * c.throttle * headroom * dt;
}
if c.brake > 0.0 {
if vf > 4.0 {
vf = (vf - self.spec.brake * c.brake * dt).max(0.0);
} else if vf > -top * 0.35 {
vf -= self.spec.accel * 0.6 * c.brake * dt;
}
}
let drag = if self.on_road { 0.35 } else { 1.6 };
vf -= vf * drag * dt;
if c.throttle == 0.0 && c.brake == 0.0 {
vf -= vf.signum() * (30.0 * dt).min(vf.abs());
}
// Sideways friction: grip drops as the slide gets faster, so once a
// car breaks away it keeps sliding.
let grip = self.spec.grip * surf.max(0.6) / (1.0 + vr.abs() / 120.0);
vr *= (-grip * dt).exp();
self.slip = vr.abs();
let steer_auth = (vf.abs() / 45.0).min(1.0) * vf.signum();
let turn = self.spec.turn * (1.0 - (vf.abs() / (top * 2.2)).min(0.35));
self.heading = (self.heading + c.steer * turn * steer_auth * dt).rem_euclid(TAU);
self.vx = fx * vf + rx * vr;
self.vy = fy * vf + ry * vr;
// Hills: the height map is sampled in screen space.
let (gx, gy) = gradient(track, self.x, self.y);
let (mut ax, mut ay) = (-gx * SLOPE_GRAVITY, -gy * SLOPE_GRAVITY);
let a = ax.hypot(ay);
if a > SLOPE_MAX {
ax *= SLOPE_MAX / a;
ay *= SLOPE_MAX / a;
}
self.vx += ax * dt;
self.vy += ay * dt;
let (ox, oy) = (self.x, self.y);
self.x += self.vx * dt;
self.y += self.vy * dt;
self.keep_in_bounds(ox, oy);
// Body tilt from the slope along and across the car.
let (fx, fy) = self.forward();
let along = gx * fx + gy * fy;
let across = gx * -fy + gy * fx;
let q = |s: f32| (s / 0.035).round() as i32;
self.tilt = vehicle::tilt_block(-q(along), q(across));
let target_rpm = if c.throttle > 0.0 {
0.35 + 0.65 * (vf.abs() / top).min(1.0)
} else {
(vf.abs() / top).min(1.0) * 0.7
};
self.rpm += (target_rpm - self.rpm) * (1.0 - (-6.0 * dt).exp());
self.bump *= (-8.0 * dt).exp();
}
/// The picture edge is a wall. Slide along it by keeping whichever axis
/// of the move stays inside, so cars never get pinned in a corner.
fn keep_in_bounds(&mut self, ox: f32, oy: f32) {
const MARGIN: f32 = 3.0;
let inside = |x: f32, y: f32| {
let (sx, sy) = track::to_screen(x, y);
track::on_screen(sx, sy, MARGIN)
};
if inside(self.x, self.y) {
return;
}
let hit = self.speed() / 200.0;
if inside(self.x, oy) {
self.y = oy;
self.vy *= -0.3;
} else if inside(ox, self.y) {
self.x = ox;
self.vx *= -0.3;
} else {
self.x = ox;
self.y = oy;
self.vx *= -0.3;
self.vy *= -0.3;
}
self.bump = self.bump.max(hit.min(0.6));
}
/// Rear wheel contact points in screen space, for skid marks.
pub fn rear_wheels(&self) -> [(f32, f32); 2] {
let (fx, fy) = self.forward();
let (rx, ry) = (-fy, fx);
let (bx, by) = (self.x - fx * 10.0, self.y - fy * 10.0);
[
track::to_screen(bx + rx * 6.0, by + ry * 6.0),
track::to_screen(bx - rx * 6.0, by - ry * 6.0),
]
}
}
/// Height change per world unit along world x and y.
fn gradient(track: &Track, wx: f32, wy: f32) -> (f32, f32) {
let d = 6.0;
let gx = (track.height_world(wx + d, wy) - track.height_world(wx - d, wy)) / (2.0 * d);
let gy = (track.height_world(wx, wy + d) - track.height_world(wx, wy - d)) / (2.0 * d);
(gx, gy)
}
/// Pushes overlapping cars apart and exchanges momentum.
pub fn collide(a: &mut Car, b: &mut Car) {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let d2 = dx * dx + dy * dy;
let min = RADIUS * 2.0;
if d2 >= min * min || d2 < 1e-6 {
return;
}
let d = d2.sqrt();
let (nx, ny) = (dx / d, dy / d);
let overlap = min - d;
let (ia, ib) = (1.0 / a.spec.mass, 1.0 / b.spec.mass);
let push = overlap / (ia + ib);
a.x -= nx * push * ia;
a.y -= ny * push * ia;
b.x += nx * push * ib;
b.y += ny * push * ib;
let vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if vn < 0.0 {
let j = -(1.0 + 0.45) * vn / (ia + ib);
a.vx -= j * ia * nx;
a.vy -= j * ia * ny;
b.vx += j * ib * nx;
b.vy += j * ib * ny;
let hit = (-vn / 150.0).min(1.0);
a.bump = a.bump.max(hit);
b.bump = b.bump.max(hit);
// A glancing knock spins the cars a little.
let spin = (nx * a.forward().1 - ny * a.forward().0) * hit * 0.25;
a.heading = (a.heading + spin).rem_euclid(TAU);
b.heading = (b.heading - spin).rem_euclid(TAU);
}
}
pub fn angle_diff(to: f32, from: f32) -> f32 {
(to - from + PI).rem_euclid(TAU) - PI
}
+361
View File
@@ -0,0 +1,361 @@
//! Race rules: grid, countdown, gate-based lap counting and finishing order.
pub mod ai;
pub mod car;
use crate::data::track::{Gate, Track};
use crate::data::vehicle::Vehicle;
use ai::Ai;
use car::{Car, Controls};
pub const DT: f32 = 1.0 / 50.0;
const COUNTDOWN: f32 = 3.0;
/// Gates are extended by this fraction at each end when testing crossings,
/// so a car clipping the verge still counts.
const GATE_SLACK: f32 = 0.35;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Driver {
Human(usize),
Cpu,
}
pub struct Entrant {
pub car: Car,
pub driver: Driver,
pub ai: Ai,
pub name: String,
/// Gates crossed in order since the start.
pub passed: usize,
pub next_gate: usize,
pub lap_start: f32,
pub best_lap: Option<f32>,
pub last_lap: Option<f32>,
pub finished: Option<f32>,
}
impl Entrant {
pub fn laps_done(&self, gates: usize) -> usize {
self.passed.saturating_sub(1) / gates
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Phase {
Countdown,
Racing,
Done,
}
pub struct Race {
pub track: Track,
pub entrants: Vec<Entrant>,
pub laps: usize,
pub time: f32,
pub phase: Phase,
pub finish_order: Vec<usize>,
/// Track image with skid marks burnt in, 0RGB.
pub ground: Vec<u32>,
pub particles: Vec<Particle>,
done_timer: f32,
rng: u32,
}
pub struct Particle {
pub x: f32,
pub y: f32,
pub vx: f32,
pub vy: f32,
pub life: f32,
pub colour: u32,
}
pub struct EntrantSetup {
pub vehicle: usize,
pub driver: Driver,
pub team: usize,
pub name: String,
}
impl Race {
pub fn new(
track: Track,
vehicles: &[Vehicle],
setup: &[EntrantSetup],
laps: usize,
seed: u32,
) -> Race {
let gates = track.gates.clone();
// Gate 0 is the start/finish line; the grid forms up behind it.
let start_gate = 0;
let path: Vec<(f32, f32)> = gates.iter().map(Gate::mid).collect();
let mut rng = seed | 1;
let mut entrants = Vec::new();
for (i, s) in setup.iter().enumerate() {
// Two-wide grid, rows 30 units apart, following the road back
// from the line.
let row = (i / 2) as f32;
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
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 line = 0.3 + 0.4 * rand01(&mut rng);
let pace = 0.86 + 0.12 * rand01(&mut rng);
entrants.push(Entrant {
car,
driver: s.driver,
ai: Ai::new(line, pace),
name: s.name.clone(),
passed: 0,
next_gate: start_gate,
lap_start: 0.0,
best_lap: None,
last_lap: None,
finished: None,
});
}
let ground = track.rgb();
Race {
track,
entrants,
laps,
time: 0.0,
phase: Phase::Countdown,
finish_order: Vec::new(),
ground,
particles: Vec::new(),
done_timer: 0.0,
rng,
}
}
pub fn countdown(&self) -> Option<u32> {
(self.phase == Phase::Countdown).then(|| (COUNTDOWN - self.time).ceil().max(1.0) as u32)
}
pub fn race_time(&self) -> f32 {
(self.time - COUNTDOWN).max(0.0)
}
pub fn humans_finished(&self) -> bool {
self.entrants
.iter()
.all(|e| e.driver == Driver::Cpu || e.finished.is_some())
}
pub fn step(&mut self, human: &[Controls]) {
self.time += DT;
if self.phase == Phase::Countdown && self.time >= COUNTDOWN {
self.phase = Phase::Racing;
}
let racing = self.phase != Phase::Countdown;
let rt = self.race_time();
let n = self.track.gates.len();
for e in self.entrants.iter_mut() {
let controls = match (e.driver, e.finished) {
(_, Some(_)) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing),
(Driver::Human(p), None) if racing => human.get(p).copied().unwrap_or_default(),
(Driver::Human(_), None) => Controls::default(),
(Driver::Cpu, None) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing),
};
e.car.controls = controls;
// Finished cars cruise round on autopilot at reduced pace.
let pace = if e.finished.is_some() { 0.6 } else { 1.0 };
let (px, py) = (e.car.x, e.car.y);
e.car.step(&self.track, DT, pace);
// Gate crossing, allowing one gate to be skipped.
for skip in 0..2 {
let gi = (e.next_gate + skip) % n;
if crosses(&self.track.gates[gi], (px, py), (e.car.x, e.car.y)) {
let before = e.passed;
e.passed += skip + 1;
e.next_gate = (gi + 1) % n;
if before == 0 {
// First time over the line: the clock starts.
e.lap_start = rt;
} else if e.finished.is_none() && (e.passed - 1) / n > (before - 1) / n {
let lap = rt - e.lap_start;
e.lap_start = rt;
e.last_lap = Some(lap);
e.best_lap = Some(e.best_lap.map_or(lap, |b: f32| b.min(lap)));
}
break;
}
}
}
for i in 0..self.entrants.len() {
for j in i + 1..self.entrants.len() {
let (a, b) = self.entrants.split_at_mut(j);
car::collide(&mut a[i].car, &mut b[0].car);
}
}
for (i, e) in self.entrants.iter_mut().enumerate() {
if e.finished.is_none() && e.passed > self.laps * n {
e.finished = Some(rt);
self.finish_order.push(i);
}
}
self.effects();
if self.phase == Phase::Racing && self.humans_finished() {
self.done_timer += DT;
if self.done_timer > 3.0 {
self.phase = Phase::Done;
}
}
}
fn effects(&mut self) {
let mut spawn = Vec::new();
for e in &self.entrants {
let c = &e.car;
let speed = c.speed();
if c.slip > 55.0 && speed > 40.0 && c.on_road {
let strength = ((c.slip - 55.0) / 120.0).clamp(0.08, 0.35);
for (wx, wy) in c.rear_wheels() {
burn(&mut self.ground, wx, wy, strength);
}
}
if !c.on_road && speed > 50.0 {
spawn.push((c.rear_wheels()[0], c.track_colour(&self.track)));
}
}
for ((x, y), colour) in spawn {
let a = rand01(&mut self.rng) * std::f32::consts::TAU;
self.particles.push(Particle {
x,
y,
vx: a.cos() * 12.0,
vy: a.sin() * 6.0 - 10.0,
life: 0.5,
colour,
});
}
for p in self.particles.iter_mut() {
p.x += p.vx * DT;
p.y += p.vy * DT;
p.life -= DT;
}
self.particles.retain(|p| p.life > 0.0);
}
/// Indices of entrants from first to last.
pub fn standings(&self) -> Vec<usize> {
let n = self.track.gates.len();
let mut order = self.finish_order.clone();
let mut rest: Vec<usize> = (0..self.entrants.len())
.filter(|i| !order.contains(i))
.collect();
rest.sort_by(|&a, &b| {
let (ea, eb) = (&self.entrants[a], &self.entrants[b]);
eb.passed.cmp(&ea.passed).then_with(|| {
let d = |e: &Entrant| {
let m = self.track.gates[e.next_gate % n].mid();
(m.0 - e.car.x).hypot(m.1 - e.car.y)
};
d(ea).total_cmp(&d(eb))
})
});
order.extend(rest);
order
}
}
impl Car {
fn track_colour(&self, track: &Track) -> u32 {
let (sx, sy) = self.screen_pos();
track.palette[track.pixel(sx, sy) as usize]
.scale(1.15)
.to_u32()
}
}
fn burn(ground: &mut [u32], sx: f32, sy: f32, strength: f32) {
use crate::data::track::{H, W};
let (x, y) = (sx as isize, sy as isize);
for dx in 0..2 {
let (px, py) = (x + dx, y);
if px < 0 || py < 0 || px >= W as isize || py >= H as isize {
continue;
}
let p = &mut ground[py as usize * W + px as usize];
let k = 1.0 - strength;
let ch = |v: u32, s: u32| ((((v >> s) & 0xff) as f32 * k) as u32) << s;
*p = ch(*p, 16) | ch(*p, 8) | ch(*p, 0);
}
}
/// The point `dist` world units back along the gate path from gate 0, and
/// the direction of travel there.
fn behind(path: &[(f32, f32)], mut dist: f32) -> ((f32, f32), f32) {
let n = path.len();
let mut i = 0;
for _ in 0..n {
let (a, b) = (path[(i + n - 1) % n], path[i]);
let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-3);
let heading = (b.1 - a.1).atan2(b.0 - a.0);
if dist <= len {
let t = dist / len;
return ((b.0 + (a.0 - b.0) * t, b.1 + (a.1 - b.1) * t), heading);
}
dist -= len;
i = (i + n - 1) % n;
}
let (a, b) = (path[n - 1], path[0]);
(b, (b.1 - a.1).atan2(b.0 - a.0))
}
fn crosses(g: &Gate, p: (f32, f32), q: (f32, f32)) -> bool {
let (dx, dy) = (g.b.0 - g.a.0, g.b.1 - g.a.1);
let a = (g.a.0 - dx * GATE_SLACK, g.a.1 - dy * GATE_SLACK);
let b = (g.b.0 + dx * GATE_SLACK, g.b.1 + dy * GATE_SLACK);
let cross = |o: (f32, f32), u: (f32, f32), v: (f32, f32)| {
(u.0 - o.0) * (v.1 - o.1) - (u.1 - o.1) * (v.0 - o.0)
};
let d1 = cross(a, b, p);
let d2 = cross(a, b, q);
let d3 = cross(p, q, a);
let d4 = cross(p, q, b);
(d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)
}
fn rand01(state: &mut u32) -> f32 {
*state ^= *state << 13;
*state ^= *state >> 17;
*state ^= *state << 5;
(*state >> 8) as f32 / (1u32 << 24) as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_crossing() {
let g = Gate {
a: (0.0, 0.0),
b: (0.0, 10.0),
};
assert!(crosses(&g, (-1.0, 5.0), (1.0, 5.0)));
assert!(!crosses(&g, (1.0, 5.0), (2.0, 5.0)));
// Within the slack beyond the gate end.
assert!(crosses(&g, (-1.0, 12.0), (1.0, 12.0)));
}
#[test]
fn grid_follows_path_backwards() {
let path = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let ((x, y), h) = behind(&path, 30.0);
assert!((x - 0.0).abs() < 1e-3 && (y - 30.0).abs() < 1e-3);
assert!((h + std::f32::consts::FRAC_PI_2).abs() < 1e-3);
let ((x, y), _) = behind(&path, 130.0);
assert!((x - 30.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3);
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Skids: a cross-platform revival of Super Skidmarks (Acid Software, 1995).
//!
//! The engine reads the original Amiga data (ADF images, the LHA archives on
//! them, or extracted files) at run time; no game assets are bundled.
mod app;
mod audio;
mod data;
mod formats;
mod game;
mod render;
use app::{App, Input};
use data::GameData;
use minifb::{Key, KeyRepeat, Scale, ScaleMode, Window, WindowOptions};
use render::{SCREEN_H, SCREEN_W};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const USAGE: &str = "\
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 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
skids trace [--data DIR] --out FILE.png [--track N] [--seconds S]
draw the computer cars' paths on a track
The data directory should hold the Super Skidmarks ADF images (or the LHA
archives from them, or extracted track/ and vehicles/ folders). It defaults to
the current directory, then the directory the executable is in.
CONTROLS:
Player 1: arrow keys (or W A S D in one-player games)
Player 2: W A S D
P pauses, Esc leaves the race.";
struct Args {
cmd: String,
data: Option<PathBuf>,
out: Option<PathBuf>,
scale: u32,
mute: bool,
track: usize,
seconds: f32,
players: usize,
menu: bool,
}
fn parse_args() -> Result<Args, String> {
let mut a = Args {
cmd: "play".into(),
data: None,
out: None,
scale: 2,
mute: false,
track: 1,
seconds: 12.0,
players: 1,
menu: false,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
let mut val = |name: &str| it.next().ok_or(format!("{name} needs a value"));
match arg.as_str() {
"-h" | "--help" | "help" => return Err(String::new()),
"--data" | "-d" => a.data = Some(val("--data")?.into()),
"--out" | "-o" => a.out = Some(val("--out")?.into()),
"--scale" => a.scale = val("--scale")?.parse().map_err(|_| "bad --scale")?,
"--track" => a.track = val("--track")?.parse().map_err(|_| "bad --track")?,
"--seconds" => a.seconds = val("--seconds")?.parse().map_err(|_| "bad --seconds")?,
"--players" => a.players = val("--players")?.parse().map_err(|_| "bad --players")?,
"--mute" => a.mute = true,
"--menu" => a.menu = true,
"info" | "extract" | "render" | "bench" | "trace" | "play" => a.cmd = arg,
other => return Err(format!("unknown argument {other}")),
}
}
Ok(a)
}
fn find_data(explicit: Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p;
}
let looks_right = |d: &Path| {
std::fs::read_dir(d).is_ok_and(|rd| {
rd.flatten().any(|e| {
let n = e.file_name().to_string_lossy().to_lowercase();
n.ends_with(".adf") || n.ends_with(".lha") || n == "track" || n == "data"
})
})
};
let mut candidates = vec![PathBuf::from(".")];
if let Some(dir) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
{
candidates.push(dir);
}
candidates.push(PathBuf::from("data"));
candidates
.into_iter()
.find(|d| looks_right(d))
.unwrap_or_else(|| PathBuf::from("."))
}
fn main() -> ExitCode {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
if !e.is_empty() {
eprintln!("error: {e}\n");
}
eprintln!("{USAGE}");
return if e.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
};
}
};
let root = find_data(args.data.clone());
let data = match GameData::load(&root) {
Ok(d) => d,
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
for w in &data.warnings {
eprintln!("warning: {w}");
}
let result = match args.cmd.as_str() {
"info" => {
info(&data);
Ok(())
}
"extract" => extract(&data, args.out.as_deref()),
"render" => render_shot(data, &args),
"bench" => bench(data, &args),
"trace" => trace(data, &args),
_ => play(data, &args),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn info(data: &GameData) {
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)),
}
}
println!("Vehicles:");
for v in &data.vehicles {
println!(" {:<10} ({})", v.name, v.id);
}
}
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;
}
}
}
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());
}
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];
}
}
}
}
}
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());
}
Ok(())
}
/// Runs a race with computer drivers in the human seats and saves the screen.
fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
let out = args.out.clone().ok_or("render needs --out FILE.png")?;
let mut app = App::new(data, audio::Audio::new(false));
if args.menu {
for _ in 0..(args.seconds / game::DT) as usize {
app.update(&Input::default());
}
app.draw();
return render::save_png(&out, SCREEN_W, SCREEN_H, &app.frame.buf)
.map_err(|e| e.to_string());
}
let track = args.track.saturating_sub(1);
let players = args.players.clamp(1, 2);
let mut race = app.build_race(track, players, app.settings.cars, 3, 3, 7)?;
// Let the CPU drive the human cars too.
let mut cams = [(0.0, 0.0); 2];
for (i, cam) in cams.iter_mut().enumerate() {
if let Some(e) = race
.entrants
.iter()
.find(|e| e.driver == game::Driver::Human(i))
{
*cam = e.car.screen_pos();
}
}
let frames = (args.seconds / game::DT) as usize;
let mut frame = render::Frame::new();
for _ in 0..frames {
let controls: Vec<_> = race
.entrants
.iter_mut()
.filter(|e| matches!(e.driver, game::Driver::Human(_)))
.map(|e| e.ai.drive(&e.car, &race.track, e.next_gate, game::DT, true))
.collect();
race.step(&controls);
app::draw_race(&mut frame, &app.data, &race, &mut cams, players);
}
render::save_png(&out, SCREEN_W, SCREEN_H, &frame.buf).map_err(|e| e.to_string())?;
let n = race.track.gates.len();
for &i in &race.standings() {
let e = &race.entrants[i];
println!(
"{:<10} laps {} gates {} best {:?}",
e.name,
e.laps_done(n),
e.passed,
e.best_lap
);
}
println!("wrote {}", out.display());
Ok(())
}
/// Races four computer cars on every track and reports how they got on:
/// a quick health check for the track decoding and the AI.
fn bench(data: GameData, args: &Args) -> Result<(), String> {
let app = App::new(data, audio::Audio::new(false));
let frames = (args.seconds / game::DT) as usize;
println!(
"{:<10} {:>5} {:>8} {:>9} {:>8}",
"TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD"
);
for ti in 0..app.data.track_list().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 {
race.step(&[]);
for e in &race.entrants {
road += e.car.on_road as usize;
samples += 1;
}
}
let n = race.track.gates.len();
let laps: Vec<String> = race
.entrants
.iter()
.map(|e| e.laps_done(n).to_string())
.collect();
let best = race
.entrants
.iter()
.filter_map(|e| e.best_lap)
.fold(f32::MAX, f32::min);
let best = if best == f32::MAX {
"-".to_string()
} else {
app::fmt_time(best)
};
println!(
"{:<10} {:>5} {:>8} {:>9} {:>7.0}%",
race.track.name,
n,
laps.join(","),
best,
100.0 * road as f32 / samples as f32
);
}
Ok(())
}
fn trace(data: GameData, args: &Args) -> Result<(), String> {
use data::track;
let out = args.out.clone().ok_or("trace needs --out FILE.png")?;
let app = App::new(data, audio::Audio::new(false));
let mut race = app.build_race(args.track.saturating_sub(1), 0, [0, 0], 4, 99, 1)?;
let mut img = race.track.rgb();
let plot = |img: &mut Vec<u32>, wx: f32, wy: f32, c: u32| {
let (x, y) = track::to_screen(wx, wy);
if track::on_screen(x, y, 0.0) {
img[y as usize * track::W + x as usize] = c;
}
};
for (gi, g) in race.track.gates.iter().enumerate() {
let c = if gi == 0 { 0xffff00 } else { 0xff2020 };
for i in 0..=64 {
let k = i as f32 / 64.0;
plot(
&mut img,
g.a.0 + (g.b.0 - g.a.0) * k,
g.a.1 + (g.b.1 - g.a.1) * k,
c,
);
}
}
const TRAIL: [u32; 4] = [0x00ffff, 0xff00ff, 0x40ff40, 0xffffff];
for _ in 0..(args.seconds / game::DT) as usize {
race.step(&[]);
for (i, e) in race.entrants.iter().enumerate() {
plot(&mut img, e.car.x, e.car.y, TRAIL[i % 4]);
}
}
let n = race.track.gates.len();
for e in &race.entrants {
println!(
"{:<8} laps {} next gate {}",
e.name,
e.laps_done(n),
e.next_gate
);
}
render::save_png(&out, track::W, track::H, &img).map_err(|e| e.to_string())
}
fn play(data: GameData, args: &Args) -> Result<(), String> {
let scale = match args.scale {
1 => Scale::X1,
4 => Scale::X4,
_ => Scale::X2,
};
let opts = WindowOptions {
scale,
resize: true,
scale_mode: ScaleMode::AspectRatioStretch,
..WindowOptions::default()
};
let mut window = Window::new("Skids - Super Skidmarks revival", SCREEN_W, SCREEN_H, opts)
.map_err(|e| e.to_string())?;
// PAL Amigas ran at 50 frames per second; the simulation is tied to it.
window.set_target_fps(50);
let mut app = App::new(data, audio::Audio::new(!args.mute));
while window.is_open() && !app.quit {
let input = Input {
held: window.get_keys(),
pressed: window
.get_keys_pressed(KeyRepeat::Yes)
.into_iter()
.filter(|&k| k != Key::Unknown)
.collect(),
};
app.update(&input);
app.draw();
window
.update_with_buffer(&app.frame.buf, SCREEN_W, SCREEN_H)
.map_err(|e| e.to_string())?;
}
Ok(())
}
+67
View File
@@ -0,0 +1,67 @@
//! A small 5x7 bitmap font (original artwork for this project).
pub const GLYPH_W: usize = 5;
pub const ADVANCE: usize = 6;
pub fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
'N' => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
'Y' => [0x11, 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04],
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
'.' => [0, 0, 0, 0, 0, 0x0C, 0x0C],
',' => [0, 0, 0, 0, 0x0C, 0x04, 0x08],
':' => [0, 0x0C, 0x0C, 0, 0x0C, 0x0C, 0],
'-' => [0, 0, 0, 0x1F, 0, 0, 0],
'+' => [0, 0x04, 0x04, 0x1F, 0x04, 0x04, 0],
'/' => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10],
'!' => [0x04, 0x04, 0x04, 0x04, 0x04, 0, 0x04],
'?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0, 0x04],
'<' => [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],
'>' => [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08],
'(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
'\'' => [0x04, 0x04, 0x08, 0, 0, 0, 0],
'"' => [0x0A, 0x0A, 0, 0, 0, 0, 0],
'#' => [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A],
'=' => [0, 0, 0x1F, 0, 0x1F, 0, 0],
'_' => [0, 0, 0, 0, 0, 0, 0x1F],
_ => [0; 7],
}
}
pub fn text_width(s: &str, scale: usize) -> usize {
s.chars().count() * ADVANCE * scale
}
+228
View File
@@ -0,0 +1,228 @@
//! Software rendering into a 0RGB framebuffer at the Amiga's PAL resolution.
pub mod font;
use crate::data::track;
use crate::data::vehicle::{SPR_H, SPR_W};
pub const SCREEN_W: usize = 320;
pub const SCREEN_H: usize = 256;
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect { x, y, w, h }
}
}
pub struct Frame {
pub buf: Vec<u32>,
}
impl Frame {
pub fn new() -> Frame {
Frame {
buf: vec![0; SCREEN_W * SCREEN_H],
}
}
pub fn clear(&mut self, c: u32) {
self.buf.fill(c);
}
#[inline]
pub fn put(&mut self, x: i32, y: i32, c: u32) {
if x >= 0 && y >= 0 && (x as usize) < SCREEN_W && (y as usize) < SCREEN_H {
self.buf[y as usize * SCREEN_W + x as usize] = c;
}
}
pub fn fill(&mut self, r: Rect, c: u32) {
let x0 = r.x.max(0) as usize;
let y0 = r.y.max(0) as usize;
let x1 = ((r.x + r.w).max(0) as usize).min(SCREEN_W);
let y1 = ((r.y + r.h).max(0) as usize).min(SCREEN_H);
for y in y0..y1 {
self.buf[y * SCREEN_W + x0..y * SCREEN_W + x1.max(x0)].fill(c);
}
}
/// Darkens a region, for menus drawn over a running race.
pub fn shade(&mut self, r: Rect, keep: u32) {
let x0 = r.x.max(0) as usize;
let y0 = r.y.max(0) as usize;
let x1 = ((r.x + r.w).max(0) as usize).min(SCREEN_W);
let y1 = ((r.y + r.h).max(0) as usize).min(SCREEN_H);
for y in y0..y1 {
for p in &mut self.buf[y * SCREEN_W + x0..y * SCREEN_W + x1.max(x0)] {
let ch = |s: u32| ((((*p >> s) & 0xff) * keep) >> 8) << s;
*p = ch(16) | ch(8) | ch(0);
}
}
}
pub fn frame_rect(&mut self, r: Rect, c: u32) {
self.fill(Rect::new(r.x, r.y, r.w, 1), c);
self.fill(Rect::new(r.x, r.y + r.h - 1, r.w, 1), c);
self.fill(Rect::new(r.x, r.y, 1, r.h), c);
self.fill(Rect::new(r.x + r.w - 1, r.y, 1, r.h), c);
}
/// Copies part of a track-sized image into `view`, with (cx, cy) the
/// track pixel at the view's top-left corner.
pub fn blit_track(&mut self, ground: &[u32], view: Rect, cx: i32, cy: i32) {
for row in 0..view.h {
let sy = view.y + row;
let ty = cy + row;
if sy < 0 || sy as usize >= SCREEN_H {
continue;
}
for col in 0..view.w {
let sx = view.x + col;
let tx = cx + col;
if sx < 0 || sx as usize >= SCREEN_W {
continue;
}
let c =
if tx >= 0 && ty >= 0 && (tx as usize) < track::W && (ty as usize) < track::H {
ground[ty as usize * track::W + tx as usize]
} else {
0x101010
};
self.buf[sy as usize * SCREEN_W + sx as usize] = c;
}
}
}
/// Draws a 64x32 indexed sprite (index 0 transparent) clipped to `clip`.
pub fn sprite(&mut self, pix: &[u8], pal: &[u32; 4], x: i32, y: i32, clip: Rect) {
for row in 0..SPR_H as i32 {
let sy = y + row;
if sy < clip.y || sy >= clip.y + clip.h || sy < 0 || sy as usize >= SCREEN_H {
continue;
}
for col in 0..SPR_W as i32 {
let i = pix[(row * SPR_W as i32 + col) as usize];
if i == 0 {
continue;
}
let sx = x + col;
if sx < clip.x || sx >= clip.x + clip.w || sx < 0 || sx as usize >= SCREEN_W {
continue;
}
self.buf[sy as usize * SCREEN_W + sx as usize] = pal[i as usize];
}
}
}
/// Soft drop shadow under a sprite: darkens what is already drawn.
pub fn sprite_shadow(&mut self, pix: &[u8], x: i32, y: i32, clip: Rect) {
for row in 0..SPR_H as i32 {
let sy = y + row;
if sy < clip.y || sy >= clip.y + clip.h || sy < 0 || sy as usize >= SCREEN_H {
continue;
}
for col in 0..SPR_W as i32 {
if pix[(row * SPR_W as i32 + col) as usize] == 0 {
continue;
}
let sx = x + col;
if sx < clip.x || sx >= clip.x + clip.w || sx < 0 || sx as usize >= SCREEN_W {
continue;
}
let p = &mut self.buf[sy as usize * SCREEN_W + sx as usize];
*p = (*p >> 1) & 0x7f7f7f;
}
}
}
pub fn text(&mut self, x: i32, y: i32, s: &str, c: u32, scale: usize) {
let mut cx = x;
for ch in s.chars() {
let g = font::glyph(ch);
for (row, bits) in g.iter().enumerate() {
for col in 0..font::GLYPH_W {
if bits & (0x10 >> col) != 0 {
for dy in 0..scale {
for dx in 0..scale {
self.put(
cx + (col * scale + dx) as i32,
y + (row * scale + dy) as i32,
c,
);
}
}
}
}
}
cx += (font::ADVANCE * scale) as i32;
}
}
/// Text with a one-pixel drop shadow so it reads over the track.
pub fn text_shadow(&mut self, x: i32, y: i32, s: &str, c: u32, scale: usize) {
self.text(x + scale as i32, y + scale as i32, s, 0x000000, scale);
self.text(x, y, s, c, scale);
}
pub fn text_centered(&mut self, cx: i32, y: i32, s: &str, c: u32, scale: usize) {
let w = font::text_width(s, scale) as i32;
self.text_shadow(cx - w / 2, y, s, c, scale);
}
pub fn image(&mut self, src: &[u32], w: usize, h: usize, x: i32, y: i32) {
for row in 0..h {
for col in 0..w {
self.put(x + col as i32, y + row as i32, src[row * w + col]);
}
}
}
}
/// Box-filtered downscale of a track-sized image.
pub fn downscale(src: &[u32], w: usize, h: usize) -> Vec<u32> {
let mut out = vec![0; w * h];
let (fx, fy) = (track::W / w, track::H / h);
for y in 0..h {
for x in 0..w {
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for yy in y * fy..(y + 1) * fy {
for xx in x * fx..(x + 1) * fx {
let p = src[yy * track::W + xx];
r += (p >> 16) & 0xff;
g += (p >> 8) & 0xff;
b += p & 0xff;
}
}
let n = (fx * fy) as u32;
out[y * w + x] = ((r / n) << 16) | ((g / n) << 8) | (b / n);
}
}
out
}
pub fn save_png(path: &std::path::Path, w: usize, h: usize, px: &[u32]) -> std::io::Result<()> {
let file = std::io::BufWriter::new(std::fs::File::create(path)?);
let mut enc = png::Encoder::new(file, w as u32, h as u32);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().map_err(std::io::Error::other)?;
let bytes: Vec<u8> = px
.iter()
.flat_map(|&p| [(p >> 16) as u8, (p >> 8) as u8, p as u8])
.collect();
wr.write_image_data(&bytes).map_err(std::io::Error::other)
}
impl Default for Frame {
fn default() -> Self {
Self::new()
}
}