Merge pull request #1 from andydixon/rust-port

Add Rust revival engine for Super Skidmarks
This commit is contained in:
Andy Dixon
2026-10-19 19:40:34 +01:00
committed by GitHub
22 changed files with 5656 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
name: build
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact: skids-linux-x86_64
bin: skids
- os: windows-latest
artifact: skids-windows-x86_64
bin: skids.exe
- os: macos-latest
artifact: skids-macos-arm64
bin: skids
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Linux dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libx11-dev libxkbcommon-dev
- name: Test
run: cargo test --release
- name: Build
run: cargo build --release
- name: Check against the game data
shell: bash
run: |
./target/release/${{ matrix.bin }} info --data .
./target/release/${{ matrix.bin }} bench --data . --seconds 60
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: |
target/release/${{ matrix.bin }}
README.md
docs/FORMATS.md
+3
View File
@@ -0,0 +1,3 @@
/target
*.png
/out
Generated
+1515
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "skids"
version = "0.1.0"
edition = "2021"
description = "Cross-platform revival of Super Skidmarks (Acid Software, 1995) that runs on the original Amiga game data"
license = "MIT"
readme = "README.md"
repository = "https://github.com/andydixon/skids"
[features]
default = ["audio"]
# Procedural engine/skid sound via cpal. Disable with --no-default-features
# on Linux machines without the ALSA development headers.
audio = ["dep:cpal"]
[dependencies]
minifb = "0.28"
png = "0.18"
cpal = { version = "0.15", optional = true }
[profile.dev]
# The renderer is a software rasteriser; unoptimised builds are too slow to play.
opt-level = 2
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+100
View File
@@ -0,0 +1,100 @@
# Skids: a Super Skidmarks revival
A cross-platform (Windows, macOS, Linux) engine written in Rust that plays
*Super Skidmarks* (Acid Software, 1995) on the original Amiga data.
It reads the game's ADF disk images directly: it unpacks the LHA archives
on them, decrunches the CrunchMania-packed files, and decodes the planar
track graphics and AGA sprite banks. No game assets are compiled into the
program; you supply the disks.
## What works
- All 24 tracks across the four track files. Each has its isometric height
map and lap gates.
- All 8 vehicles (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police,
Cow), with 32 headings and 25 body-tilt frames for hills.
- 1 or 2 players (split screen) plus up to 7 computer drivers.
- Power-slide handling, slopes, car-to-car bumping, and skid marks burnt
into the track.
- Lap and position tracking, lap times, and a results table.
- Procedural engine, tyre and crash sound.
## What's reconstructed
Disk 1 of the six-disk set is missing from this archive. It held the
executable, palettes, sound and music. As a result:
- **Handling, AI and race rules** are new code written to feel like the
original, not a translation of the original 68000 code.
- **Colours** come from a reconstructed palette, built around how the
tracks use their 16 colour indices. If you have the real palettes, put
them in a `palettes/` folder next to the disks as `.pal` files (16 lines
of `RRGGBB`):
- `track1-3.pal` for one track
- `track1.pal` for all six tracks in a file
- `default.pal` for everything
- **Sound** is synthesised.
If you find disk 1, drop it in with the others. Anything it contains under
`track/` or `vehicles/` is picked up automatically. The format notes in
[docs/FORMATS.md](docs/FORMATS.md) cover everything reverse engineered so
far.
## Running
```sh
cargo run --release -- --data /path/to/adfs
```
Without `--data`, the game looks in the current directory and then next to
the executable, so running it from this repository's root just works.
| Action | Player 1 | Player 2 |
|-------------|---------------------------------------|----------|
| Accelerate | ↑ (or W in one-player games) | W |
| Brake / reverse | ↓ (or S) | S |
| Steer | ← → (or A D) | A D |
| Pause | P | |
| Leave race | Esc | |
Options: `--scale 1|2|4` (window size), `--mute`.
### Tools
```sh
skids info --data DIR # list tracks and vehicles found
skids extract --data DIR --out pngs/ # dump every track (with gates) and car sheet
skids bench --data DIR --seconds 120 # race CPU cars on every track: laps and time on road
skids trace --data DIR --track 11 --out t.png # plot CPU racing lines
skids render --data DIR --track 3 --out shot.png # headless screenshot
```
## Building
You need a stable Rust toolchain.
- **Linux:** install the ALSA and X11 development packages, e.g.
`sudo apt install libasound2-dev libx11-dev libxkbcommon-dev`. To build
without sound (no ALSA needed), use `cargo build --release --no-default-features`.
- **macOS / Windows:** `cargo build --release` works as is.
GitHub Actions builds all three platforms on every push
(`.github/workflows/build.yml`).
## Layout
```
src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers
src/data/ track, vehicle and palette decoding; locating the game files
src/game/ physics, AI and race rules
src/render/ software renderer at 320×256 (PAL low-res) and a bitmap font
src/audio.rs synthesised sound (cpal)
src/app.rs menus, race screens and results
```
## Credits
*Super Skidmarks* is by Acid Software. This project is an unofficial
revival for preservation. The CrunchMania bitstream layout follows Teemu
Suutari's [ancient](https://github.com/temisu/ancient) (BSD-2-Clause).
+135
View File
@@ -0,0 +1,135 @@
# Super Skidmarks data formats
Notes from reverse engineering the AGA/CD32 release of *Super Skidmarks*
(Acid Software, 1995). Everything here was worked out from the data on disks
2–6. Disk 1, which holds the executable, is missing from this archive, so
anything that lives only in the executable (palettes, sound, handling) is
unknown. See [Gaps](#gaps).
All multi-byte values are big-endian unless noted.
## Disks
Each disk is a standard 880 KB double-density ADF (1760 blocks of 512 bytes)
formatted as OFS (`DOS\0`). The `[HD]` in the file names refers to the
hard-drive-installable release, not high-density disks. Each disk holds a
single file:
| Disk | File | Contents |
|------|---------------|------------------------------------|
| 1 | `smcd32a.lha` | *missing* |
| 2 | `smcd32b.lha` | `vehicles/cow.aga`, `track/TRACK1` |
| 3 | `smcd32c.lha` | `track/TRACK2`, `vehicles/TRUCK.aga` |
| 4 | `smcd32d.lha` | `track/TRACK3`, `vehicles/PORSCHE.aga` |
| 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` |
| 6 | `smcd32f.lha` | `vehicles/F1.aga`, `Midget.aga`, `mini.aga`, `Police.aga` |
Each disk's archive stands on its own (the set isn't one archive split
across disks). They use level-0 headers and the `-lh0-` method (stored),
under a top-level `super_skidmarks_cd³²\` directory. Most members are
individually packed with CrunchMania.
## CrunchMania
```
0 4 id: "CrM!" (LZ + fixed codes) or "CrM2" (LZ + Huffman)
lower-case "Crm" = delta-encoded samples (running byte sum)
4 2 unused
6 4 unpacked size
10 4 packed size (excludes this 14-byte header)
14 … packed stream
```
The stream is read **backwards** from `14 + packed - 6`, a byte at a time,
LSB first. The last six bytes seed the bit buffer: a 32-bit word shifted
right by `16 - shift` with `16 + shift` valid bits, where `shift` is the
final 16-bit word. Output is also written from the end towards the start.
`src/formats/crm.rs` implements both variants. Every packed file on the
disks decodes exactly: the output buffer fills completely and the input is
consumed to its first byte.
## Tracks (`track/TRACKn`)
```
0x00 7 × u32 end offset of sections 0..6
0x1C … section 0: track-selector thumbnails (20622 bytes, raw)
… sections 1..6: one track each, CrunchMania-packed
```
So the four files hold 24 tracks. Each track section unpacks to 281240
(`0x44A98`) bytes:
| Offset | Size | Contents |
|----------|--------|----------|
| `0x0000` | 8192 | Height map: 64 × 64 `u16`, 8.8 fixed point, row-major. Each cell covers 16 × 8 screen pixels. |
| `0x2000` | 8192 | Unused, zero on every track. |
| `0x4000` | 8 | `u16` gate count, `u16` start x, `u16` start y, `u16` flags. |
| `0x4008` | 8 × n | Gates: `x1 y1 x2 y2`, world coordinates × 32. The list repeats after the last gate. |
| … | | Unknown data, mostly a bit pattern, up to `0x4A98`. |
| `0x4A98` | 4 × 65536 | Four non-interleaved 1024 × 512 bitplanes, giving 16 colours. |
### Track geometry
Gates are line segments across the road and run in race order; gate 0 is
the start/finish line. They're defined on an isometric ground plane rotated
45° to the picture. The projection that best fits the data is:
```
screen x = wx - wy + 512
screen y = (wx + wy) × 31/64 - 252
```
With these constants, 96% of points sampled along the gates land on road
pixels across all 24 tracks (92% minimum). A plain screen-aligned 2:1 squash
gives 40–70%. Adding a height term doesn't improve the fit, so the height
map is used only for slope physics and body tilt.
The stored start point is on the road for 14 of the 24 tracks read as screen
coordinates, and 10 read as world coordinates. It isn't used; the grid forms
up behind gate 0.
### Palette use
The track pixel indices are ordered from dark to light. Indices 0–7 are
scenery and 8–15 the driving surface, so the fourth bitplane is effectively
a road mask. The game uses it as such here: off-road means less grip and
lower speed.
## Vehicles (`vehicles/*.aga`)
CrunchMania-packed, 409600 bytes unpacked:
25 tilt blocks × 32 headings × 512-byte frames.
A frame is a 64 × 32 AGA hardware sprite. Each line holds 8 bytes of plane
0, then 8 bytes of plane 1, giving 3 colours plus transparent:
| Index | Use |
|-------|---------------------|
| 0 | transparent |
| 1 | tyres, shadow, dark |
| 2 | trim / accent |
| 3 | bodywork |
- **Heading:** frame 12 faces screen-right, and frames advance clockwise in
11.25° steps. In world terms, frame = 12 + (angle + 45°) / 11.25°.
- **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level.
Block 12 is the car on flat ground.
`Police.aga` reads as all zeros with one third-party ADF tool, but it's
intact when read directly from the disk image.
## Gaps
These almost certainly live on the missing disk 1:
- The main executable, and with it the real handling model, AI, rules,
menus and fonts. This port's versions are reconstructions.
- Colour palettes. Neither the track nor the vehicle files carry any. The
engine uses a reconstructed palette, and real ones can be dropped in as
`.pal` files (see the README).
- Sound effects and music.
- Any tracks or vehicles beyond the 24 and 8 above.
If disk 1 turns up, put it next to the others and run `skids info`.
Anything in a `track/` or `vehicles/` directory inside its archive is
picked up automatically.
+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()
}
}