Extract the track thumbnails and the ball sprite bank

- Track files: decode the selector thumbnails (a 640x64 16-colour
  picture, four sequential bitplanes, six 123x32 tiles in a 3x2 grid) into
  tracks/<name>/thumb.png, and show them in the menu at double height
  (they are Amiga hi-res)
- gfx/ball.spr: decode the 64-frame attached-sprite bank (spinning ball
  with the Acid logo) into gfx/ball.png; its colours are not in the file
- Command-line output no longer panics when piped into e.g. head
This commit is contained in:
2026-10-19 20:43:34 +00:00
parent 9d82d99c15
commit c36572f8dc
32 changed files with 262 additions and 36 deletions
+14 -2
View File
@@ -47,7 +47,14 @@ Plain text; lines starting with `#` are comments.
- **`start`, `flags`:** values from the original data that the game
doesn't use.
To add a track, make a new folder with both files. Tracks are listed in
### `thumb.png` (optional)
The track's 123 × 32 thumbnail from the original track-select screen, in
the track's palette. It's drawn at double height in the menu, because the
original's pixels are half-width. Without one, the menu shows a shrunken
`track.png`.
To add a track, make a new folder with `track.png` and `track.txt`. Tracks are listed in
name order.
## Vehicles: `vehicles/<id>.png` and `<id>.txt`
@@ -125,7 +132,12 @@ files, the first in name order plays.
## Pictures: `gfx/*.png`
Indexed PNGs. `skidtoon.png` is the title picture at the top of the menu,
shown at half size. The others aren't used.
shown at half size. The others aren't used:
- `ball.png`: 64 frames of a spinning ball with the Acid logo, 16 frames
across, each 32 × 33. Its real colours aren't known, so it has a grey
palette.
- `chat2.png`, `mouse.png`, `sign.png`: small odds and ends.
## Palettes: `palettes/*.pal` (optional)
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+25 -6
View File
@@ -58,7 +58,7 @@ consumed to its first byte.
```
0x00 7 × u32 end offset of sections 0..6
0x1C … section 0: header for the whole file (20622 bytes, raw)
0x1C … section 0: thumbnails and palette for the whole file (20622 bytes, raw)
… sections 1..6: one track each, CrunchMania-packed
```
@@ -73,8 +73,16 @@ The last 142 bytes of section 0 hold:
| 98 | 12 | Unknown |
| 110 | … | The name of the set of tracks, NUL-terminated |
The rest of section 0 is the track-selector thumbnails, which aren't
decoded.
The first 20480 bytes of section 0 are the track-selector thumbnails:
- **Picture:** one 640 × 64, 16-colour picture in the file's palette,
stored as four bitplanes one after another (80 bytes per row, 5120 bytes
per plane).
- **Layout:** the six tracks' thumbnails, 123 × 32 each, sit in a 3 × 2
grid at its top left, in track order: top row tracks 1–3, bottom row
4–6.
- **Display:** the picture is Amiga hi-res, whose pixels are half as wide
as low-res ones. So a thumbnail shows at 123 × 64 next to the low-res
track pictures, the same shape as the track.
Each track section unpacks to 281240 (`0x44A98`) bytes:
@@ -166,7 +174,18 @@ extra and alternative sounds (`crash1`, `crash2`, `engine`, `motor`,
IFF ILBM with ByteRun1 compression. `skidtoon.iff` is the 608 × 180,
16-colour title picture; `chat2`, `mouse` and `sign` are small odds and
ends. `ball.spr` (34816 bytes) is in another format and isn't decoded.
ends.
`ball.spr` is a bank of 64 saved hardware sprites: a spinning ball with
the Acid logo. Each 544-byte frame is:
- **Header** (16 bytes): `u32` saved memory pointer, `u16` lines (33),
`u16` strips (4), `u16` flags (`0x8020`), `u16` bytes per strip (132),
4 unused bytes.
- **Strips:** four 16-pixel sprites, each line two bitplane words.
Flag bit 15 marks attached sprites. The strips pair up into two 16-colour
columns, so a frame is 32 × 33 pixels. The bank carries no colours.
## Music (`sfx/tune.crn`)
@@ -202,8 +221,8 @@ first minute), and it loops at the same point.
decoded; this port's versions are reconstructions. Decoding the `.set`
values would be the first step towards the original handling.
- **Unknown data:** the 12 bytes after each track file's palette, the bit
pattern after the gates in each track section, the thumbnails, and
`ball.spr`.
pattern after the gates in each track section, and the colours of
`ball.spr` (probably set by the executable).
The engine's own editable formats, which `skids extract` converts all of
the above into, are described in [assets/README.md](../assets/README.md).
+16 -2
View File
@@ -233,8 +233,22 @@ impl App {
fn thumb(&mut self, i: usize) -> Option<&Vec<u32>> {
if !self.thumbs.contains_key(&i) {
let t = self.data.load_track(i).ok()?;
self.thumbs
.insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H));
let img = match &t.thumbnail {
// The original thumbnail: Amiga hi-res pixels, so each row is
// shown twice to keep its shape.
Some(px) => {
let pal: Vec<u32> = t.palette.iter().map(|c| c.to_u32()).collect();
let mut out = vec![0x101010u32; THUMB_W * THUMB_H];
for y in 0..THUMB_H {
for x in 0..track::THUMB_W.min(THUMB_W) {
out[y * THUMB_W + x] = pal[px[(y / 2) * track::THUMB_W + x] as usize];
}
}
out
}
None => render::downscale(&t.rgb(), THUMB_W, THUMB_H),
};
self.thumbs.insert(i, img);
}
self.thumbs.get(&i)
}
+18 -2
View File
@@ -299,8 +299,14 @@ impl GameData {
let mut t = match &entry.source {
TrackSource::Asset { png, txt } => {
let txt = String::from_utf8_lossy(self.files.get(txt).unwrap_or_default());
Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt)
.map_err(|e| format!("{}: {e}", entry.name))?
let mut t =
Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt)
.map_err(|e| format!("{}: {e}", entry.name))?;
let thumb = png.replace("/track.png", "/thumb.png");
if let Some(d) = self.files.get(&thumb) {
t.set_thumb_png(d).map_err(|e| format!("{thumb}: {e}"))?;
}
t
}
TrackSource::Packed { file, section } => self.track_files[*file].decode(*section)?,
};
@@ -383,6 +389,16 @@ fn load_media(files: &FileSet, warnings: &mut Vec<String>) -> Media {
}
}
}
for (key, data) in &files.files {
if let Some(name) = stem(key, "gfx", ".spr") {
match iff::read_sprite_bank(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) {
+64 -2
View File
@@ -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.
//! Section 0 starts with the selector thumbnails: a 640x64 16-colour
//! picture, four bitplanes one after another (80 bytes per row), with
//! the six tracks' 123x32 thumbnails in a 3x2 grid at its top left.
//! Its pixels are Amiga hi-res, so each shows at 123x64 on screen.
//! 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
@@ -50,11 +54,16 @@ pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool {
sx >= margin && sy >= margin && sx < W as f32 - margin && sy < H as f32 - margin
}
pub const THUMB_W: usize = 123;
pub const THUMB_H: usize = 32;
pub struct TrackFile {
pub name: String,
pub number: u32,
pub palette: [Rgb; 16],
pub set_name: String,
/// The six selector thumbnails, THUMB_W x THUMB_H palette indices each.
pub thumbnails: Vec<Vec<u8>>,
/// Packed track sections (index 0 = first playable track).
pub sections: Vec<Vec<u8>>,
}
@@ -75,6 +84,8 @@ pub struct Track {
pub name: String,
/// The set of six tracks this one belongs to.
pub set_name: String,
/// The original selector thumbnail, THUMB_W x THUMB_H palette indices.
pub thumbnail: Option<Vec<u8>>,
/// Palette indices, W x H.
pub pixels: Vec<u8>,
pub palette: [Rgb; 16],
@@ -112,13 +123,16 @@ 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()));
let header = &d[SECTIONS * 4..ends[0]];
let thumbnails = thumbnails(header);
let (palette, set_name) =
file_header(header).unwrap_or((palette::TRACK_DEFAULT, String::new()));
Ok(TrackFile {
name: name.to_string(),
number,
palette,
set_name,
thumbnails,
sections,
})
}
@@ -132,10 +146,38 @@ impl TrackFile {
let mut t = Track::from_section(self.track_name(section), &raw)?;
t.palette = self.palette;
t.set_name = self.set_name.clone();
t.thumbnail = self.thumbnails.get(section).cloned();
Ok(t)
}
}
/// The six thumbnails from the start of section 0.
fn thumbnails(s: &[u8]) -> Vec<Vec<u8>> {
const ROW: usize = 80;
const ROWS: usize = 64;
let plane = ROW * ROWS;
if s.len() < plane * 4 {
return Vec::new();
}
let px = |x: usize, y: usize| -> u8 {
(0..4)
.map(|p| ((s[p * plane + y * ROW + x / 8] >> (7 - x % 8)) & 1) << p)
.sum()
};
(0..6)
.map(|i| {
let (ox, oy) = ((i % 3) * THUMB_W, (i / 3) * THUMB_H);
let mut t = Vec::with_capacity(THUMB_W * THUMB_H);
for y in 0..THUMB_H {
for x in 0..THUMB_W {
t.push(px(ox + x, oy + y));
}
}
t
})
.collect()
}
/// 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)?..)?;
@@ -197,6 +239,7 @@ impl Track {
pixels,
palette: palette::TRACK_DEFAULT,
set_name: String::new(),
thumbnail: None,
height,
gates,
start,
@@ -336,6 +379,7 @@ impl Track {
pixels: img.pixels,
palette: pal,
set_name,
thumbnail: None,
height,
gates,
start,
@@ -343,6 +387,24 @@ impl Track {
})
}
/// The thumbnail as an indexed PNG in the track's palette.
pub fn thumb_png(&self) -> Option<Result<Vec<u8>, String>> {
self.thumbnail
.as_ref()
.map(|t| image::write_indexed(THUMB_W, THUMB_H, &self.palette, false, t))
}
pub fn set_thumb_png(&mut self, png: &[u8]) -> Result<(), String> {
let img = image::read_indexed(png, &self.palette)?;
if (img.w, img.h) != (THUMB_W, THUMB_H) || img.pixels.iter().any(|&p| p > 15) {
return Err(format!(
"thumb.png must be {THUMB_W}x{THUMB_H} using colours 0-15"
));
}
self.thumbnail = Some(img.pixels);
Ok(())
}
pub fn to_png(&self) -> Result<Vec<u8>, String> {
image::write_indexed(W, H, &self.palette, false, &self.pixels)
}
+85
View File
@@ -170,6 +170,74 @@ pub fn read_ilbm(d: &[u8]) -> Result<Picture, String> {
})
}
/// Reads a bank of hardware sprites saved by the game (`gfx/ball.spr`) as
/// one picture sheet, 16 frames across.
///
/// Each frame is a 16-byte header (BE32 saved pointer, BE16 lines, BE16
/// strip count, BE16 flags, BE16 bytes per strip, 4 unused) followed by the
/// strips: 16-pixel sprites with two bitplane words per line. With flag bit
/// 15 set, strips pair up as attached sprites (16 colours, 16 pixels per
/// pair); otherwise each strip is a 4-colour, 16-pixel column. The bank
/// carries no colours, so the sheet gets a grey ramp.
pub fn read_sprite_bank(d: &[u8]) -> Result<Picture, String> {
if d.len() < 16 {
return Err("too short for a sprite bank".into());
}
let lines = be16(d, 4) as usize;
let strips = be16(d, 6) as usize;
let attached = be16(d, 8) & 0x8000 != 0 && strips.is_multiple_of(2);
let per_strip = be16(d, 10) as usize;
if lines == 0 || strips == 0 || per_strip != lines * 4 {
return Err("not a sprite bank".into());
}
let record = 16 + strips * per_strip;
let frames = d.len() / record;
let fw = if attached {
strips / 2 * 16
} else {
strips * 16
};
let (cols, rows) = (frames.min(16), frames.div_ceil(16));
let (w, h) = (cols * fw, rows * lines);
let mut pixels = vec![0u8; w * h];
for f in 0..frames {
let r = &d[f * record..(f + 1) * record];
if be16(r, 4) as usize != lines || be16(r, 6) as usize != strips {
return Err(format!("frame {f} has a different size"));
}
let strip_px = |s: usize, x: usize, y: usize| -> u8 {
let o = 16 + s * per_strip + y * 4;
let bit = 0x8000 >> x;
((be16(r, o) & bit != 0) as u8) | (((be16(r, o + 2) & bit != 0) as u8) << 1)
};
let (ox, oy) = ((f % 16) * fw, (f / 16) * lines);
for y in 0..lines {
for x in 0..fw {
let v = if attached {
let pair = x / 16;
strip_px(pair * 2, x % 16, y) | (strip_px(pair * 2 + 1, x % 16, y) << 2)
} else {
strip_px(x / 16, x % 16, y)
};
pixels[(oy + y) * w + ox + x] = v;
}
}
}
let n = if attached { 16 } else { 4 };
let palette = (0..n)
.map(|i| {
let v = (i * 255 / (n - 1)) as u8;
(v, v, v)
})
.collect();
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> {
@@ -277,6 +345,23 @@ mod tests {
assert_eq!(back, s);
}
#[test]
fn attached_sprite_bank() {
// One frame: 1 line, 2 attached strips; pixel 0 uses all four planes.
let mut d = vec![0u8; 16 + 2 * 4];
d[5] = 1;
d[7] = 2;
d[8] = 0x80;
d[11] = 4;
for o in [16, 18, 20, 22] {
d[o] = 0x80;
}
let p = read_sprite_bank(&d).unwrap();
assert_eq!((p.w, p.h), (16, 1));
assert_eq!(p.pixels[0], 15);
assert_eq!(p.pixels[1], 0);
}
#[test]
fn byterun1_picture() {
// 16x1, 1 plane: run of 2 x 0xff.
+40 -22
View File
@@ -3,6 +3,17 @@
//! The engine reads the original Amiga data (ADF images, the LHA archives on
//! them, or extracted files) at run time; no game assets are bundled.
/// Like `println!`, but a closed pipe (e.g. `skids info | head`) ends the
/// program quietly instead of panicking.
macro_rules! out {
($($arg:tt)*) => {{
use std::io::Write;
if writeln!(std::io::stdout(), $($arg)*).is_err() {
std::process::exit(0);
}
}};
}
mod app;
mod audio;
mod data;
@@ -202,24 +213,24 @@ fn main() -> ExitCode {
fn info(data: &GameData, dir: Option<&Path>) {
let built_in = data::embedded::files().len();
match (built_in, dir) {
(0, Some(d)) => println!("Data: {}", d.display()),
(n, Some(d)) => println!("Data: built in ({n} files), plus {}", d.display()),
(n, None) => println!("Data: built in ({n} files)"),
(0, Some(d)) => out!("Data: {}", d.display()),
(n, Some(d)) => out!("Data: built in ({n} files), plus {}", d.display()),
(n, None) => out!("Data: built in ({n} files)"),
}
println!("Tracks:");
out!("Tracks:");
for (i, entry) in data.tracks.iter().enumerate() {
let from = match entry.source {
data::TrackSource::Asset { .. } => "asset",
data::TrackSource::Packed { .. } => "original data",
};
match data.load_track(i) {
Ok(t) => println!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()),
Err(e) => println!(" {:<10} ERROR {e}", entry.name),
Ok(t) => out!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()),
Err(e) => out!(" {:<10} ERROR {e}", entry.name),
}
}
println!("Vehicles:");
out!("Vehicles:");
for v in &data.vehicles {
println!(" {:<10} ({})", v.name, v.id);
out!(" {:<10} ({})", v.name, v.id);
}
}
@@ -263,7 +274,7 @@ fn unpack(input: &Path, out: Option<&Path>) -> Result<(), String> {
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);
out!("{:>8} {}", data.len(), path);
}
if failed > 0 {
return Err(format!("{failed} files could not be extracted"));
@@ -299,7 +310,10 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
}
write(dir.join("track.png"), &png)?;
write(dir.join("track.txt"), txt.as_bytes())?;
println!("wrote {}", dir.display());
if let Some(thumb) = t.thumb_png() {
write(dir.join("thumb.png"), &thumb?)?;
}
out!("wrote {}", dir.display());
}
for v in &data.vehicles {
let (png, txt) = (v.sheet_png()?, v.settings_text());
@@ -313,7 +327,7 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
if let Some(menu) = v.menu_png() {
write(dir.join(format!("{}-menu.png", v.id)), &menu?)?;
}
println!("wrote {}", dir.join(&v.id).display());
out!("wrote {}", dir.join(&v.id).display());
}
for (name, s) in &data.sounds {
let wav = formats::iff::sound_to_wav(s);
@@ -323,13 +337,13 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
}
let path = out.join("sounds").join(format!("{name}.wav"));
write(path.clone(), &wav)?;
println!("wrote {}", path.display());
out!("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());
out!("wrote {}", path.display());
}
for (name, p) in &data.pictures {
let pal: Vec<data::palette::Rgb> = p
@@ -343,7 +357,7 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
}
let path = out.join("gfx").join(format!("{name}.png"));
write(path.clone(), &png)?;
println!("wrote {}", path.display());
out!("wrote {}", path.display());
}
Ok(())
}
@@ -394,7 +408,7 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
let n = race.track.gates.len();
for &i in &race.standings() {
let e = &race.entrants[i];
println!(
out!(
"{:<10} laps {} gates {} best {:?}",
e.name,
e.laps_done(n),
@@ -402,7 +416,7 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
e.best_lap
);
}
println!("wrote {}", out.display());
out!("wrote {}", out.display());
Ok(())
}
@@ -415,9 +429,13 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
};
app.settings.caravans = args.caravans;
let frames = (args.seconds / game::DT) as usize;
println!(
out!(
"{:<10} {:>5} {:>8} {:>9} {:>8}",
"TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD"
"TRACK",
"GATES",
"LAPS",
"BEST LAP",
"ON ROAD"
);
for ti in 0..app.data.tracks.len() {
let mut race = app.build_race(ti, 0, [0, 0], 4, 99, ti as u32 + 1)?;
@@ -445,7 +463,7 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
} else {
app::fmt_time(best)
};
println!(
out!(
"{:<10} {:>5} {:>8} {:>9} {:>7.0}%",
race.track.name,
n,
@@ -494,7 +512,7 @@ fn render_music(data: &GameData, args: &Args) -> Result<(), String> {
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!(
out!(
"wrote {} ({:.0} s, peak {:.2}, position {})",
out.display(),
args.seconds,
@@ -502,7 +520,7 @@ fn render_music(data: &GameData, args: &Args) -> Result<(), String> {
player.position()
);
if let Some(t) = wrapped_at {
println!(
out!(
"the song loops back to the start after {}:{:04.1}",
(t / 60.0) as u32,
t % 60.0
@@ -556,7 +574,7 @@ fn trace(data: GameData, args: &Args) -> Result<(), String> {
}
let n = race.track.gates.len();
for e in &race.entrants {
println!(
out!(
"{:<8} laps {} next gate {}",
e.name,
e.laps_done(n),