Add Rust revival engine for Super Skidmarks
Cross-platform (Windows/macOS/Linux) engine that plays the original Amiga data straight from the ADF images: - ADF (OFS/FFS), LHA and CrunchMania (CrM!/CrM2) readers - Track decoding: 24 tracks of 1024x512 16-colour planar art, height maps and lap gates on an isometric ground plane - Vehicle decoding: 8 cars, 32 headings x 25 tilt frames of 64x32 AGA sprites - Reconstructed handling, AI, race rules, menus and synthesised sound (disk 1 with the executable, palettes and audio is not in the set) - info/extract/bench/trace/render tools, format notes, CI builds
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
*.png
|
||||
/out
|
||||
Generated
+1515
File diff suppressed because it is too large
Load Diff
+29
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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!"[..]));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user