Add the contents of disk 1
- LHA -lh5/-lh6/-lh7 decoding with CRC checks (disk 1 is mostly lh5) - Real track palettes and set names, read from each track file's header - Camaro, plus the caravan as a towable trailer (Caravans option) - Drivers' names, menu pictures and the original .set values per car - Original sound effects (IFF 8SVX) through a new sample mixer: engine pitched with revs, tyre screech, crashes, backfire, horn, start beeps - The original tune (OctaMED MMD1) through a new player; loudness tracks OpenMPT's render through the whole song. Music option in the menu - Title picture (IFF ILBM) at the top of the menu - skids unpack and skids music commands - Gate crossings now judged on each car's whole movement per frame, including collision knocks; stalled computer cars are put back on the line after 8 seconds The executable's game logic (handling, AI) is still not decoded.
This commit is contained in:
+281
-49
@@ -1,6 +1,6 @@
|
||||
//! Front end: menu, race screens and results.
|
||||
|
||||
use crate::audio::Audio;
|
||||
use crate::audio::{Audio, Sfx};
|
||||
use crate::data::{palette, track, vehicle, GameData};
|
||||
use crate::game::car::Controls;
|
||||
use crate::game::{Driver, EntrantSetup, Phase, Race};
|
||||
@@ -45,12 +45,22 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Music {
|
||||
Menus,
|
||||
Always,
|
||||
Off,
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
pub players: usize,
|
||||
pub track: usize,
|
||||
pub laps: usize,
|
||||
pub cars: [usize; 2],
|
||||
pub opponents: usize,
|
||||
/// Every car tows a caravan.
|
||||
pub caravans: bool,
|
||||
pub music: Music,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -62,6 +72,8 @@ enum Item {
|
||||
Car1,
|
||||
Car2,
|
||||
Opponents,
|
||||
Caravans,
|
||||
Music,
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -73,14 +85,29 @@ enum Screen {
|
||||
race: Box<Race>,
|
||||
cams: [(f32, f32); 2],
|
||||
paused: bool,
|
||||
sounds: RaceSounds,
|
||||
},
|
||||
Results {
|
||||
race: Box<Race>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the race screen remembers between frames to decide when to play
|
||||
/// one-shot sounds.
|
||||
#[derive(Default)]
|
||||
struct RaceSounds {
|
||||
countdown: Option<u32>,
|
||||
go_played: bool,
|
||||
bumps: Vec<f32>,
|
||||
throttle: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub data: GameData,
|
||||
/// Indices of vehicles that can be driven (not trailers).
|
||||
cars: Vec<usize>,
|
||||
/// The title picture at half size, if the data has one.
|
||||
title: Option<(usize, usize, Vec<u32>)>,
|
||||
pub settings: Settings,
|
||||
screen: Screen,
|
||||
pub frame: Frame,
|
||||
@@ -95,21 +122,35 @@ pub struct App {
|
||||
|
||||
impl App {
|
||||
pub fn new(data: GameData, audio: Audio) -> App {
|
||||
let cars = [
|
||||
data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0),
|
||||
let drivable: Vec<usize> = (0..data.vehicles.len())
|
||||
.filter(|&i| !data.vehicles[i].trailer)
|
||||
.collect();
|
||||
let pick = |id: &str| {
|
||||
data.vehicles
|
||||
.iter()
|
||||
.position(|v| v.id == "mini")
|
||||
.unwrap_or(0),
|
||||
];
|
||||
.position(|v| v.id == id)
|
||||
.unwrap_or(drivable[0])
|
||||
};
|
||||
let cars = [pick("f1"), pick("mini")];
|
||||
let title = data.pictures.get("skidtoon").map(half_size);
|
||||
let music = if audio.has_music() {
|
||||
Music::Menus
|
||||
} else {
|
||||
Music::Off
|
||||
};
|
||||
audio.set_music(music != Music::Off);
|
||||
App {
|
||||
data,
|
||||
cars: drivable,
|
||||
title,
|
||||
settings: Settings {
|
||||
players: 1,
|
||||
track: 0,
|
||||
laps: 3,
|
||||
cars,
|
||||
opponents: 3,
|
||||
caravans: false,
|
||||
music,
|
||||
},
|
||||
screen: Screen::Menu { cursor: 0 },
|
||||
frame: Frame::new(),
|
||||
@@ -134,7 +175,14 @@ impl App {
|
||||
if self.settings.players == 2 {
|
||||
v.push(Item::Car2);
|
||||
}
|
||||
v.extend([Item::Opponents, Item::Quit]);
|
||||
v.push(Item::Opponents);
|
||||
if self.data.vehicles.iter().any(|v| v.trailer) {
|
||||
v.push(Item::Caravans);
|
||||
}
|
||||
if self.audio.has_music() {
|
||||
v.push(Item::Music);
|
||||
}
|
||||
v.push(Item::Quit);
|
||||
v
|
||||
}
|
||||
|
||||
@@ -148,7 +196,11 @@ impl App {
|
||||
seed: u32,
|
||||
) -> Result<Race, String> {
|
||||
let track = self.data.load_track(track_index)?;
|
||||
let nv = self.data.vehicles.len();
|
||||
let trailer = if self.settings.caravans {
|
||||
self.data.vehicles.iter().position(|v| v.trailer)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut setup = Vec::new();
|
||||
for (p, &car) in cars.iter().enumerate().take(humans) {
|
||||
setup.push(EntrantSetup {
|
||||
@@ -156,6 +208,7 @@ impl App {
|
||||
driver: Driver::Human(p),
|
||||
team: p,
|
||||
name: format!("PLAYER {}", p + 1),
|
||||
trailer,
|
||||
});
|
||||
}
|
||||
let mut rng = seed.wrapping_mul(2654435761) | 1;
|
||||
@@ -164,10 +217,11 @@ impl App {
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
setup.push(EntrantSetup {
|
||||
vehicle: rng as usize % nv,
|
||||
vehicle: self.cars[rng as usize % self.cars.len()],
|
||||
driver: Driver::Cpu,
|
||||
team: humans + i,
|
||||
name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(),
|
||||
trailer,
|
||||
});
|
||||
}
|
||||
// Humans start at the back, as is tradition.
|
||||
@@ -189,9 +243,15 @@ impl App {
|
||||
self.tick += 1;
|
||||
match &mut self.screen {
|
||||
Screen::Menu { .. } => self.update_menu(input),
|
||||
Screen::Race { race, paused, .. } => {
|
||||
Screen::Race {
|
||||
race,
|
||||
paused,
|
||||
sounds,
|
||||
cams,
|
||||
} => {
|
||||
if input.hit(Key::Escape) {
|
||||
self.audio.silence();
|
||||
self.audio.set_music(self.settings.music != Music::Off);
|
||||
self.screen = Screen::Menu { cursor: 0 };
|
||||
return;
|
||||
}
|
||||
@@ -216,22 +276,10 @@ impl App {
|
||||
};
|
||||
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);
|
||||
}
|
||||
race_sounds(&self.audio, race, sounds, cams, input, self.tick);
|
||||
if race.phase == Phase::Done {
|
||||
self.audio.silence();
|
||||
self.audio.set_music(self.settings.music != Music::Off);
|
||||
let Screen::Race { race, .. } =
|
||||
std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 })
|
||||
else {
|
||||
@@ -265,7 +313,6 @@ impl App {
|
||||
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 {
|
||||
@@ -275,9 +322,23 @@ impl App {
|
||||
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::Car1 | Item::Car2 => {
|
||||
let slot = (item == Item::Car2) as usize;
|
||||
let at = self
|
||||
.cars
|
||||
.iter()
|
||||
.position(|&c| c == s.cars[slot])
|
||||
.unwrap_or(0);
|
||||
s.cars[slot] = self.cars[wrap(at, self.cars.len(), delta)];
|
||||
}
|
||||
Item::Opponents => s.opponents = wrap(s.opponents, 8 - s.players + 1, delta),
|
||||
Item::Caravans => s.caravans = !s.caravans,
|
||||
Item::Music => {
|
||||
const ORDER: [Music; 3] = [Music::Menus, Music::Always, Music::Off];
|
||||
let at = ORDER.iter().position(|&m| m == s.music).unwrap_or(0);
|
||||
s.music = ORDER[wrap(at, 3, delta)];
|
||||
self.audio.set_music(s.music != Music::Off);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.opponents = s.opponents.min(8 - s.players);
|
||||
@@ -294,6 +355,8 @@ impl App {
|
||||
| Item::Car2
|
||||
| Item::Laps
|
||||
| Item::Players
|
||||
| Item::Caravans
|
||||
| Item::Music
|
||||
| Item::Opponents => {
|
||||
let s = &self.settings;
|
||||
match self.build_race(
|
||||
@@ -310,7 +373,9 @@ impl App {
|
||||
race: Box::new(r),
|
||||
cams,
|
||||
paused: false,
|
||||
sounds: RaceSounds::default(),
|
||||
};
|
||||
self.audio.set_music(self.settings.music == Music::Always);
|
||||
if let Screen::Race { race, cams, .. } = &mut screen {
|
||||
for (i, cam) in cams.iter_mut().enumerate() {
|
||||
if let Some(e) =
|
||||
@@ -400,8 +465,17 @@ impl App {
|
||||
f.clear(0x101820);
|
||||
}
|
||||
|
||||
f.text_centered(160, 10, "SKIDS", YELLOW, 4);
|
||||
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1);
|
||||
let top = match &self.title {
|
||||
Some((w, h, px)) => {
|
||||
f.image(px, *w, *h, (SCREEN_W as i32 - *w as i32) / 2, 2);
|
||||
*h as i32 + 6
|
||||
}
|
||||
None => {
|
||||
f.text_centered(160, 10, "SKIDS", YELLOW, 4);
|
||||
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1);
|
||||
60
|
||||
}
|
||||
};
|
||||
|
||||
let cursor = cursor.min(items.len() - 1);
|
||||
let s = &self.settings;
|
||||
@@ -414,9 +488,20 @@ impl App {
|
||||
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::Caravans => {
|
||||
format!("CARAVANS < {} >", if s.caravans { "ON" } else { "OFF" })
|
||||
}
|
||||
Item::Music => format!(
|
||||
"MUSIC < {} >",
|
||||
match s.music {
|
||||
Music::Menus => "MENUS",
|
||||
Music::Always => "ALWAYS",
|
||||
Music::Off => "OFF",
|
||||
}
|
||||
),
|
||||
Item::Quit => "QUIT".to_string(),
|
||||
};
|
||||
let y = 64 + i as i32 * 14;
|
||||
let y = top + i as i32 * 12;
|
||||
let colour = if i == cursor { YELLOW } else { WHITE };
|
||||
if i == cursor {
|
||||
f.text_shadow(8, y, ">", RED, 1);
|
||||
@@ -430,10 +515,22 @@ impl App {
|
||||
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),
|
||||
Rect::new(px - 2, top - 2, THUMB_W as i32 + 4, THUMB_H as i32 + 4),
|
||||
WHITE,
|
||||
);
|
||||
f.image(&th, THUMB_W, THUMB_H, px, 64);
|
||||
f.image(&th, THUMB_W, THUMB_H, px, top);
|
||||
let set = self
|
||||
.data
|
||||
.load_track(track)
|
||||
.map(|t| t.set_name)
|
||||
.unwrap_or_default();
|
||||
f.text_centered(
|
||||
px + THUMB_W as i32 / 2,
|
||||
top + THUMB_H as i32 + 4,
|
||||
&truncate(&set.to_uppercase(), 21),
|
||||
GREY,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Car preview, slowly turning.
|
||||
@@ -443,33 +540,155 @@ impl App {
|
||||
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 by = top + THUMB_H as i32 + 16;
|
||||
let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, by + 3);
|
||||
f.fill(Rect::new(px - 2, by, THUMB_W as i32 + 4, 48), 0x203018);
|
||||
f.frame_rect(Rect::new(px - 2, by, THUMB_W as i32 + 4, 48), 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 label = if v.driver.is_empty() {
|
||||
v.name.clone()
|
||||
} else {
|
||||
format!("{} - {}", v.name, v.driver)
|
||||
};
|
||||
f.text_centered(
|
||||
px + THUMB_W as i32 / 2,
|
||||
by + 38,
|
||||
&truncate(&label, 21),
|
||||
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, 230, help, GREY, 1);
|
||||
f.text_centered(
|
||||
160,
|
||||
236,
|
||||
242,
|
||||
"UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO",
|
||||
GREY,
|
||||
1,
|
||||
);
|
||||
if let Some(st) = &self.status {
|
||||
f.text_centered(160, 200, st, RED, 1);
|
||||
f.text_centered(160, 218, st, RED, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the sounds a race frame calls for: engines and tyres for each
|
||||
/// player, countdown beeps, horns, backfires, and crashes near a camera.
|
||||
fn race_sounds(
|
||||
audio: &Audio,
|
||||
race: &Race,
|
||||
s: &mut RaceSounds,
|
||||
cams: &[(f32, f32); 2],
|
||||
input: &Input,
|
||||
tick: u64,
|
||||
) {
|
||||
let humans: Vec<usize> = (0..race.entrants.len())
|
||||
.filter(|&i| matches!(race.entrants[i].driver, Driver::Human(_)))
|
||||
.collect();
|
||||
for (slot, &i) in humans.iter().enumerate().take(2) {
|
||||
let c = &race.entrants[i].car;
|
||||
let skid = if c.on_road {
|
||||
((c.slip - 50.0) / 100.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
audio.set_voice(slot, true, c.rpm, skid);
|
||||
let pan = if humans.len() == 2 {
|
||||
[0.3, 0.7][slot]
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let throttle = c.controls.throttle;
|
||||
// Lifting off at high revs pops now and then.
|
||||
if s.throttle[slot] > 0.0
|
||||
&& throttle == 0.0
|
||||
&& c.rpm > 0.7
|
||||
&& (tick.wrapping_mul(2654435761)).is_multiple_of(4)
|
||||
{
|
||||
audio.play(Sfx::Backfire, pan);
|
||||
}
|
||||
s.throttle[slot] = throttle;
|
||||
}
|
||||
// Player one's horn is Right Shift, player two's Left Shift.
|
||||
let horn_keys = [Key::RightShift, Key::LeftShift];
|
||||
for (slot, key) in horn_keys.iter().enumerate().take(humans.len().max(1)) {
|
||||
if input.hit(*key) {
|
||||
audio.play(
|
||||
Sfx::Horn,
|
||||
if humans.len() == 2 {
|
||||
[0.3, 0.7][slot]
|
||||
} else {
|
||||
0.5
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match race.countdown() {
|
||||
Some(n) if s.countdown != Some(n) => {
|
||||
s.countdown = Some(n);
|
||||
audio.play(Sfx::Beep, 0.5);
|
||||
}
|
||||
None if !s.go_played && race.phase != Phase::Countdown => {
|
||||
s.go_played = true;
|
||||
audio.play(Sfx::Go, 0.5);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
s.bumps.resize(race.entrants.len(), 0.0);
|
||||
for (i, e) in race.entrants.iter().enumerate() {
|
||||
let b = e.car.bump;
|
||||
if b > 0.3 && b > s.bumps[i] + 0.2 {
|
||||
let (sx, sy) = e.car.screen_pos();
|
||||
let near = cams
|
||||
.iter()
|
||||
.take(humans.len().max(1))
|
||||
.map(|c| (c.0 - sx).hypot(c.1 - sy))
|
||||
.fold(f32::MAX, f32::min);
|
||||
if near < 220.0 {
|
||||
let pan = (0.5 + (sx - cams[0].0) / 400.0).clamp(0.1, 0.9);
|
||||
audio.play(Sfx::Crash(b), pan);
|
||||
}
|
||||
}
|
||||
s.bumps[i] = b;
|
||||
}
|
||||
}
|
||||
|
||||
/// Halves a picture with a 2x2 box filter, for the menu header.
|
||||
fn half_size(p: &crate::formats::iff::Picture) -> (usize, usize, Vec<u32>) {
|
||||
let (w, h) = (p.w / 2, p.h / 2);
|
||||
let rgb = |x: usize, y: usize| {
|
||||
let c = p
|
||||
.palette
|
||||
.get(p.pixels[y * p.w + x] as usize)
|
||||
.copied()
|
||||
.unwrap_or((0, 0, 0));
|
||||
(c.0 as u32, c.1 as u32, c.2 as u32)
|
||||
};
|
||||
let mut out = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let px = [
|
||||
rgb(2 * x, 2 * y),
|
||||
rgb(2 * x + 1, 2 * y),
|
||||
rgb(2 * x, 2 * y + 1),
|
||||
rgb(2 * x + 1, 2 * y + 1),
|
||||
];
|
||||
let avg = |f: fn(&(u32, u32, u32)) -> u32| px.iter().map(f).sum::<u32>() / 4;
|
||||
out.push((avg(|c| c.0) << 16) | (avg(|c| c.1) << 8) | avg(|c| c.2));
|
||||
}
|
||||
}
|
||||
(w, h, out)
|
||||
}
|
||||
|
||||
pub fn draw_race(
|
||||
f: &mut Frame,
|
||||
data: &GameData,
|
||||
@@ -536,24 +755,37 @@ pub fn draw_world(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Cars and caravans, back to front.
|
||||
let mut order: Vec<(f32, usize, bool)> = Vec::new();
|
||||
for (i, e) in race.entrants.iter().enumerate() {
|
||||
order.push((e.car.screen_pos().1, i, false));
|
||||
if let Some(t) = e.trailer {
|
||||
order.push((track::to_screen(t.x, t.y).1, i, true));
|
||||
}
|
||||
}
|
||||
order.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (_, i, is_trailer) in order {
|
||||
let e = &race.entrants[i];
|
||||
let c = &e.car;
|
||||
let pal = palette::car_palette(c.team);
|
||||
if is_trailer {
|
||||
let t = e.trailer.unwrap();
|
||||
let (sx, sy) = track::to_screen(t.x, t.y);
|
||||
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 =
|
||||
data.vehicles[t.vehicle].frame(vehicle::LEVEL, vehicle::heading_frame(t.heading));
|
||||
f.sprite_shadow(pix, x + 3, y + 2, view);
|
||||
f.sprite(pix, &pal, x, y, view);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
f.sprite(pix, &pal, x, y, view);
|
||||
if let Driver::Human(p) = e.driver {
|
||||
let tag = format!("P{}", p + 1);
|
||||
let tx = x + 32 - 5;
|
||||
|
||||
+323
-92
@@ -1,151 +1,364 @@
|
||||
//! Procedural sound: an engine note per human player, tyre squeal and knocks.
|
||||
//! Sound: the game's own samples and tune, mixed in software.
|
||||
//!
|
||||
//! 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.
|
||||
//! Each human player has an engine voice (the `motor3` loop, pitched with
|
||||
//! revs) and a tyre voice (`screech2`). One-shots cover crashes, backfires,
|
||||
//! the horn and the start beeps; the OctaMED tune plays on top. When a
|
||||
//! sample is missing, a simple synthesised stand-in is used instead.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use crate::formats::iff::Sound;
|
||||
use crate::formats::med::Song;
|
||||
use crate::music::Player;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub const VOICES: usize = 2;
|
||||
|
||||
#[derive(Default)]
|
||||
struct VoiceParams {
|
||||
rpm: AtomicU32,
|
||||
skid: AtomicU32,
|
||||
bump: AtomicU32,
|
||||
active: AtomicU32,
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Sfx {
|
||||
/// Countdown tick.
|
||||
Beep,
|
||||
/// Lights out.
|
||||
Go,
|
||||
Horn,
|
||||
Backfire,
|
||||
/// Strength 0..1.
|
||||
Crash(f32),
|
||||
}
|
||||
|
||||
/// The samples the game uses, picked out of the sound assets by name.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Bank {
|
||||
engine: Option<Arc<Sound>>,
|
||||
skid: Option<Arc<Sound>>,
|
||||
crashes: Vec<Arc<Sound>>,
|
||||
beep: Option<Arc<Sound>>,
|
||||
go: Option<Arc<Sound>>,
|
||||
horn: Option<Arc<Sound>>,
|
||||
backfire: Option<Arc<Sound>>,
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
pub fn from_sounds(sounds: &BTreeMap<String, Sound>) -> Bank {
|
||||
let get = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| sounds.get(*n))
|
||||
.map(|s| Arc::new(s.clone()))
|
||||
};
|
||||
Bank {
|
||||
engine: get(&["motor3", "motor", "engine"]),
|
||||
skid: get(&["screech2", "screech3", "screech"]),
|
||||
crashes: ["crash1", "crash2", "crash3"]
|
||||
.iter()
|
||||
.filter_map(|n| sounds.get(*n))
|
||||
.map(|s| Arc::new(s.clone()))
|
||||
.collect(),
|
||||
beep: get(&["beep"]),
|
||||
go: get(&["beep2", "beep"]),
|
||||
horn: get(&["horn"]),
|
||||
backfire: get(&["backfire"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample playing from `pos`, `step` source frames per output frame.
|
||||
struct Play {
|
||||
sound: Arc<Sound>,
|
||||
pos: f64,
|
||||
step: f64,
|
||||
volume: f32,
|
||||
pan: f32,
|
||||
/// Stop after this many output frames (0 = play to the end).
|
||||
frames_left: usize,
|
||||
}
|
||||
|
||||
impl Play {
|
||||
fn new(sound: Arc<Sound>, rate: f32, volume: f32, pan: f32) -> Play {
|
||||
let step = sound.rate as f64 / rate as f64;
|
||||
Play {
|
||||
sound,
|
||||
pos: 0.0,
|
||||
step,
|
||||
volume,
|
||||
pan,
|
||||
frames_left: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Next sample value, or None when finished.
|
||||
fn next(&mut self, pitch: f64) -> Option<f32> {
|
||||
let s = &self.sound;
|
||||
let i = self.pos as usize;
|
||||
if i >= s.data.len() {
|
||||
return None;
|
||||
}
|
||||
let v = s.data[i] as f32 / 128.0 * s.volume;
|
||||
self.pos += self.step * pitch;
|
||||
if self.pos as usize >= s.data.len() {
|
||||
if let Some(start) = s.loop_start {
|
||||
let len = (s.data.len() - start).max(1) as f64;
|
||||
while self.pos as usize >= s.data.len() {
|
||||
self.pos -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
voices: [VoiceParams; VOICES],
|
||||
master: AtomicU32,
|
||||
struct Voice {
|
||||
active: bool,
|
||||
rpm: f32,
|
||||
skid: f32,
|
||||
level: f32,
|
||||
engine: Option<Play>,
|
||||
tyres: Option<Play>,
|
||||
synth: Synth,
|
||||
}
|
||||
|
||||
fn store(a: &AtomicU32, v: f32) {
|
||||
a.store(v.to_bits(), Ordering::Relaxed);
|
||||
struct Mixer {
|
||||
rate: f32,
|
||||
bank: Bank,
|
||||
voices: [Voice; VOICES],
|
||||
oneshots: Vec<Play>,
|
||||
music: Option<Player>,
|
||||
song: Option<Arc<Song>>,
|
||||
music_on: bool,
|
||||
music_volume: f32,
|
||||
noise: u32,
|
||||
}
|
||||
|
||||
fn load(a: &AtomicU32) -> f32 {
|
||||
f32::from_bits(a.load(Ordering::Relaxed))
|
||||
impl Mixer {
|
||||
fn frame(&mut self) -> (f32, f32) {
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
let rate = self.rate;
|
||||
for (i, v) in self.voices.iter_mut().enumerate() {
|
||||
let target = if v.active { 1.0 } else { 0.0 };
|
||||
v.level += (target - v.level) * 0.0008;
|
||||
if v.level < 1e-4 {
|
||||
continue;
|
||||
}
|
||||
let pan = if i == 0 { 0.35 } else { 0.65 };
|
||||
let mut out = 0.0;
|
||||
match (&mut v.engine, &self.bank.engine) {
|
||||
(Some(p), _) => out += p.next(0.55 + v.rpm as f64 * 1.35).unwrap_or(0.0) * 0.55,
|
||||
(None, Some(s)) => v.engine = Some(Play::new(s.clone(), rate, 1.0, pan)),
|
||||
(None, None) => out += v.synth.engine(v.rpm, rate),
|
||||
}
|
||||
match (&mut v.tyres, &self.bank.skid) {
|
||||
(Some(p), _) => {
|
||||
out += p.next(0.9 + v.skid as f64 * 0.2).unwrap_or(0.0) * v.skid.min(1.0) * 0.45
|
||||
}
|
||||
(None, Some(s)) => v.tyres = Some(Play::new(s.clone(), rate, 1.0, pan)),
|
||||
(None, None) => out += v.synth.squeal(v.skid),
|
||||
}
|
||||
out *= v.level;
|
||||
l += out * (1.0 - pan) * 2.0;
|
||||
r += out * pan * 2.0;
|
||||
}
|
||||
self.oneshots.retain_mut(|p| {
|
||||
if p.frames_left == 1 {
|
||||
return false;
|
||||
}
|
||||
p.frames_left = p.frames_left.saturating_sub(1);
|
||||
match p.next(1.0) {
|
||||
Some(v) => {
|
||||
l += v * p.volume * (1.0 - p.pan) * 2.0;
|
||||
r += v * p.volume * p.pan * 2.0;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
});
|
||||
if self.music_on {
|
||||
if let Some(m) = &mut self.music {
|
||||
let (ml, mr) = m.next();
|
||||
l += ml * self.music_volume;
|
||||
r += mr * self.music_volume;
|
||||
}
|
||||
}
|
||||
(l * 0.5, r * 0.5)
|
||||
}
|
||||
|
||||
fn rand(&mut self) -> u32 {
|
||||
self.noise ^= self.noise << 13;
|
||||
self.noise ^= self.noise >> 17;
|
||||
self.noise ^= self.noise << 5;
|
||||
self.noise
|
||||
}
|
||||
|
||||
fn play(&mut self, sfx: Sfx, pan: f32) {
|
||||
let (sound, volume, max_secs) = match sfx {
|
||||
Sfx::Beep => (self.bank.beep.clone(), 0.6, 0.35),
|
||||
Sfx::Go => (self.bank.go.clone(), 0.7, 0.8),
|
||||
Sfx::Horn => (self.bank.horn.clone(), 0.8, 0.6),
|
||||
Sfx::Backfire => (self.bank.backfire.clone(), 0.7, 0.0),
|
||||
Sfx::Crash(strength) => {
|
||||
let n = self.bank.crashes.len();
|
||||
let r = self.rand() as usize;
|
||||
let pick = if n > 0 {
|
||||
Some(self.bank.crashes[r % n].clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(pick, 0.3 + 0.7 * strength.clamp(0.0, 1.0), 0.0)
|
||||
}
|
||||
};
|
||||
let Some(sound) = sound else { return };
|
||||
// A crash pile-up should not stack dozens of copies.
|
||||
if self.oneshots.len() >= 12 {
|
||||
self.oneshots.remove(0);
|
||||
}
|
||||
let mut p = Play::new(sound, self.rate, volume, pan);
|
||||
// Looped samples (beeps, horn) are cut off after a fixed time.
|
||||
p.frames_left = if max_secs > 0.0 {
|
||||
(max_secs * self.rate) as usize + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.oneshots.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
shared: Arc<Shared>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
#[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);
|
||||
pub fn new(enabled: bool, bank: Bank, song: Option<Arc<Song>>) -> Audio {
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
rate: 44100.0,
|
||||
bank,
|
||||
voices: Default::default(),
|
||||
oneshots: Vec::new(),
|
||||
music: None,
|
||||
song,
|
||||
music_on: false,
|
||||
music_volume: 0.7,
|
||||
noise: 0x2468_ace1,
|
||||
}));
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
let stream = if enabled {
|
||||
backend::open(shared.clone())
|
||||
backend::open(mixer.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Audio {
|
||||
shared,
|
||||
mixer,
|
||||
_stream: stream,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
{
|
||||
let _ = enabled;
|
||||
Audio { shared }
|
||||
Audio { mixer }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
|
||||
let mut m = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut m)
|
||||
}
|
||||
|
||||
pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32) {
|
||||
self.with(|m| {
|
||||
let v = &mut m.voices[i];
|
||||
v.active = active;
|
||||
v.rpm = rpm;
|
||||
v.skid = skid;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn play(&self, sfx: Sfx, pan: f32) {
|
||||
self.with(|m| m.play(sfx, pan.clamp(0.0, 1.0)));
|
||||
}
|
||||
|
||||
/// Starts the tune from the top, or stops it.
|
||||
pub fn set_music(&self, on: bool) {
|
||||
self.with(|m| {
|
||||
if on && !m.music_on {
|
||||
m.music = m.song.clone().map(|s| Player::new(s, m.rate as f64));
|
||||
}
|
||||
m.music_on = on;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn has_music(&self) -> bool {
|
||||
self.with(|m| m.song.is_some())
|
||||
}
|
||||
|
||||
pub fn silence(&self) {
|
||||
for i in 0..VOICES {
|
||||
self.set_voice(i, false, 0.0, 0.0, 0.0);
|
||||
self.set_voice(i, false, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `frames` stereo frames without a sound device (for tests).
|
||||
#[cfg(test)]
|
||||
fn render(&self, frames: usize) -> Vec<(f32, f32)> {
|
||||
self.with(|m| (0..frames).map(|_| m.frame()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Synth state for one voice, run on the audio thread.
|
||||
/// Stand-in sounds for when the samples are not available.
|
||||
#[derive(Default)]
|
||||
struct Synth {
|
||||
phase: f32,
|
||||
sub_phase: f32,
|
||||
rpm: f32,
|
||||
skid: f32,
|
||||
knock: f32,
|
||||
noise: u32,
|
||||
lp: f32,
|
||||
noise: u32,
|
||||
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);
|
||||
}
|
||||
fn white(&mut self) -> f32 {
|
||||
self.noise = self
|
||||
.noise
|
||||
.wrapping_mul(1_664_525)
|
||||
.wrapping_add(1_013_904_223);
|
||||
(self.noise >> 8) as f32 / (1u32 << 23) as f32 - 1.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;
|
||||
fn engine(&mut self, rpm: f32, rate: f32) -> f32 {
|
||||
let freq = 38.0 + 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;
|
||||
let raw = pulse + sub + self.white() * 0.15;
|
||||
self.lp += (raw - self.lp) * (0.06 + rpm * 0.12);
|
||||
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
|
||||
fn squeal(&mut self, skid: f32) -> f32 {
|
||||
let w = self.white();
|
||||
self.skid_lp += (w - self.skid_lp) * 0.35;
|
||||
(w - self.skid_lp) * skid.min(1.0) * 0.22
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
mod backend {
|
||||
use super::{load, Shared, Synth, VOICES};
|
||||
use super::Mixer;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn open(shared: Arc<Shared>) -> Option<cpal::Stream> {
|
||||
pub fn open(mixer: Arc<Mutex<Mixer>>) -> 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();
|
||||
mixer.lock().ok()?.rate = config.sample_rate.0 as f32;
|
||||
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),
|
||||
cpal::SampleFormat::F32 => build::<f32>(&device, &config, mixer),
|
||||
cpal::SampleFormat::I16 => build::<i16>(&device, &config, mixer),
|
||||
cpal::SampleFormat::U16 => build::<u16>(&device, &config, mixer),
|
||||
_ => None,
|
||||
}?;
|
||||
stream.play().ok()?;
|
||||
@@ -155,33 +368,20 @@ mod backend {
|
||||
fn build<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
shared: Arc<Shared>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> 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);
|
||||
let mut m = mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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));
|
||||
let (l, r) = m.frame();
|
||||
let (l, r) = (l.clamp(-1.0, 1.0), r.clamp(-1.0, 1.0));
|
||||
for (c, out) in frame.iter_mut().enumerate() {
|
||||
let v = if channels == 1 {
|
||||
(l + r) * 0.5
|
||||
@@ -200,3 +400,34 @@ mod backend {
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tone(rate: u32, looped: bool) -> Sound {
|
||||
let data = (0..400)
|
||||
.map(|i| ((i as f32 * 0.3).sin() * 100.0) as i8)
|
||||
.collect();
|
||||
Sound {
|
||||
rate,
|
||||
data,
|
||||
loop_start: looped.then_some(0),
|
||||
volume: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_mix_and_one_shots_finish() {
|
||||
let mut sounds = BTreeMap::new();
|
||||
sounds.insert("motor3".to_string(), tone(13714, true));
|
||||
sounds.insert("crash1".to_string(), tone(8363, false));
|
||||
let a = Audio::new(false, Bank::from_sounds(&sounds), None);
|
||||
a.set_voice(0, true, 0.5, 0.0);
|
||||
a.play(Sfx::Crash(1.0), 0.5);
|
||||
let out = a.render(44100);
|
||||
assert!(out.iter().any(|f| f.0.abs() > 0.01), "engine is audible");
|
||||
// A 400-sample crash at 8363 Hz lasts about 2100 output frames.
|
||||
assert!(a.with(|m| m.oneshots.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
+146
-10
@@ -14,7 +14,8 @@ pub mod palette;
|
||||
pub mod track;
|
||||
pub mod vehicle;
|
||||
|
||||
use crate::formats::{adf::Adf, crm, lha};
|
||||
use crate::formats::iff::{self, Picture, Sound};
|
||||
use crate::formats::{adf::Adf, crm, lha, med};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -80,11 +81,8 @@ impl FileSet {
|
||||
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
|
||||
)),
|
||||
Ok(d) => self.add(&e.path, d),
|
||||
Err(why) => self.warnings.push(format!("{path}: {}: {why}", e.path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +114,11 @@ fn logical_key(path: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let (dir, name) = (&parts[n - 2], &parts[n - 1]);
|
||||
matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}"))
|
||||
matches!(
|
||||
dir.as_str(),
|
||||
"track" | "vehicles" | "palettes" | "sfx" | "gfx" | "sounds" | "music"
|
||||
)
|
||||
.then(|| format!("{dir}/{name}"))
|
||||
}
|
||||
|
||||
/// Unpacks CrunchMania data, passing plain data through untouched.
|
||||
@@ -144,6 +146,12 @@ pub struct GameData {
|
||||
pub tracks: Vec<TrackEntry>,
|
||||
pub track_files: Vec<TrackFile>,
|
||||
pub vehicles: Vec<Vehicle>,
|
||||
/// Sound effects by lower-case name (`motor3`, `screech2`, ...).
|
||||
pub sounds: BTreeMap<String, Sound>,
|
||||
/// The tune as an OctaMED module, and the name it is stored under.
|
||||
pub music: Option<(String, Vec<u8>)>,
|
||||
/// Pictures by lower-case name (`skidtoon`, ...).
|
||||
pub pictures: BTreeMap<String, Picture>,
|
||||
pub files: FileSet,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -151,7 +159,17 @@ pub struct GameData {
|
||||
impl GameData {
|
||||
/// Loads the built-in data, overlaid with whatever is under `dir`.
|
||||
pub fn load(dir: Option<&Path>) -> Result<GameData, String> {
|
||||
let mut files = FileSet::embedded();
|
||||
Self::load_from(dir, true)
|
||||
}
|
||||
|
||||
/// As `load`, optionally without the built-in data (used when
|
||||
/// converting original disks, so the old assets do not shadow them).
|
||||
pub fn load_from(dir: Option<&Path>, built_in: bool) -> Result<GameData, String> {
|
||||
let mut files = if built_in {
|
||||
FileSet::embedded()
|
||||
} else {
|
||||
FileSet::default()
|
||||
};
|
||||
if let Some(dir) = dir {
|
||||
files.scan(dir);
|
||||
}
|
||||
@@ -205,6 +223,7 @@ impl GameData {
|
||||
let Some(id) = key
|
||||
.strip_prefix("vehicles/")
|
||||
.and_then(|n| n.strip_suffix(".png"))
|
||||
.filter(|n| !n.ends_with("-menu"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -212,7 +231,14 @@ impl GameData {
|
||||
.get(&format!("vehicles/{id}.txt"))
|
||||
.map(String::from_utf8_lossy);
|
||||
match Vehicle::from_assets(id, data, txt.as_deref()) {
|
||||
Ok(v) => vehicles.push(v),
|
||||
Ok(mut v) => {
|
||||
if let Some(png) = files.get(&format!("vehicles/{id}-menu.png")) {
|
||||
if let Err(e) = v.set_menu_png(png) {
|
||||
warnings.push(format!("vehicles/{id}-menu.png: {e}"));
|
||||
}
|
||||
}
|
||||
vehicles.push(v)
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -227,7 +253,14 @@ impl GameData {
|
||||
continue;
|
||||
}
|
||||
match Vehicle::parse(name, data) {
|
||||
Ok(Some(v)) => vehicles.push(v),
|
||||
Ok(Some(mut v)) => {
|
||||
if let Some(set) = files.get(&format!("vehicles/{}.set", v.id)) {
|
||||
if let Err(e) = v.apply_set(set) {
|
||||
warnings.push(format!("vehicles/{}.set: {e}", v.id));
|
||||
}
|
||||
}
|
||||
vehicles.push(v)
|
||||
}
|
||||
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
@@ -248,10 +281,14 @@ impl GameData {
|
||||
}
|
||||
));
|
||||
}
|
||||
let (sounds, music, pictures) = load_media(&files, &mut warnings);
|
||||
Ok(GameData {
|
||||
tracks,
|
||||
track_files,
|
||||
vehicles,
|
||||
sounds,
|
||||
music,
|
||||
pictures,
|
||||
files,
|
||||
warnings,
|
||||
})
|
||||
@@ -274,6 +311,105 @@ impl GameData {
|
||||
}
|
||||
}
|
||||
|
||||
type Media = (
|
||||
BTreeMap<String, Sound>,
|
||||
Option<(String, Vec<u8>)>,
|
||||
BTreeMap<String, Picture>,
|
||||
);
|
||||
|
||||
/// Sounds, music and pictures. Assets (`sounds/*.wav`, `music/*.med`,
|
||||
/// `gfx/*.png`) win over the originals (`sfx/` and `gfx/` IFF files, and the
|
||||
/// CrunchMania-packed tune).
|
||||
fn load_media(files: &FileSet, warnings: &mut Vec<String>) -> Media {
|
||||
let stem = |key: &str, dir: &str, ext: &str| -> Option<String> {
|
||||
let name = key.strip_prefix(dir)?.strip_prefix('/')?;
|
||||
name.strip_suffix(ext).map(str::to_string)
|
||||
};
|
||||
let mut sounds = BTreeMap::new();
|
||||
// Lowest priority first; later inserts replace earlier ones.
|
||||
for dir in ["gfx", "sfx"] {
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, dir, ".iff") {
|
||||
if iff::is_8svx(data) {
|
||||
match iff::read_8svx(data) {
|
||||
Ok(s) => {
|
||||
sounds.insert(name, s);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "sounds", ".wav") {
|
||||
match iff::wav_to_sound(data) {
|
||||
Ok(s) => {
|
||||
sounds.insert(name, s);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut music = None;
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "sfx", ".crn") {
|
||||
match unpack(data) {
|
||||
Ok(d) if med::is_med(&d) => music = Some((name, d)),
|
||||
Ok(_) => warnings.push(format!("{key}: not an OctaMED module")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((key, data)) = files
|
||||
.files
|
||||
.iter()
|
||||
.find(|(k, _)| k.starts_with("music/") && k.ends_with(".med"))
|
||||
{
|
||||
music = Some((stem(key, "music", ".med").unwrap_or_default(), data.clone()));
|
||||
}
|
||||
|
||||
let mut pictures = BTreeMap::new();
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "gfx", ".iff") {
|
||||
if data.get(8..12) == Some(b"ILBM") {
|
||||
match iff::read_ilbm(data) {
|
||||
Ok(p) => {
|
||||
pictures.insert(name, p);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "gfx", ".png") {
|
||||
match image::read_indexed(data, &palette::TRACK_DEFAULT) {
|
||||
Ok(img) => {
|
||||
let palette = img
|
||||
.palette
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| (c.0, c.1, c.2))
|
||||
.collect();
|
||||
pictures.insert(
|
||||
name,
|
||||
Picture {
|
||||
w: img.w,
|
||||
h: img.h,
|
||||
pixels: img.pixels,
|
||||
palette,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(sounds, music, pictures)
|
||||
}
|
||||
|
||||
/// Sort key that orders TRACK2-10 after TRACK2-9.
|
||||
fn natural_key(name: &str) -> Vec<(String, u64)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
+3
-5
@@ -1,10 +1,8 @@
|
||||
//! 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.
|
||||
//! Each original track file carries the 16-colour palette its six tracks
|
||||
//! share (see `track.rs`). `TRACK_DEFAULT` is only a fallback for track
|
||||
//! pictures that arrive without one: indices 0-7 scenery, 8-15 road.
|
||||
//!
|
||||
//! Each track asset's PNG carries its own palette, so the usual way to
|
||||
//! recolour a track is to edit that. Palettes can also be dropped in as text
|
||||
|
||||
+36
-3
@@ -3,6 +3,10 @@
|
||||
//! 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.
|
||||
//! The last 142 bytes of section 0 are: BE16 file number, the 16
|
||||
//! colour palette shared by all six tracks as 48 BE16 values (R, G, B
|
||||
//! per colour, 0-15 each), 12 bytes of unknown settings, then the
|
||||
//! name of the set of tracks, NUL-terminated.
|
||||
//!
|
||||
//! Each track section (281240 bytes once decrunched):
|
||||
//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels
|
||||
@@ -49,6 +53,8 @@ pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool {
|
||||
pub struct TrackFile {
|
||||
pub name: String,
|
||||
pub number: u32,
|
||||
pub palette: [Rgb; 16],
|
||||
pub set_name: String,
|
||||
/// Packed track sections (index 0 = first playable track).
|
||||
pub sections: Vec<Vec<u8>>,
|
||||
}
|
||||
@@ -67,6 +73,8 @@ impl Gate {
|
||||
|
||||
pub struct Track {
|
||||
pub name: String,
|
||||
/// The set of six tracks this one belongs to.
|
||||
pub set_name: String,
|
||||
/// Palette indices, W x H.
|
||||
pub pixels: Vec<u8>,
|
||||
pub palette: [Rgb; 16],
|
||||
@@ -104,9 +112,13 @@ impl TrackFile {
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit())
|
||||
.parse()
|
||||
.unwrap_or(u32::MAX);
|
||||
let (palette, set_name) = file_header(&d[SECTIONS * 4..ends[0]])
|
||||
.unwrap_or((palette::TRACK_DEFAULT, String::new()));
|
||||
Ok(TrackFile {
|
||||
name: name.to_string(),
|
||||
number,
|
||||
palette,
|
||||
set_name,
|
||||
sections,
|
||||
})
|
||||
}
|
||||
@@ -117,10 +129,26 @@ impl TrackFile {
|
||||
|
||||
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)
|
||||
let mut t = Track::from_section(self.track_name(section), &raw)?;
|
||||
t.palette = self.palette;
|
||||
t.set_name = self.set_name.clone();
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette and set name from the tail of section 0.
|
||||
fn file_header(s: &[u8]) -> Option<([Rgb; 16], String)> {
|
||||
let tail = s.get(s.len().checked_sub(142)?..)?;
|
||||
let mut pal = [Rgb(0, 0, 0); 16];
|
||||
for (i, c) in pal.iter_mut().enumerate() {
|
||||
let v = |k: usize| (be16(tail, 2 + (i * 3 + k) * 2).min(15) * 17) as u8;
|
||||
*c = Rgb(v(0), v(1), v(2));
|
||||
}
|
||||
let name = &tail[110..];
|
||||
let end = name.iter().position(|&b| b == 0).unwrap_or(name.len());
|
||||
Some((pal, crate::formats::latin1(&name[..end]).trim().to_string()))
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn from_section(name: String, d: &[u8]) -> Result<Track, String> {
|
||||
if d.len() < SECTION_SIZE {
|
||||
@@ -168,6 +196,7 @@ impl Track {
|
||||
name,
|
||||
pixels,
|
||||
palette: palette::TRACK_DEFAULT,
|
||||
set_name: String::new(),
|
||||
height,
|
||||
gates,
|
||||
start,
|
||||
@@ -236,6 +265,7 @@ impl Track {
|
||||
let mut gates = Vec::new();
|
||||
let mut start = (0.0, 0.0);
|
||||
let mut flags = 0;
|
||||
let mut set_name = String::new();
|
||||
let mut height = Vec::with_capacity(HM_W * HM_H);
|
||||
let mut in_height = false;
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
@@ -287,6 +317,7 @@ impl Track {
|
||||
.ok_or_else(|| err("bad flags"))?
|
||||
}
|
||||
"height" => in_height = true,
|
||||
"set" => set_name = line[3..].trim().to_string(),
|
||||
other => return Err(err(&format!("unknown entry `{other}`"))),
|
||||
}
|
||||
}
|
||||
@@ -304,6 +335,7 @@ impl Track {
|
||||
name: name.to_string(),
|
||||
pixels: img.pixels,
|
||||
palette: pal,
|
||||
set_name,
|
||||
height,
|
||||
gates,
|
||||
start,
|
||||
@@ -331,10 +363,11 @@ impl Track {
|
||||
t.push_str(&format!("gate {} {} {} {}\n", g.a.0, g.a.1, g.b.0, g.b.1));
|
||||
}
|
||||
t.push_str(&format!(
|
||||
"\n# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
|
||||
"\n# Name of the set of tracks this one belongs to, shown in the menu.\nset {}\n\n\
|
||||
# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
|
||||
# height: 64 rows of 64 values, ground height x 256. Each value covers\n\
|
||||
# 16x8 pixels of the picture.\nheight\n",
|
||||
self.start.0, self.start.1, self.flags
|
||||
self.set_name, self.start.0, self.start.1, self.flags
|
||||
));
|
||||
for row in self.height.chunks(HM_W) {
|
||||
let line: Vec<String> = row.iter().map(|v| v.to_string()).collect();
|
||||
|
||||
+114
-17
@@ -6,6 +6,11 @@
|
||||
//! Its colours are only a preview; cars are recoloured per player. The text
|
||||
//! file holds the display name and handling.
|
||||
//!
|
||||
//! Settings (`vehicles/*.set`, disk 1): car name and driver name, each
|
||||
//! ending in a newline, then 544 bytes: 16 BE16 values (11 used; their
|
||||
//! meaning lives in the executable and is not decoded yet) and a 64x32
|
||||
//! two-colour-plane picture for the menu, plane 0 then plane 1.
|
||||
//!
|
||||
//! Original data (`vehicles/*.aga`):
|
||||
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes.
|
||||
//! Each frame is a 64x32 AGA sprite with two bitplanes stored per line
|
||||
@@ -135,6 +140,13 @@ pub fn default_spec(id: &str) -> Spec {
|
||||
pub struct Vehicle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub driver: String,
|
||||
/// Towed behind cars rather than driven (the caravan).
|
||||
pub trailer: bool,
|
||||
/// The 16 values from the original `.set` file, kept for reference.
|
||||
pub original: Vec<u16>,
|
||||
/// 64x32 menu picture (indices 0-3), from the `.set` file.
|
||||
pub menu_picture: Option<Vec<u8>>,
|
||||
pub spec: Spec,
|
||||
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
|
||||
pub frames: Vec<u8>,
|
||||
@@ -166,12 +178,58 @@ impl Vehicle {
|
||||
let id = file.trim_end_matches(".aga").to_lowercase();
|
||||
Ok(Some(Vehicle {
|
||||
name: display_name(&id),
|
||||
driver: String::new(),
|
||||
trailer: id == "caravan",
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: default_spec(&id),
|
||||
id,
|
||||
frames,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Applies an original `.set` file.
|
||||
pub fn apply_set(&mut self, d: &[u8]) -> Result<(), String> {
|
||||
let nl = |from: usize| d[from..].iter().position(|&b| b == b'\n').map(|p| p + from);
|
||||
let a = nl(0).ok_or("no name line")?;
|
||||
let b = nl(a + 1).ok_or("no driver line")?;
|
||||
let body = &d[b + 1..];
|
||||
if body.len() < 32 + 512 {
|
||||
return Err(format!("{} bytes of settings, expected 544", body.len()));
|
||||
}
|
||||
let name = crate::formats::latin1(&d[..a]).trim().to_uppercase();
|
||||
if !matches!(self.id.as_str(), "f1" | "vw") && !name.is_empty() {
|
||||
self.name = name;
|
||||
}
|
||||
self.driver = crate::formats::latin1(&d[a + 1..b]).trim().to_uppercase();
|
||||
self.original = (0..16).map(|i| crate::formats::be16(body, i * 2)).collect();
|
||||
let pic = &body[32..32 + 512];
|
||||
let mut px = vec![0u8; SPR_W * SPR_H];
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (byte, mask) = (i / 8, 0x80 >> (i % 8));
|
||||
*p = (pic[byte] & mask != 0) as u8 | (((pic[256 + byte] & mask) != 0) as u8) << 1;
|
||||
}
|
||||
self.menu_picture = Some(px);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn menu_png(&self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.menu_picture
|
||||
.as_ref()
|
||||
.map(|px| image::write_indexed(SPR_W, SPR_H, &preview_palette(), true, px))
|
||||
}
|
||||
|
||||
pub fn set_menu_png(&mut self, png: &[u8]) -> Result<(), String> {
|
||||
let img = image::read_indexed(png, &preview_palette())?;
|
||||
if (img.w, img.h) != (SPR_W, SPR_H) || img.pixels.iter().any(|&p| p > 3) {
|
||||
return Err(format!(
|
||||
"menu picture must be {SPR_W}x{SPR_H} using colours 0-3"
|
||||
));
|
||||
}
|
||||
self.menu_picture = Some(img.pixels);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads an editable asset: sprite sheet PNG plus optional settings text.
|
||||
pub fn from_assets(id: &str, png: &[u8], txt: Option<&str>) -> Result<Vehicle, String> {
|
||||
let img = image::read_indexed(png, &preview_palette())?;
|
||||
@@ -198,6 +256,10 @@ impl Vehicle {
|
||||
let mut v = Vehicle {
|
||||
id: id.to_string(),
|
||||
name: display_name(id),
|
||||
driver: String::new(),
|
||||
trailer: id == "caravan",
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: default_spec(id),
|
||||
frames,
|
||||
};
|
||||
@@ -227,6 +289,13 @@ impl Vehicle {
|
||||
"# Skids vehicle settings. Lines starting with # are comments.\n\
|
||||
# The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\
|
||||
name {name}\n\
|
||||
driver {driver}\n\
|
||||
# yes = towed behind cars (Caravans option) instead of driven\n\
|
||||
trailer {trailer}\n\
|
||||
# The 16 values from the original .set file. Their meaning is not\n\
|
||||
# decoded yet, so the game does not use them; the handling below is\n\
|
||||
# a reconstruction.\n\
|
||||
original {original}\n\
|
||||
# top speed on tarmac, world pixels per second\n\
|
||||
top {top}\n\
|
||||
# acceleration and braking, world pixels per second squared\n\
|
||||
@@ -242,6 +311,14 @@ impl Vehicle {
|
||||
offroad {offroad}\n",
|
||||
id = self.id,
|
||||
name = self.name,
|
||||
driver = self.driver,
|
||||
trailer = if self.trailer { "yes" } else { "no" },
|
||||
original = self
|
||||
.original
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
top = s.top,
|
||||
accel = s.accel,
|
||||
brake = s.brake,
|
||||
@@ -255,16 +332,32 @@ impl Vehicle {
|
||||
fn apply_settings(&mut self, txt: &str) -> Result<(), String> {
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
let Some((key, val)) = line.split_once(char::is_whitespace) else {
|
||||
if line.is_empty() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (key, val) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let val = val.trim();
|
||||
match key {
|
||||
"name" => {
|
||||
self.name = val.to_uppercase();
|
||||
continue;
|
||||
}
|
||||
return Err(format!("line {}: expected `key value`", n + 1));
|
||||
};
|
||||
let val = val.trim();
|
||||
if key == "name" {
|
||||
self.name = val.to_uppercase();
|
||||
continue;
|
||||
"driver" => {
|
||||
self.driver = val.to_uppercase();
|
||||
continue;
|
||||
}
|
||||
"trailer" => {
|
||||
self.trailer = matches!(val, "yes" | "true" | "1");
|
||||
continue;
|
||||
}
|
||||
"original" => {
|
||||
self.original = val
|
||||
.split_whitespace()
|
||||
.filter_map(|x| x.parse().ok())
|
||||
.collect();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let num: f32 = val
|
||||
.parse()
|
||||
@@ -338,24 +431,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let mut v = Vehicle {
|
||||
let blank = |frames| Vehicle {
|
||||
id: "f1".into(),
|
||||
name: "X".into(),
|
||||
name: String::new(),
|
||||
driver: String::new(),
|
||||
trailer: false,
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: BASE_SPEC,
|
||||
frames: vec![0; TILTS * HEADINGS * SPR_W * SPR_H],
|
||||
frames,
|
||||
};
|
||||
let mut v = blank(vec![0; TILTS * HEADINGS * SPR_W * SPR_H]);
|
||||
v.driver = "SIMON".into();
|
||||
v.original = vec![60, 71, 30];
|
||||
v.spec.grip = 6.25;
|
||||
v.name = "FORMULA 1".into();
|
||||
let txt = v.settings_text();
|
||||
let mut w = Vehicle {
|
||||
id: "f1".into(),
|
||||
name: String::new(),
|
||||
spec: BASE_SPEC,
|
||||
frames: Vec::new(),
|
||||
};
|
||||
let mut w = blank(Vec::new());
|
||||
w.apply_settings(&txt).unwrap();
|
||||
assert_eq!(w.spec, v.spec);
|
||||
assert_eq!(w.name, "FORMULA 1");
|
||||
assert_eq!(w.driver, "SIMON");
|
||||
assert_eq!(w.original, v.original);
|
||||
assert!(w.apply_settings("grip fast").is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! IFF readers: 8SVX sounds and ILBM pictures.
|
||||
|
||||
use super::{be16, be32};
|
||||
|
||||
/// An 8-bit signed sample, as stored in an 8SVX `BODY`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Sound {
|
||||
pub rate: u32,
|
||||
pub data: Vec<i8>,
|
||||
/// Loop start in samples, if the sound repeats.
|
||||
pub loop_start: Option<usize>,
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Picture {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
/// Palette indices, w * h.
|
||||
pub pixels: Vec<u8>,
|
||||
/// 8-bit RGB triples.
|
||||
pub palette: Vec<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// (chunk id, chunk body) pairs.
|
||||
type Chunks<'a> = Vec<(&'a [u8], &'a [u8])>;
|
||||
|
||||
fn chunks(d: &[u8]) -> Option<(&[u8], Chunks<'_>)> {
|
||||
if d.len() < 12 || &d[0..4] != b"FORM" {
|
||||
return None;
|
||||
}
|
||||
let end = (be32(d, 4) as usize + 8).min(d.len());
|
||||
let form = &d[8..12];
|
||||
let mut out = Vec::new();
|
||||
let mut p = 12;
|
||||
while p + 8 <= end {
|
||||
let id = &d[p..p + 4];
|
||||
let n = be32(d, p + 4) as usize;
|
||||
let body = d.get(p + 8..p + 8 + n)?;
|
||||
out.push((id, body));
|
||||
p += 8 + n + (n & 1);
|
||||
}
|
||||
Some((form, out))
|
||||
}
|
||||
|
||||
pub fn is_8svx(d: &[u8]) -> bool {
|
||||
d.len() >= 12 && &d[0..4] == b"FORM" && &d[8..12] == b"8SVX"
|
||||
}
|
||||
|
||||
pub fn read_8svx(d: &[u8]) -> Result<Sound, String> {
|
||||
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
|
||||
if form != b"8SVX" {
|
||||
return Err("not an 8SVX sound".into());
|
||||
}
|
||||
let vhdr = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"VHDR")
|
||||
.ok_or("no VHDR chunk")?
|
||||
.1;
|
||||
let body = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BODY")
|
||||
.ok_or("no BODY chunk")?
|
||||
.1;
|
||||
if vhdr.len() < 20 {
|
||||
return Err("short VHDR".into());
|
||||
}
|
||||
let one_shot = be32(vhdr, 0) as usize;
|
||||
let repeat = be32(vhdr, 4) as usize;
|
||||
let rate = be16(vhdr, 12) as u32;
|
||||
if vhdr[15] != 0 {
|
||||
return Err("compressed 8SVX is not supported".into());
|
||||
}
|
||||
let volume = (be32(vhdr, 16) as f32 / 65536.0).clamp(0.0, 1.0);
|
||||
// Only the first octave is used; multi-octave sounds store more after it.
|
||||
let len = if one_shot + repeat > 0 {
|
||||
(one_shot + repeat).min(body.len())
|
||||
} else {
|
||||
body.len()
|
||||
};
|
||||
let data = body[..len].iter().map(|&b| b as i8).collect();
|
||||
let loop_start = (repeat > 2).then_some(one_shot);
|
||||
Ok(Sound {
|
||||
rate: rate.max(1000),
|
||||
data,
|
||||
loop_start,
|
||||
volume,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_ilbm(d: &[u8]) -> Result<Picture, String> {
|
||||
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
|
||||
if form != b"ILBM" {
|
||||
return Err("not an ILBM picture".into());
|
||||
}
|
||||
let bmhd = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BMHD")
|
||||
.ok_or("no BMHD chunk")?
|
||||
.1;
|
||||
let body = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BODY")
|
||||
.ok_or("no BODY chunk")?
|
||||
.1;
|
||||
let w = be16(bmhd, 0) as usize;
|
||||
let h = be16(bmhd, 2) as usize;
|
||||
let planes = bmhd[8] as usize;
|
||||
let masking = bmhd[9];
|
||||
let compression = bmhd[10];
|
||||
if planes == 0 || planes > 8 || w == 0 || h == 0 {
|
||||
return Err("unsupported bitmap layout".into());
|
||||
}
|
||||
let palette = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"CMAP")
|
||||
.map(|c| c.1.chunks_exact(3).map(|p| (p[0], p[1], p[2])).collect())
|
||||
.unwrap_or_else(|| {
|
||||
(0..1u16 << planes)
|
||||
.map(|i| {
|
||||
let v = (i * 255 / ((1 << planes) - 1)) as u8;
|
||||
(v, v, v)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let row_bytes = w.div_ceil(16) * 2;
|
||||
let rows_per_line = planes + (masking == 1) as usize;
|
||||
let mut raw = Vec::with_capacity(row_bytes * rows_per_line * h);
|
||||
match compression {
|
||||
0 => raw.extend_from_slice(body),
|
||||
1 => {
|
||||
// ByteRun1
|
||||
let mut i = 0;
|
||||
while i < body.len() && raw.len() < row_bytes * rows_per_line * h {
|
||||
let n = body[i] as i8;
|
||||
i += 1;
|
||||
if n >= 0 {
|
||||
let k = n as usize + 1;
|
||||
raw.extend_from_slice(body.get(i..i + k).ok_or("ByteRun1 data ended early")?);
|
||||
i += k;
|
||||
} else if n != -128 {
|
||||
let b = *body.get(i).ok_or("ByteRun1 data ended early")?;
|
||||
raw.extend(std::iter::repeat_n(b, (1 - n as isize) as usize));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown ILBM compression {compression}")),
|
||||
}
|
||||
if raw.len() < row_bytes * rows_per_line * h {
|
||||
return Err("BODY is shorter than the picture".into());
|
||||
}
|
||||
let mut pixels = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
for p in 0..planes {
|
||||
let row = &raw[(y * rows_per_line + p) * row_bytes..][..row_bytes];
|
||||
for x in 0..w {
|
||||
if row[x >> 3] & (0x80 >> (x & 7)) != 0 {
|
||||
pixels[y * w + x] |= 1 << p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Picture {
|
||||
w,
|
||||
h,
|
||||
pixels,
|
||||
palette,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes 8-bit unsigned mono WAV, with the loop start in a `smpl` chunk so
|
||||
/// sample editors keep it.
|
||||
pub fn sound_to_wav(s: &Sound) -> Vec<u8> {
|
||||
let data: Vec<u8> = s.data.iter().map(|&v| (v as i16 + 128) as u8).collect();
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = Vec::new();
|
||||
let mut fmt = Vec::new();
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&s.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&s.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&8u16.to_le_bytes());
|
||||
chunks.push((b"fmt ", fmt));
|
||||
chunks.push((b"data", data));
|
||||
if let Some(start) = s.loop_start {
|
||||
let mut smpl = vec![0u8; 36];
|
||||
smpl[28..32].copy_from_slice(&1u32.to_le_bytes());
|
||||
let mut lp = vec![0u8; 24];
|
||||
lp[8..12].copy_from_slice(&(start as u32).to_le_bytes());
|
||||
lp[12..16].copy_from_slice(&(s.data.len() as u32 - 1).to_le_bytes());
|
||||
smpl.extend(lp);
|
||||
chunks.push((b"smpl", smpl));
|
||||
}
|
||||
let mut out = b"RIFF\0\0\0\0WAVE".to_vec();
|
||||
for (id, body) in chunks {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
if body.len() & 1 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
let n = (out.len() - 8) as u32;
|
||||
out[4..8].copy_from_slice(&n.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads mono or stereo 8/16-bit PCM WAV (stereo is mixed down).
|
||||
pub fn wav_to_sound(d: &[u8]) -> Result<Sound, String> {
|
||||
if d.len() < 12 || &d[0..4] != b"RIFF" || &d[8..12] != b"WAVE" {
|
||||
return Err("not a WAV file".into());
|
||||
}
|
||||
let le16 = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
let le32 = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
||||
let (mut fmt, mut data, mut loop_start) = (None, None, None);
|
||||
let mut p = 12;
|
||||
while p + 8 <= d.len() {
|
||||
let n = le32(p + 4) as usize;
|
||||
let body = p + 8..(p + 8 + n).min(d.len());
|
||||
match &d[p..p + 4] {
|
||||
b"fmt " if n >= 16 => {
|
||||
fmt = Some((le16(p + 8), le16(p + 10), le32(p + 12), le16(p + 22)))
|
||||
}
|
||||
b"data" => data = Some(body),
|
||||
b"smpl" if n >= 60 && le32(p + 8 + 28) > 0 => {
|
||||
loop_start = Some(le32(p + 8 + 36 + 8) as usize)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p += 8 + n + (n & 1);
|
||||
}
|
||||
let (format, channels, rate, bits) = fmt.ok_or("no fmt chunk")?;
|
||||
let body = &d[data.ok_or("no data chunk")?];
|
||||
if format != 1 || channels == 0 || !(bits == 8 || bits == 16) {
|
||||
return Err("only 8- or 16-bit PCM WAV is supported".into());
|
||||
}
|
||||
let step = channels as usize * (bits as usize / 8);
|
||||
let sample = |frame: &[u8]| -> i32 {
|
||||
let mut acc = 0i32;
|
||||
for c in 0..channels as usize {
|
||||
acc += if bits == 8 {
|
||||
(frame[c] as i32 - 128) << 8
|
||||
} else {
|
||||
i16::from_le_bytes([frame[2 * c], frame[2 * c + 1]]) as i32
|
||||
};
|
||||
}
|
||||
acc / channels as i32
|
||||
};
|
||||
let data: Vec<i8> = body
|
||||
.chunks_exact(step)
|
||||
.map(|f| (sample(f) >> 8) as i8)
|
||||
.collect();
|
||||
let loop_start = loop_start.filter(|&s| s + 2 < data.len());
|
||||
Ok(Sound {
|
||||
rate: rate.max(1000),
|
||||
data,
|
||||
loop_start,
|
||||
volume: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wav_round_trip() {
|
||||
let s = Sound {
|
||||
rate: 8363,
|
||||
data: (-50..50).map(|v| v as i8).collect(),
|
||||
loop_start: Some(20),
|
||||
volume: 1.0,
|
||||
};
|
||||
let back = wav_to_sound(&sound_to_wav(&s)).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byterun1_picture() {
|
||||
// 16x1, 1 plane: run of 2 x 0xff.
|
||||
let mut d = b"FORM\0\0\0\0ILBMBMHD\0\0\0\x14".to_vec();
|
||||
let mut bmhd = vec![0u8; 20];
|
||||
bmhd[1] = 16;
|
||||
bmhd[3] = 1;
|
||||
bmhd[8] = 1;
|
||||
bmhd[10] = 1;
|
||||
d.extend(bmhd);
|
||||
d.extend(b"BODY\0\0\0\x02\xff\xff");
|
||||
let n = (d.len() - 8) as u32;
|
||||
d[4..8].copy_from_slice(&n.to_be_bytes());
|
||||
let p = read_ilbm(&d).unwrap();
|
||||
assert_eq!(p.pixels, vec![1; 16]);
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -1,9 +1,11 @@
|
||||
//! 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.
|
||||
//! Supports the stored (`-lh0-`, `-lz4-`) and LZSS/Huffman (`-lh5-`, `-lh6-`,
|
||||
//! `-lh7-`) methods. Every decoded file is checked against the header CRC.
|
||||
//! Entries in other methods, or that fail to decode, carry the reason in
|
||||
//! `data` instead of their contents.
|
||||
|
||||
use super::lzh;
|
||||
use super::{latin1, le16, le32};
|
||||
use std::fmt;
|
||||
|
||||
@@ -28,8 +30,8 @@ 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>>,
|
||||
/// File contents, or why they could not be read.
|
||||
pub data: Result<Vec<u8>, String>,
|
||||
}
|
||||
|
||||
pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
@@ -45,7 +47,9 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
}
|
||||
let method = latin1(method);
|
||||
let mut packed = le32(d, p + 7) as usize;
|
||||
let original = le32(d, p + 11) as usize;
|
||||
let level = d[p + 20];
|
||||
let mut crc = None;
|
||||
|
||||
let mut name = String::new();
|
||||
let mut dir = String::new();
|
||||
@@ -59,6 +63,9 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
return Err(LhaError::Truncated(p));
|
||||
}
|
||||
name = latin1(&d[p + 22..p + 22 + name_len]);
|
||||
if p + 24 + name_len <= d.len() {
|
||||
crc = Some(le16(d, p + 22 + name_len));
|
||||
}
|
||||
let mut q = p + header_len;
|
||||
if level == 1 {
|
||||
// Extended headers follow the base header and are counted
|
||||
@@ -81,6 +88,7 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
if p + header_len > d.len() || header_len < 26 {
|
||||
return Err(LhaError::Truncated(p));
|
||||
}
|
||||
crc = Some(le16(d, p + 21));
|
||||
let mut q = p + 26;
|
||||
let mut next = le16(d, p + 24) as usize;
|
||||
while next != 0 {
|
||||
@@ -101,10 +109,18 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
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,
|
||||
let body = &d[data_start..data_end];
|
||||
let decoded = match method.as_str() {
|
||||
"-lh0-" | "-lz4-" => Ok(body.to_vec()),
|
||||
"-lh5-" => lzh::decode(body, original, 13).map_err(|e| e.to_string()),
|
||||
"-lh6-" => lzh::decode(body, original, 15).map_err(|e| e.to_string()),
|
||||
"-lh7-" => lzh::decode(body, original, 16).map_err(|e| e.to_string()),
|
||||
other => Err(format!("unsupported method {other}")),
|
||||
};
|
||||
let data = decoded.and_then(|v| match crc {
|
||||
Some(c) if lzh::crc16(&v) != c => Err("CRC mismatch".to_string()),
|
||||
_ => Ok(v),
|
||||
});
|
||||
if method != "-lhd-" {
|
||||
out.push(LhaEntry { path, method, data });
|
||||
}
|
||||
@@ -149,7 +165,7 @@ mod tests {
|
||||
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.extend_from_slice(&lzh::crc16(body).to_le_bytes());
|
||||
h[0] = (h.len() - 2) as u8;
|
||||
h.extend_from_slice(body);
|
||||
h
|
||||
@@ -163,7 +179,7 @@ mod tests {
|
||||
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!"[..]));
|
||||
assert_eq!(e[0].data.as_deref(), Ok(&b"hello"[..]));
|
||||
assert_eq!(e[1].data.as_deref(), Ok(&b"world!"[..]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Decoder for the LHA `-lh5-`, `-lh6-` and `-lh7-` methods: LZSS with
|
||||
//! static Huffman blocks, as used by LHA 2.x on the Amiga.
|
||||
//!
|
||||
//! Each block starts with a 16-bit symbol count, then three code-length
|
||||
//! tables: one for the code-length codes (19 symbols), one for literals and
|
||||
//! lengths (510 symbols) and one for match offsets. Codes are canonical,
|
||||
//! assigned in symbol order within each length, and read MSB first.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum LzhError {
|
||||
Truncated,
|
||||
BadTable,
|
||||
BadReference,
|
||||
}
|
||||
|
||||
impl fmt::Display for LzhError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
LzhError::Truncated => "compressed data ended early",
|
||||
LzhError::BadTable => "invalid Huffman table",
|
||||
LzhError::BadReference => "match reaches before the start of the file",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LzhError {}
|
||||
|
||||
const NC: usize = 510;
|
||||
const NT: usize = 19;
|
||||
const TBIT: u32 = 5;
|
||||
const CBIT: u32 = 9;
|
||||
|
||||
struct Bits<'a> {
|
||||
d: &'a [u8],
|
||||
pos: usize,
|
||||
buf: u64,
|
||||
len: u32,
|
||||
}
|
||||
|
||||
impl Bits<'_> {
|
||||
fn fill(&mut self) {
|
||||
while self.len <= 56 {
|
||||
// Reading past the end yields zero bits; callers detect overrun
|
||||
// through `overrun()` once a block has been decoded.
|
||||
let b = self.d.get(self.pos).copied().unwrap_or(0);
|
||||
self.pos += 1;
|
||||
self.buf |= (b as u64) << (56 - self.len);
|
||||
self.len += 8;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&mut self, n: u32) -> u32 {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.fill();
|
||||
(self.buf >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
fn skip(&mut self, n: u32) {
|
||||
self.fill();
|
||||
self.buf <<= n;
|
||||
self.len -= n;
|
||||
}
|
||||
|
||||
fn get(&mut self, n: u32) -> u32 {
|
||||
let v = self.peek(n);
|
||||
self.skip(n);
|
||||
v
|
||||
}
|
||||
|
||||
fn overrun(&self) -> bool {
|
||||
self.pos > self.d.len() + 8
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Huffman decoder built from code lengths.
|
||||
struct Table {
|
||||
/// A table with one symbol consumes no bits.
|
||||
single: Option<u16>,
|
||||
count: [u16; 17],
|
||||
symbols: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn single(sym: u16) -> Table {
|
||||
Table {
|
||||
single: Some(sym),
|
||||
count: [0; 17],
|
||||
symbols: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_lengths(lengths: &[u8]) -> Result<Table, LzhError> {
|
||||
let mut count = [0u16; 17];
|
||||
for &l in lengths {
|
||||
if l as usize > 16 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
count[l as usize] += 1;
|
||||
}
|
||||
count[0] = 0;
|
||||
let mut symbols = Vec::new();
|
||||
for len in 1..=16u8 {
|
||||
for (s, &l) in lengths.iter().enumerate() {
|
||||
if l == len {
|
||||
symbols.push(s as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Over-subscribed code sets are corrupt; incomplete ones are allowed.
|
||||
let mut left = 1i32;
|
||||
for &c in &count[1..] {
|
||||
left = left * 2 - c as i32;
|
||||
if left < 0 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
}
|
||||
Ok(Table {
|
||||
single: None,
|
||||
count,
|
||||
symbols,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(&self, bits: &mut Bits) -> Result<u16, LzhError> {
|
||||
if let Some(s) = self.single {
|
||||
return Ok(s);
|
||||
}
|
||||
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
|
||||
for len in 1..=16 {
|
||||
code |= bits.get(1) as i32;
|
||||
let n = self.count[len] as i32;
|
||||
if code - first < n {
|
||||
return Ok(self.symbols[(index + code - first) as usize]);
|
||||
}
|
||||
index += n;
|
||||
first = (first + n) << 1;
|
||||
code <<= 1;
|
||||
}
|
||||
Err(LzhError::BadTable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the code-length or offset table (`read_pt_len` in LHA).
|
||||
fn read_pt(
|
||||
bits: &mut Bits,
|
||||
nn: usize,
|
||||
nbit: u32,
|
||||
special: Option<usize>,
|
||||
) -> Result<Table, LzhError> {
|
||||
let n = bits.get(nbit) as usize;
|
||||
if n == 0 {
|
||||
return Ok(Table::single(bits.get(nbit) as u16));
|
||||
}
|
||||
if n > nn {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
let mut lens = vec![0u8; nn];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut c = bits.peek(3);
|
||||
if c == 7 {
|
||||
// Lengths of 7 and above: unary extension after the first 3 bits.
|
||||
let mut mask = 1u32 << 12;
|
||||
let look = bits.peek(16);
|
||||
while look & mask != 0 {
|
||||
mask >>= 1;
|
||||
c += 1;
|
||||
if c > 16 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
}
|
||||
bits.skip(c - 3);
|
||||
} else {
|
||||
bits.skip(3);
|
||||
}
|
||||
lens[i] = c as u8;
|
||||
i += 1;
|
||||
if Some(i) == special {
|
||||
let zeros = bits.get(2) as usize;
|
||||
i = (i + zeros).min(nn);
|
||||
}
|
||||
}
|
||||
Table::from_lengths(&lens)
|
||||
}
|
||||
|
||||
/// Reads the literal/length table (`read_c_len` in LHA).
|
||||
fn read_c(bits: &mut Bits, pt: &Table) -> Result<Table, LzhError> {
|
||||
let n = bits.get(CBIT) as usize;
|
||||
if n == 0 {
|
||||
return Ok(Table::single(bits.get(CBIT) as u16));
|
||||
}
|
||||
if n > NC {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
let mut lens = vec![0u8; NC];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
match pt.decode(bits)? {
|
||||
0 => i += 1,
|
||||
1 => i += bits.get(4) as usize + 3,
|
||||
2 => i += bits.get(CBIT) as usize + 20,
|
||||
c => {
|
||||
lens[i] = (c - 2) as u8;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if i > NC {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
Table::from_lengths(&lens)
|
||||
}
|
||||
|
||||
/// Decodes `original_size` bytes. `dict_bits` is 13 for lh5, 15 for lh6
|
||||
/// and 16 for lh7.
|
||||
pub fn decode(d: &[u8], original_size: usize, dict_bits: u32) -> Result<Vec<u8>, LzhError> {
|
||||
let np = dict_bits as usize + 1;
|
||||
let pbit = if dict_bits >= 15 { 5 } else { 4 };
|
||||
let mut bits = Bits {
|
||||
d,
|
||||
pos: 0,
|
||||
buf: 0,
|
||||
len: 0,
|
||||
};
|
||||
let mut out = Vec::with_capacity(original_size);
|
||||
while out.len() < original_size {
|
||||
let mut left = bits.get(16);
|
||||
if left == 0 {
|
||||
return Err(LzhError::Truncated);
|
||||
}
|
||||
let pt = read_pt(&mut bits, NT, TBIT, Some(3))?;
|
||||
let c = read_c(&mut bits, &pt)?;
|
||||
let p = read_pt(&mut bits, np, pbit, None)?;
|
||||
while left > 0 && out.len() < original_size {
|
||||
left -= 1;
|
||||
let sym = c.decode(&mut bits)? as usize;
|
||||
if sym < 256 {
|
||||
out.push(sym as u8);
|
||||
continue;
|
||||
}
|
||||
let length = sym - 256 + 3;
|
||||
let mut dist = p.decode(&mut bits)? as u32;
|
||||
if dist > 1 {
|
||||
dist = (1 << (dist - 1)) + bits.get(dist - 1);
|
||||
}
|
||||
let dist = dist as usize + 1;
|
||||
if dist > out.len() {
|
||||
return Err(LzhError::BadReference);
|
||||
}
|
||||
let start = out.len() - dist;
|
||||
for k in 0..length.min(original_size - out.len()) {
|
||||
out.push(out[start + k]);
|
||||
}
|
||||
}
|
||||
if bits.overrun() {
|
||||
return Err(LzhError::Truncated);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-16 as used in LHA headers (polynomial 0xA001, reflected, init 0).
|
||||
pub fn crc16(d: &[u8]) -> u16 {
|
||||
let mut crc = 0u16;
|
||||
for &b in d {
|
||||
crc ^= b as u16;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 {
|
||||
(crc >> 1) ^ 0xa001
|
||||
} else {
|
||||
crc >> 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_check_value() {
|
||||
// Standard check value for CRC-16/ARC.
|
||||
assert_eq!(crc16(b"123456789"), 0xbb3d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_decode() {
|
||||
// Lengths a=1, b=2, c=2 give codes 0, 10, 11.
|
||||
let t = Table::from_lengths(&[1, 2, 2]).unwrap();
|
||||
let data = [0b0101_1000u8];
|
||||
let mut bits = Bits {
|
||||
d: &data,
|
||||
pos: 0,
|
||||
buf: 0,
|
||||
len: 0,
|
||||
};
|
||||
assert_eq!(t.decode(&mut bits), Ok(0));
|
||||
assert_eq!(t.decode(&mut bits), Ok(1));
|
||||
assert_eq!(t.decode(&mut bits), Ok(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! OctaMED module reader (MMD0 and MMD1, 8-bit sample instruments).
|
||||
//!
|
||||
//! Layout notes (all offsets big-endian, pointers relative to file start):
|
||||
//! 0 "MMD0"/"MMD1", 8 song struct, 16 block table, 24 sample table.
|
||||
//! Song struct: 63 x 8-byte sample entries (repeat, repeat length in words,
|
||||
//! MIDI channel, preset, volume, transpose), then block count, sequence
|
||||
//! length, 256-byte play sequence, default tempo, transpose, flags, flags2,
|
||||
//! secondary tempo (ticks per line), 16 track volumes, master volume,
|
||||
//! sample count. MMD0 blocks have a 2-byte header and 3-byte notes; MMD1
|
||||
//! blocks have an 8-byte header and 4-byte notes.
|
||||
|
||||
use super::{be16, be32};
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq)]
|
||||
pub struct Note {
|
||||
/// 0 = none, 1 = C-1 ...
|
||||
pub note: u8,
|
||||
/// 0 = none, else instrument number (1-based).
|
||||
pub instr: u8,
|
||||
pub cmd: u8,
|
||||
pub param: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Instrument {
|
||||
pub data: Vec<i8>,
|
||||
pub loop_start: usize,
|
||||
pub loop_len: usize,
|
||||
pub volume: u8,
|
||||
pub transpose: i8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Block {
|
||||
pub tracks: usize,
|
||||
pub lines: usize,
|
||||
pub notes: Vec<Note>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Song {
|
||||
pub instruments: Vec<Instrument>,
|
||||
pub blocks: Vec<Block>,
|
||||
pub sequence: Vec<usize>,
|
||||
/// Lines per minute is derived from these by the player.
|
||||
pub tempo: u16,
|
||||
pub speed: u8,
|
||||
pub bpm_mode: bool,
|
||||
pub lines_per_beat: u8,
|
||||
pub volume_hex: bool,
|
||||
pub transpose: i8,
|
||||
pub track_volume: [u8; 16],
|
||||
pub master_volume: u8,
|
||||
}
|
||||
|
||||
pub fn is_med(d: &[u8]) -> bool {
|
||||
d.len() > 52 && (&d[0..4] == b"MMD0" || &d[0..4] == b"MMD1")
|
||||
}
|
||||
|
||||
pub fn parse(d: &[u8]) -> Result<Song, String> {
|
||||
if !is_med(d) {
|
||||
return Err("not an OctaMED MMD0/MMD1 module".into());
|
||||
}
|
||||
let mmd1 = d[3] == b'1';
|
||||
let get = |o: usize, n: usize| {
|
||||
d.get(o..o + n)
|
||||
.ok_or_else(|| format!("offset {o} is outside the module"))
|
||||
};
|
||||
let song = be32(d, 8) as usize;
|
||||
let blockarr = be32(d, 16) as usize;
|
||||
let smplarr = be32(d, 24) as usize;
|
||||
let s = get(song, 788)?;
|
||||
let nblocks = be16(s, 504) as usize;
|
||||
let songlen = (be16(s, 506) as usize).min(256);
|
||||
let sequence: Vec<usize> = s[508..508 + songlen].iter().map(|&b| b as usize).collect();
|
||||
let tempo = be16(s, 764);
|
||||
let transpose = s[766] as i8;
|
||||
let flags = s[767];
|
||||
let flags2 = s[768];
|
||||
let speed = s[769].max(1);
|
||||
let mut track_volume = [64u8; 16];
|
||||
track_volume.copy_from_slice(&s[770..786]);
|
||||
let master_volume = s[786];
|
||||
let nsamples = (s[787] as usize).min(63);
|
||||
|
||||
let mut instruments = Vec::new();
|
||||
for i in 0..nsamples {
|
||||
let e = &s[i * 8..i * 8 + 8];
|
||||
let (rep, replen) = (be16(e, 0) as usize * 2, be16(e, 2) as usize * 2);
|
||||
let mut inst = Instrument {
|
||||
volume: e[6].min(64),
|
||||
transpose: e[7] as i8,
|
||||
..Default::default()
|
||||
};
|
||||
let p = if smplarr != 0 {
|
||||
be32(get(smplarr + i * 4, 4)?, 0) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if p != 0 {
|
||||
let h = get(p, 6)?;
|
||||
let len = be32(h, 0) as usize;
|
||||
let kind = be16(h, 4) as i16;
|
||||
if kind != 0 {
|
||||
return Err(format!("instrument {} is a synth or multi-octave sample (type {kind}), which is not supported", i + 1));
|
||||
}
|
||||
inst.data = get(p + 6, len)?.iter().map(|&b| b as i8).collect();
|
||||
if replen > 2 && rep < len {
|
||||
inst.loop_start = rep;
|
||||
inst.loop_len = replen.min(len - rep);
|
||||
}
|
||||
}
|
||||
instruments.push(inst);
|
||||
}
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
for b in 0..nblocks {
|
||||
let bp = be32(get(blockarr + b * 4, 4)?, 0) as usize;
|
||||
let (tracks, lines, header, width) = if mmd1 {
|
||||
let h = get(bp, 8)?;
|
||||
(be16(h, 0) as usize, be16(h, 2) as usize + 1, 8, 4)
|
||||
} else {
|
||||
let h = get(bp, 2)?;
|
||||
(h[0] as usize, h[1] as usize + 1, 2, 3)
|
||||
};
|
||||
if tracks == 0 || tracks > 64 {
|
||||
return Err(format!("block {b} has {tracks} tracks"));
|
||||
}
|
||||
let body = get(bp + header, tracks * lines * width)?;
|
||||
let notes = body
|
||||
.chunks_exact(width)
|
||||
.map(|c| {
|
||||
if mmd1 {
|
||||
Note {
|
||||
note: c[0] & 0x7f,
|
||||
instr: c[1] & 0x3f,
|
||||
cmd: c[2],
|
||||
param: c[3],
|
||||
}
|
||||
} else {
|
||||
// MMD0: nnnnnnn i | iiii cccc | pppppppp
|
||||
let instr = ((c[0] & 0x80) >> 3) | (c[1] >> 4);
|
||||
Note {
|
||||
note: c[0] & 0x3f,
|
||||
instr,
|
||||
cmd: c[1] & 0x0f,
|
||||
param: c[2],
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
blocks.push(Block {
|
||||
tracks,
|
||||
lines,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
if sequence.iter().any(|&b| b >= blocks.len()) || sequence.is_empty() {
|
||||
return Err("play sequence refers to a missing block".into());
|
||||
}
|
||||
Ok(Song {
|
||||
instruments,
|
||||
blocks,
|
||||
sequence,
|
||||
tempo,
|
||||
speed,
|
||||
bpm_mode: flags2 & 0x20 != 0,
|
||||
lines_per_beat: (flags2 & 0x1f) + 1,
|
||||
volume_hex: flags & 0x10 != 0,
|
||||
transpose,
|
||||
track_volume,
|
||||
master_volume,
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
pub mod adf;
|
||||
pub mod crm;
|
||||
pub mod iff;
|
||||
pub mod lha;
|
||||
pub mod lzh;
|
||||
pub mod med;
|
||||
|
||||
pub(crate) fn be16(d: &[u8], off: usize) -> u16 {
|
||||
u16::from_be_bytes([d[off], d[off + 1]])
|
||||
|
||||
+5
-13
@@ -17,8 +17,6 @@ pub struct Ai {
|
||||
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,
|
||||
@@ -29,7 +27,6 @@ impl Ai {
|
||||
Ai {
|
||||
line,
|
||||
pace,
|
||||
seg: None,
|
||||
stuck_for: 0.0,
|
||||
blocked_for: 0.0,
|
||||
reversing: 0.0,
|
||||
@@ -46,16 +43,12 @@ impl Ai {
|
||||
/// 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;
|
||||
}
|
||||
// Only the stretch into the gate the lap counter needs next, or the
|
||||
// one after it (crossing counts with one gate skipped), so a car
|
||||
// knocked backwards turns round for the gate it missed.
|
||||
let seg = next_gate;
|
||||
let mut best = (f32::MAX, seg, 0.0f32);
|
||||
for k in 0..4 {
|
||||
for k in 0..2 {
|
||||
let i = (seg + k) % n;
|
||||
let a = self.point(&gates[(i + n - 1) % n]);
|
||||
let b = self.point(&gates[i]);
|
||||
@@ -68,7 +61,6 @@ impl Ai {
|
||||
}
|
||||
}
|
||||
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]);
|
||||
|
||||
+135
-6
@@ -32,6 +32,59 @@ pub struct Entrant {
|
||||
pub best_lap: Option<f32>,
|
||||
pub last_lap: Option<f32>,
|
||||
pub finished: Option<f32>,
|
||||
pub trailer: Option<Trailer>,
|
||||
/// Race time when this entrant last crossed a gate.
|
||||
pub progress_at: f32,
|
||||
}
|
||||
|
||||
/// A caravan on a tow bar behind a car.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Trailer {
|
||||
pub vehicle: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub heading: f32,
|
||||
}
|
||||
|
||||
/// Hitch distance behind the car's centre, and tow bar length to the
|
||||
/// trailer's centre, in world units.
|
||||
const HITCH: f32 = 12.0;
|
||||
const TOW_BAR: f32 = 20.0;
|
||||
const TRAILER_RADIUS: f32 = 10.0;
|
||||
/// Seconds without crossing a gate before a computer car is rescued.
|
||||
const RESCUE_AFTER: f32 = 8.0;
|
||||
|
||||
fn rescue(e: &mut Entrant, gates: &[Gate]) {
|
||||
let n = gates.len();
|
||||
let from = gates[(e.next_gate + n - 1) % n].mid();
|
||||
let to = gates[e.next_gate].mid();
|
||||
let heading = (to.1 - from.1).atan2(to.0 - from.0);
|
||||
let c = &mut e.car;
|
||||
c.x = from.0;
|
||||
c.y = from.1;
|
||||
c.vx = 0.0;
|
||||
c.vy = 0.0;
|
||||
c.heading = heading.rem_euclid(std::f32::consts::TAU);
|
||||
if let Some(t) = &mut e.trailer {
|
||||
let back = HITCH + TOW_BAR;
|
||||
t.x = from.0 - heading.cos() * back;
|
||||
t.y = from.1 - heading.sin() * back;
|
||||
t.heading = heading;
|
||||
}
|
||||
}
|
||||
|
||||
impl Trailer {
|
||||
/// Drags the trailer after its car: it keeps its distance from the
|
||||
/// hitch and swings to point at it.
|
||||
fn follow(&mut self, car: &Car) {
|
||||
let (fx, fy) = car.forward();
|
||||
let (hx, hy) = (car.x - fx * HITCH, car.y - fy * HITCH);
|
||||
let (dx, dy) = (self.x - hx, self.y - hy);
|
||||
let d = dx.hypot(dy).max(1e-3);
|
||||
self.x = hx + dx / d * TOW_BAR;
|
||||
self.y = hy + dy / d * TOW_BAR;
|
||||
self.heading = (hy - self.y).atan2(hx - self.x);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entrant {
|
||||
@@ -75,6 +128,8 @@ pub struct EntrantSetup {
|
||||
pub driver: Driver,
|
||||
pub team: usize,
|
||||
pub name: String,
|
||||
/// Vehicle index of a caravan to tow, if any.
|
||||
pub trailer: Option<usize>,
|
||||
}
|
||||
|
||||
impl Race {
|
||||
@@ -97,7 +152,13 @@ impl Race {
|
||||
// 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);
|
||||
// Leave room for a caravan between rows when towing.
|
||||
let spacing = if s.trailer.is_some() {
|
||||
30.0 + HITCH + TOW_BAR + TRAILER_RADIUS
|
||||
} else {
|
||||
30.0
|
||||
};
|
||||
let ((px, py), heading) = behind(&path, 18.0 + row * spacing);
|
||||
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].spec, s.team, x, y, heading);
|
||||
@@ -114,7 +175,23 @@ impl Race {
|
||||
best_lap: None,
|
||||
last_lap: None,
|
||||
finished: None,
|
||||
progress_at: 0.0,
|
||||
trailer: s.trailer.map(|t| {
|
||||
let (fx, fy) = (heading.cos(), heading.sin());
|
||||
let back = HITCH + TOW_BAR;
|
||||
Trailer {
|
||||
vehicle: t,
|
||||
x: x - fx * back,
|
||||
y: y - fy * back,
|
||||
heading,
|
||||
}
|
||||
}),
|
||||
});
|
||||
if let Some(e) = entrants.last_mut().filter(|e| e.trailer.is_some()) {
|
||||
// Towing costs some speed and pick-up.
|
||||
e.car.spec.top *= 0.9;
|
||||
e.car.spec.accel *= 0.8;
|
||||
}
|
||||
}
|
||||
let ground = track.rgb();
|
||||
Race {
|
||||
@@ -153,6 +230,9 @@ impl Race {
|
||||
let racing = self.phase != Phase::Countdown;
|
||||
let rt = self.race_time();
|
||||
let n = self.track.gates.len();
|
||||
// Where every car started the frame: gate crossings are judged on
|
||||
// the whole frame's movement, including knocks from collisions.
|
||||
let starts: Vec<(f32, f32)> = self.entrants.iter().map(|e| (e.car.x, e.car.y)).collect();
|
||||
|
||||
for e in self.entrants.iter_mut() {
|
||||
let controls = match (e.driver, e.finished) {
|
||||
@@ -164,15 +244,37 @@ impl Race {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Caravans follow their cars and shove other cars out of the way.
|
||||
for i in 0..self.entrants.len() {
|
||||
let Some(mut t) = self.entrants[i].trailer else {
|
||||
continue;
|
||||
};
|
||||
t.follow(&self.entrants[i].car);
|
||||
for (j, other) in self.entrants.iter_mut().enumerate() {
|
||||
if j != i {
|
||||
push_off(&mut other.car, t.x, t.y);
|
||||
}
|
||||
}
|
||||
self.entrants[i].trailer = Some(t);
|
||||
}
|
||||
|
||||
for (e, &(px, py)) in self.entrants.iter_mut().zip(&starts) {
|
||||
// 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.progress_at = rt;
|
||||
e.next_gate = (gi + 1) % n;
|
||||
if before == 0 {
|
||||
// First time over the line: the clock starts.
|
||||
@@ -188,10 +290,17 @@ impl Race {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// A computer car that has made no progress for a while is put back
|
||||
// on the racing line at the last gate it crossed.
|
||||
if self.phase == Phase::Racing {
|
||||
for e in self.entrants.iter_mut() {
|
||||
if e.driver == Driver::Cpu
|
||||
&& e.finished.is_none()
|
||||
&& rt - e.progress_at > RESCUE_AFTER
|
||||
{
|
||||
rescue(e, &self.track.gates);
|
||||
e.progress_at = rt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +386,26 @@ impl Car {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a car out of a caravan it has run into, killing the speed it had
|
||||
/// into it.
|
||||
fn push_off(c: &mut Car, tx: f32, ty: f32) {
|
||||
let (dx, dy) = (c.x - tx, c.y - ty);
|
||||
let d = dx.hypot(dy);
|
||||
let min = car::RADIUS + TRAILER_RADIUS;
|
||||
if d >= min || d < 1e-3 {
|
||||
return;
|
||||
}
|
||||
let (nx, ny) = (dx / d, dy / d);
|
||||
c.x += nx * (min - d);
|
||||
c.y += ny * (min - d);
|
||||
let vn = c.vx * nx + c.vy * ny;
|
||||
if vn < 0.0 {
|
||||
c.vx -= vn * nx * 1.3;
|
||||
c.vy -= vn * ny * 1.3;
|
||||
c.bump = c.bump.max((-vn / 150.0).min(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+211
-11
@@ -8,6 +8,7 @@ mod audio;
|
||||
mod data;
|
||||
mod formats;
|
||||
mod game;
|
||||
mod music;
|
||||
mod render;
|
||||
|
||||
use app::{App, Input};
|
||||
@@ -24,15 +25,19 @@ 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] convert the data to editable assets (default ./assets)
|
||||
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu]
|
||||
skids unpack FILE.adf|FILE.lha --out DIR extract every file from a disk image or archive
|
||||
skids music [--data DIR] --out FILE.wav [--seconds S]
|
||||
render the tune to a WAV file
|
||||
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] [--caravans]
|
||||
simulate a race (or the menu) and save a frame
|
||||
skids bench [--data DIR] [--seconds S] race computer cars on every track
|
||||
skids bench [--data DIR] [--seconds S] [--caravans]
|
||||
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 game data is built into the executable, so no disks are needed. --data
|
||||
adds to or overrides it with ADF images, LHA archives, or extracted track/,
|
||||
vehicles/ and palettes/ folders (e.g. disk 1, or real palettes). Without
|
||||
vehicles/ and palettes/ folders, or an assets folder of edits. Without
|
||||
--data, a folder like that in the current directory or next to the
|
||||
executable is picked up automatically.
|
||||
|
||||
@@ -51,6 +56,7 @@ struct Args {
|
||||
seconds: f32,
|
||||
players: usize,
|
||||
menu: bool,
|
||||
caravans: bool,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
@@ -64,6 +70,7 @@ fn parse_args() -> Result<Args, String> {
|
||||
seconds: 12.0,
|
||||
players: 1,
|
||||
menu: false,
|
||||
caravans: false,
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
@@ -78,7 +85,12 @@ fn parse_args() -> Result<Args, String> {
|
||||
"--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,
|
||||
"--caravans" => a.caravans = true,
|
||||
"info" | "extract" | "render" | "bench" | "trace" | "music" | "play" => a.cmd = arg,
|
||||
"unpack" => {
|
||||
a.cmd = arg;
|
||||
a.data = Some(val("unpack")?.into());
|
||||
}
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
}
|
||||
@@ -86,7 +98,7 @@ fn parse_args() -> Result<Args, String> {
|
||||
}
|
||||
|
||||
/// An explicit --data directory, or a nearby folder that looks like it holds
|
||||
/// game data (e.g. disk 1 dropped next to the executable). The built-in data
|
||||
/// game data (e.g. edited assets next to the executable). The built-in data
|
||||
/// is always loaded first, so finding nothing is fine.
|
||||
fn find_data(explicit: Option<PathBuf>) -> Option<PathBuf> {
|
||||
if explicit.is_some() {
|
||||
@@ -111,6 +123,21 @@ fn find_data(explicit: Option<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|d| looks_right(d))
|
||||
}
|
||||
|
||||
/// Sound output using the game's samples and tune.
|
||||
fn make_audio(data: &GameData, enabled: bool) -> audio::Audio {
|
||||
let song = data
|
||||
.music
|
||||
.as_ref()
|
||||
.and_then(|(name, d)| match formats::med::parse(d) {
|
||||
Ok(s) => Some(std::sync::Arc::new(s)),
|
||||
Err(e) => {
|
||||
eprintln!("warning: music {name}: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
audio::Audio::new(enabled, audio::Bank::from_sounds(&data.sounds), song)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = match parse_args() {
|
||||
Ok(a) => a,
|
||||
@@ -126,8 +153,22 @@ fn main() -> ExitCode {
|
||||
};
|
||||
}
|
||||
};
|
||||
if args.cmd == "unpack" {
|
||||
return match unpack(args.data.as_deref().unwrap(), args.out.as_deref()) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
let root = find_data(args.data.clone());
|
||||
let data = match GameData::load(root.as_deref()) {
|
||||
// Converting disks must read only the disks, not the built-in assets.
|
||||
let data = match if args.cmd == "extract" {
|
||||
GameData::load_from(root.as_deref(), false)
|
||||
} else {
|
||||
GameData::load(root.as_deref())
|
||||
} {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
@@ -146,6 +187,7 @@ fn main() -> ExitCode {
|
||||
"render" => render_shot(data, &args),
|
||||
"bench" => bench(data, &args),
|
||||
"trace" => trace(data, &args),
|
||||
"music" => render_music(&data, &args),
|
||||
_ => play(data, &args),
|
||||
};
|
||||
match result {
|
||||
@@ -181,6 +223,54 @@ fn info(data: &GameData, dir: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts every file from an ADF image or LHA archive, unpacking LHA
|
||||
/// archives found on disks. Files keep their stored (packed) form.
|
||||
fn unpack(input: &Path, out: Option<&Path>) -> Result<(), String> {
|
||||
use formats::{adf::Adf, lha};
|
||||
let out = out.ok_or("unpack needs --out DIR")?;
|
||||
let data = std::fs::read(input).map_err(|e| format!("{}: {e}", input.display()))?;
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
if input
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("adf"))
|
||||
{
|
||||
let adf = Adf::open(data).map_err(|e| e.to_string())?;
|
||||
for f in adf.files().map_err(|e| e.to_string())? {
|
||||
files.push((f.path, f.data));
|
||||
}
|
||||
} else {
|
||||
files.push((input.file_name().unwrap().to_string_lossy().into(), data));
|
||||
}
|
||||
let mut failed = 0;
|
||||
while let Some((path, data)) = files.pop() {
|
||||
if path.to_lowercase().ends_with(".lha") {
|
||||
for e in lha::parse(&data).map_err(|e| format!("{path}: {e}"))? {
|
||||
match e.data {
|
||||
Ok(d) => files.push((e.path, d)),
|
||||
Err(why) => {
|
||||
eprintln!("FAILED {} ({}): {why}", e.path, e.method);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Keep paths inside `out`.
|
||||
let safe: PathBuf = path
|
||||
.split(['/', '\\'])
|
||||
.filter(|c| !c.is_empty() && *c != "..")
|
||||
.collect();
|
||||
let dest = out.join(safe);
|
||||
std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&dest, &data).map_err(|e| format!("{}: {e}", dest.display()))?;
|
||||
println!("{:>8} {}", data.len(), path);
|
||||
}
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} files could not be extracted"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes every track and vehicle as editable assets, then reads each one
|
||||
/// back to check the conversion is lossless.
|
||||
fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
|
||||
@@ -220,15 +310,52 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
|
||||
let dir = out.join("vehicles");
|
||||
write(dir.join(format!("{}.png", v.id)), &png)?;
|
||||
write(dir.join(format!("{}.txt", v.id)), txt.as_bytes())?;
|
||||
if let Some(menu) = v.menu_png() {
|
||||
write(dir.join(format!("{}-menu.png", v.id)), &menu?)?;
|
||||
}
|
||||
println!("wrote {}", dir.join(&v.id).display());
|
||||
}
|
||||
for (name, s) in &data.sounds {
|
||||
let wav = formats::iff::sound_to_wav(s);
|
||||
let back = formats::iff::wav_to_sound(&wav)?;
|
||||
if back.data != s.data || back.rate != s.rate || back.loop_start != s.loop_start {
|
||||
return Err(format!("sound {name}: WAV does not round-trip"));
|
||||
}
|
||||
let path = out.join("sounds").join(format!("{name}.wav"));
|
||||
write(path.clone(), &wav)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
if let Some((name, med)) = &data.music {
|
||||
formats::med::parse(med).map_err(|e| format!("music {name}: {e}"))?;
|
||||
let path = out.join("music").join(format!("{name}.med"));
|
||||
write(path.clone(), med)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
for (name, p) in &data.pictures {
|
||||
let pal: Vec<data::palette::Rgb> = p
|
||||
.palette
|
||||
.iter()
|
||||
.map(|c| data::palette::Rgb(c.0, c.1, c.2))
|
||||
.collect();
|
||||
let png = data::image::write_indexed(p.w, p.h, &pal, false, &p.pixels)?;
|
||||
if data::image::read_indexed(&png, &pal)?.pixels != p.pixels {
|
||||
return Err(format!("picture {name}: PNG does not round-trip"));
|
||||
}
|
||||
let path = out.join("gfx").join(format!("{name}.png"));
|
||||
write(path.clone(), &png)?;
|
||||
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));
|
||||
let mut app = {
|
||||
let audio = make_audio(&data, false);
|
||||
App::new(data, audio)
|
||||
};
|
||||
app.settings.caravans = args.caravans;
|
||||
if args.menu {
|
||||
for _ in 0..(args.seconds / game::DT) as usize {
|
||||
app.update(&Input::default());
|
||||
@@ -282,7 +409,11 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
|
||||
/// 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 mut app = {
|
||||
let audio = make_audio(&data, false);
|
||||
App::new(data, audio)
|
||||
};
|
||||
app.settings.caravans = args.caravans;
|
||||
let frames = (args.seconds / game::DT) as usize;
|
||||
println!(
|
||||
"{:<10} {:>5} {:>8} {:>9} {:>8}",
|
||||
@@ -326,11 +457,77 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_music(data: &GameData, args: &Args) -> Result<(), String> {
|
||||
let out = args.out.clone().ok_or("music needs --out FILE.wav")?;
|
||||
let (name, d) = data.music.as_ref().ok_or("no music in the data")?;
|
||||
let song = std::sync::Arc::new(formats::med::parse(d).map_err(|e| format!("{name}: {e}"))?);
|
||||
let rate = 44100u32;
|
||||
let mut player = music::Player::new(song, rate as f64);
|
||||
let frames = (args.seconds * rate as f32) as usize;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
let mut peak = 0.0f32;
|
||||
let mut wrapped_at = None;
|
||||
let mut last_order = 0;
|
||||
for i in 0..frames {
|
||||
let (l, r) = player.next();
|
||||
let order = player.order();
|
||||
if order < last_order && wrapped_at.is_none() {
|
||||
wrapped_at = Some(i as f32 / rate as f32);
|
||||
}
|
||||
last_order = order;
|
||||
peak = peak.max(l.abs()).max(r.abs());
|
||||
for v in [l, r] {
|
||||
pcm.extend_from_slice(&((v.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut wav = b"RIFF\0\0\0\0WAVEfmt ".to_vec();
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
||||
wav.extend_from_slice(&rate.to_le_bytes());
|
||||
wav.extend_from_slice(&(rate * 4).to_le_bytes());
|
||||
wav.extend_from_slice(&4u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(pcm);
|
||||
let n = (wav.len() - 8) as u32;
|
||||
wav[4..8].copy_from_slice(&n.to_le_bytes());
|
||||
std::fs::write(&out, wav).map_err(|e| e.to_string())?;
|
||||
println!(
|
||||
"wrote {} ({:.0} s, peak {:.2}, position {})",
|
||||
out.display(),
|
||||
args.seconds,
|
||||
peak,
|
||||
player.position()
|
||||
);
|
||||
if let Some(t) = wrapped_at {
|
||||
println!(
|
||||
"the song loops back to the start after {}:{:04.1}",
|
||||
(t / 60.0) as u32,
|
||||
t % 60.0
|
||||
);
|
||||
}
|
||||
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 app = {
|
||||
let audio = make_audio(&data, false);
|
||||
App::new(data, audio)
|
||||
};
|
||||
app.settings.caravans = args.caravans;
|
||||
// Same seed as `bench` uses for this track, so problems seen there can be traced.
|
||||
let mut race = app.build_race(
|
||||
args.track.saturating_sub(1),
|
||||
0,
|
||||
[0, 0],
|
||||
4,
|
||||
99,
|
||||
args.track.max(1) as u32,
|
||||
)?;
|
||||
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);
|
||||
@@ -385,7 +582,10 @@ fn play(data: GameData, args: &Args) -> Result<(), String> {
|
||||
.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));
|
||||
let mut app = {
|
||||
let audio = make_audio(&data, !args.mute);
|
||||
App::new(data, audio)
|
||||
};
|
||||
while window.is_open() && !app.quit {
|
||||
let input = Input {
|
||||
held: window.get_keys(),
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
//! OctaMED song playback, mixed in software.
|
||||
//!
|
||||
//! Implements what the game's tune uses: sample instruments with loops,
|
||||
//! slides (1, 2), portamento (3), speed (9), position jump (B), volume (C),
|
||||
//! volume slide (D) and the F group (break, tempo, retrigger, note delay,
|
||||
//! set pitch, stop, note off). Other commands are ignored.
|
||||
|
||||
use crate::formats::med::{Note, Song};
|
||||
use std::sync::Arc;
|
||||
|
||||
const PAULA_CLOCK: f64 = 3_546_895.0;
|
||||
/// Period of note 1 (C-1), as on ProTracker.
|
||||
const C1_PERIOD: f64 = 856.0;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Channel {
|
||||
instr: Option<usize>,
|
||||
pos: f64,
|
||||
period: f64,
|
||||
target: f64,
|
||||
porta_speed: f64,
|
||||
volume: f64,
|
||||
playing: bool,
|
||||
/// Note to start later this line (F2 delay) and retrigger interval (F1/F3).
|
||||
delayed: Option<(Note, u32)>,
|
||||
retrigger: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
song: Arc<Song>,
|
||||
rate: f64,
|
||||
order: usize,
|
||||
line: usize,
|
||||
tick: u32,
|
||||
speed: u32,
|
||||
tempo: u16,
|
||||
tick_left: f64,
|
||||
jump: Option<(usize, usize)>,
|
||||
channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
fn period_for(note: i32) -> f64 {
|
||||
C1_PERIOD * 2f64.powf(-(note - 1) as f64 / 12.0)
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(song: Arc<Song>, rate: f64) -> Player {
|
||||
let tracks = song.blocks.iter().map(|b| b.tracks).max().unwrap_or(4);
|
||||
let speed = song.speed as u32;
|
||||
let tempo = song.tempo;
|
||||
Player {
|
||||
song,
|
||||
rate,
|
||||
order: 0,
|
||||
line: 0,
|
||||
tick: 0,
|
||||
speed,
|
||||
tempo,
|
||||
tick_left: 0.0,
|
||||
jump: None,
|
||||
channels: vec![Channel::default(); tracks],
|
||||
}
|
||||
}
|
||||
|
||||
fn ticks_per_second(&self) -> f64 {
|
||||
let s = &self.song;
|
||||
let bpm = if s.bpm_mode {
|
||||
self.tempo as f64 * s.lines_per_beat as f64 / 4.0
|
||||
} else if self.tempo <= 10 {
|
||||
// SoundTracker-compatible tempo: a plain 50 Hz tick.
|
||||
125.0
|
||||
} else {
|
||||
self.tempo as f64 * 125.0 / 33.0
|
||||
};
|
||||
(bpm * 2.0 / 5.0).max(1.0)
|
||||
}
|
||||
|
||||
fn trigger(&mut self, ch: usize, n: Note) {
|
||||
let song = self.song.clone();
|
||||
let c = &mut self.channels[ch];
|
||||
if n.instr > 0 {
|
||||
let i = n.instr as usize - 1;
|
||||
if i < song.instruments.len() {
|
||||
c.instr = Some(i);
|
||||
c.volume = song.instruments[i].volume as f64;
|
||||
}
|
||||
}
|
||||
if n.note > 0 {
|
||||
let transpose =
|
||||
c.instr.map_or(0, |i| song.instruments[i].transpose as i32) + song.transpose as i32;
|
||||
let p = period_for(n.note as i32 + transpose);
|
||||
if n.cmd == 3 && c.playing {
|
||||
c.target = p;
|
||||
if n.param > 0 {
|
||||
c.porta_speed = n.param as f64;
|
||||
}
|
||||
} else {
|
||||
c.period = p;
|
||||
c.target = p;
|
||||
c.pos = 0.0;
|
||||
c.playing = c.instr.is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_line(&mut self) {
|
||||
let song = self.song.clone();
|
||||
let block = &song.blocks[song.sequence[self.order]];
|
||||
for ch in 0..block.tracks.min(self.channels.len()) {
|
||||
let n = block.notes[self.line * block.tracks + ch];
|
||||
let c = &mut self.channels[ch];
|
||||
c.retrigger = None;
|
||||
c.delayed = None;
|
||||
match (n.cmd, n.param) {
|
||||
(0x0f, 0xf1) => c.retrigger = Some((self.speed / 2).max(1)),
|
||||
(0x0f, 0xf3) => c.retrigger = Some((self.speed / 3).max(1)),
|
||||
(0x0f, 0xf2) => {
|
||||
c.delayed = Some((n, (self.speed / 2).max(1)));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if n.cmd == 0x0f && n.param == 0xfd && n.note > 0 {
|
||||
// Set pitch without restarting the sample.
|
||||
let transpose = c.instr.map_or(0, |i| song.instruments[i].transpose as i32)
|
||||
+ song.transpose as i32;
|
||||
c.period = period_for(n.note as i32 + transpose);
|
||||
c.target = c.period;
|
||||
} else {
|
||||
self.trigger(ch, n);
|
||||
}
|
||||
let c = &mut self.channels[ch];
|
||||
match n.cmd {
|
||||
0x09 if n.param > 0 => self.speed = (n.param as u32).min(32),
|
||||
0x0b => self.jump = Some((n.param as usize, 0)),
|
||||
0x0c => {
|
||||
let v = if self.song.volume_hex {
|
||||
n.param as u32
|
||||
} else {
|
||||
(n.param >> 4) as u32 * 10 + (n.param & 15) as u32
|
||||
};
|
||||
c.volume = v.min(64) as f64;
|
||||
}
|
||||
0x0f => match n.param {
|
||||
0x00 => self.jump = Some((self.order + 1, 0)),
|
||||
0x01..=0xf0 => self.tempo = n.param as u16,
|
||||
0xfe => self.jump = Some((0, 0)),
|
||||
0xff => c.playing = false,
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn effects_tick(&mut self) {
|
||||
let song = self.song.clone();
|
||||
let block = &song.blocks[song.sequence[self.order]];
|
||||
for ch in 0..block.tracks.min(self.channels.len()) {
|
||||
let n = block.notes[self.line * block.tracks + ch];
|
||||
if let Some((note, at)) = self.channels[ch].delayed {
|
||||
if self.tick == at {
|
||||
self.trigger(ch, note);
|
||||
self.channels[ch].delayed = None;
|
||||
}
|
||||
}
|
||||
let c = &mut self.channels[ch];
|
||||
if let Some(every) = c.retrigger {
|
||||
if self.tick.is_multiple_of(every) {
|
||||
c.pos = 0.0;
|
||||
}
|
||||
}
|
||||
match n.cmd {
|
||||
0x01 => c.period = (c.period - n.param as f64).max(57.0),
|
||||
0x02 => c.period = (c.period + n.param as f64).min(3424.0),
|
||||
0x03 => {
|
||||
let s = c.porta_speed;
|
||||
c.period = if c.period > c.target {
|
||||
(c.period - s).max(c.target)
|
||||
} else {
|
||||
(c.period + s).min(c.target)
|
||||
};
|
||||
}
|
||||
0x0d => {
|
||||
let (up, down) = ((n.param >> 4) as f64, (n.param & 15) as f64);
|
||||
c.volume = if up > 0.0 {
|
||||
(c.volume + up).min(64.0)
|
||||
} else {
|
||||
(c.volume - down).max(0.0)
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_line(&mut self) {
|
||||
let len = self.song.sequence.len();
|
||||
if let Some((order, line)) = self.jump.take() {
|
||||
self.order = if order < len { order } else { 0 };
|
||||
self.line = line;
|
||||
return;
|
||||
}
|
||||
self.line += 1;
|
||||
if self.line >= self.song.blocks[self.song.sequence[self.order]].lines {
|
||||
self.line = 0;
|
||||
self.order = (self.order + 1) % len;
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
if self.tick == 0 {
|
||||
self.start_line();
|
||||
} else {
|
||||
self.effects_tick();
|
||||
}
|
||||
self.tick += 1;
|
||||
if self.tick >= self.speed {
|
||||
self.tick = 0;
|
||||
self.advance_line();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order(&self) -> usize {
|
||||
self.order
|
||||
}
|
||||
|
||||
/// Current (sequence position, line), for diagnostics.
|
||||
pub fn position(&self) -> String {
|
||||
format!("{}:{}", self.order, self.line)
|
||||
}
|
||||
|
||||
/// Mixes one stereo frame.
|
||||
pub fn next(&mut self) -> (f32, f32) {
|
||||
if self.tick_left <= 0.0 {
|
||||
self.tick();
|
||||
self.tick_left += self.rate / self.ticks_per_second();
|
||||
}
|
||||
self.tick_left -= 1.0;
|
||||
|
||||
let song = &self.song;
|
||||
let master = song.master_volume.min(64) as f64 / 64.0;
|
||||
let (mut l, mut r) = (0.0f64, 0.0f64);
|
||||
for (ch, c) in self.channels.iter_mut().enumerate() {
|
||||
if !c.playing {
|
||||
continue;
|
||||
}
|
||||
let Some(inst) = c.instr.map(|i| &song.instruments[i]) else {
|
||||
continue;
|
||||
};
|
||||
let len = inst.data.len();
|
||||
if len == 0 {
|
||||
c.playing = false;
|
||||
continue;
|
||||
}
|
||||
let i = c.pos as usize;
|
||||
if i >= len {
|
||||
c.playing = false;
|
||||
continue;
|
||||
}
|
||||
let v = inst.data[i] as f64 / 128.0
|
||||
* (c.volume / 64.0)
|
||||
* (song.track_volume[ch % 16].min(64) as f64 / 64.0);
|
||||
// Amiga hardware panning: channels 0 and 3 left, 1 and 2 right.
|
||||
if matches!(ch % 4, 0 | 3) {
|
||||
l += v;
|
||||
} else {
|
||||
r += v;
|
||||
}
|
||||
c.pos += PAULA_CLOCK / c.period / self.rate;
|
||||
if inst.loop_len > 2 {
|
||||
let end = (inst.loop_start + inst.loop_len) as f64;
|
||||
while c.pos >= end {
|
||||
c.pos -= inst.loop_len as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Blend a little of each side into the other, as hard Amiga stereo
|
||||
// is tiring on headphones.
|
||||
let (l, r) = (l * 0.75 + r * 0.25, r * 0.75 + l * 0.25);
|
||||
((l * master * 0.5) as f32, (r * master * 0.5) as f32)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user