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:
2026-10-19 20:24:33 +00:00
committed by Claude
parent f27bd2e993
commit 9d82d99c15
106 changed files with 2706 additions and 287 deletions
+33 -29
View File
@@ -3,14 +3,17 @@
A cross-platform (Windows, macOS, Linux) engine written in Rust that plays A cross-platform (Windows, macOS, Linux) engine written in Rust that plays
*Super Skidmarks* (Acid Software, 1995) on the original Amiga data. *Super Skidmarks* (Acid Software, 1995) on the original Amiga data.
The game's data was converted from the original ADF disk images (disks 2–6 The game's data was converted from the six original ADF disk images into
of the six-disk set) into editable files in [`assets/`](assets/README.md). editable files in [`assets/`](assets/README.md). The images themselves
The images themselves are no longer kept in this repository. aren't kept in this repository.
- **Each track:** a 16-colour PNG (the PNG's palette is the track's - **Each track:** a 16-colour PNG (the PNG's palette is the track's
palette), plus a text file with the lap gates and height map. palette), plus a text file with the lap gates and height map.
- **Each car:** a PNG sprite sheet, plus a text file with its name and - **Each car:** a PNG sprite sheet and menu picture, plus a text file with
handling. its name, driver and handling.
- **Sounds:** WAV files.
- **Music:** the tune, as an OctaMED module.
- **Pictures:** the title picture and a few other images, as PNGs.
Edit them with any image editor or text editor. The build embeds the Edit them with any image editor or text editor. The build embeds the
`assets/` folder, so the finished program is a single self-contained file. `assets/` folder, so the finished program is a single self-contained file.
@@ -24,36 +27,33 @@ give either to also gets the game data.
## What works ## What works
- All 24 tracks across the four track files. Each has its isometric height - All 24 tracks across the four track files, in their original palettes.
map and lap gates. Each has its isometric height map and lap gates.
- All 8 vehicles (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police, - All 9 cars (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police,
Cow), with 32 headings and 25 body-tilt frames for hills. Cow, Camaro) with their drivers' names, each with 32 headings and 25
body-tilt frames for hills.
- The caravan: turn on **Caravans** in the menu and every car tows one.
- The original sound effects, and the original tune played by a built-in
OctaMED player. The menu has a **Music** option: menus, always, or off.
- The original title picture.
- 1 or 2 players (split screen) plus up to 7 computer drivers. - 1 or 2 players (split screen) plus up to 7 computer drivers.
- Power-slide handling, slopes, car-to-car bumping, and skid marks burnt - Power-slide handling, slopes, car-to-car bumping, and skid marks burnt
into the track. into the track.
- Lap and position tracking, lap times, and a results table. - Lap and position tracking, lap times, and a results table.
- Procedural engine, tyre and crash sound. - Engine note pitched with revs, tyre screech, crashes, backfires, a horn
and start beeps, all from the original samples.
## What's reconstructed ## What's reconstructed
Disk 1 of the six-disk set is missing from this archive. It held the The executable is on disk 1, but its game logic, a compiled Blitz Basic 2
executable, palettes, sound and music. As a result: program, hasn't been decoded. So the **handling, computer drivers and race
rules** are new code written to feel like the original, not a translation
of it.
- **Handling, AI and race rules** are new code written to feel like the Each car's original settings file has eleven handling numbers whose
original, not a translation of the original 68000 code. meaning lives in that code. They're kept in the car's settings asset for
- **Colours** come from a reconstructed palette, built around how the when it is decoded. [docs/FORMATS.md](docs/FORMATS.md) covers everything
tracks use their 16 colour indices. If you have the real palettes, put reverse engineered so far.
them in a `palettes/` folder next to the disks as `.pal` files (16 lines
of `RRGGBB`):
- `track1-3.pal` for one track
- `track1.pal` for all six tracks in a file
- `default.pal` for everything
- **Sound** is synthesised.
If you find disk 1, drop it in with the others. Anything it contains under
`track/` or `vehicles/` is picked up automatically. The format notes in
[docs/FORMATS.md](docs/FORMATS.md) cover everything reverse engineered so
far.
## Running ## Running
@@ -76,6 +76,7 @@ exists both as an asset and on disk, the asset wins.
| Accelerate | ↑ (or W in one-player games) | W | | Accelerate | ↑ (or W in one-player games) | W |
| Brake / reverse | ↓ (or S) | S | | Brake / reverse | ↓ (or S) | S |
| Steer | ← → (or A D) | A D | | Steer | ← → (or A D) | A D |
| Horn | Right Shift | Left Shift |
| Pause | P | | | Pause | P | |
| Leave race | Esc | | | Leave race | Esc | |
@@ -89,6 +90,8 @@ skids extract --data ADFS [--out assets] # convert original disks into editable
skids bench --data DIR --seconds 120 # race CPU cars on every track: laps and time on road skids bench --data DIR --seconds 120 # race CPU cars on every track: laps and time on road
skids trace --data DIR --track 11 --out t.png # plot CPU racing lines skids trace --data DIR --track 11 --out t.png # plot CPU racing lines
skids render --data DIR --track 3 --out shot.png # headless screenshot skids render --data DIR --track 3 --out shot.png # headless screenshot
skids music --out tune.wav --seconds 60 # render the tune to WAV
skids unpack disk.adf --out files/ # extract a disk or LHA archive as-is
``` ```
## Building ## Building
@@ -114,11 +117,12 @@ GitHub Actions builds all three platforms on every push
``` ```
assets/ the game data as editable PNG and text files assets/ the game data as editable PNG and text files
build.rs embeds assets/ into the executable build.rs embeds assets/ into the executable
src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers src/formats/ ADF (OFS/FFS), LHA (incl. -lh5-), CrunchMania, IFF 8SVX/ILBM, OctaMED
src/data/ track, vehicle and palette decoding; locating the game files src/data/ track, vehicle and palette decoding; locating the game files
src/game/ physics, AI and race rules src/game/ physics, AI and race rules
src/render/ software renderer at 320×256 (PAL low-res) and a bitmap font src/render/ software renderer at 320×256 (PAL low-res) and a bitmap font
src/audio.rs synthesised sound (cpal) src/audio.rs sample mixer and sound effects (cpal)
src/music.rs OctaMED player
src/app.rs menus, race screens and results src/app.rs menus, race screens and results
``` ```
+45 -2
View File
@@ -42,6 +42,8 @@ Plain text; lines starting with `#` are comments.
- **`height`:** 64 rows of 64 numbers giving ground height × 256. Each - **`height`:** 64 rows of 64 numbers giving ground height × 256. Each
number covers a 16 × 8 pixel cell of the picture. Slopes push cars number covers a 16 × 8 pixel cell of the picture. Slopes push cars
downhill and tilt the car sprites. downhill and tilt the car sprites.
- **`set`:** the name of the set of tracks this one belongs to, shown under
the menu thumbnail.
- **`start`, `flags`:** values from the original data that the game - **`start`, `flags`:** values from the original data that the game
doesn't use. doesn't use.
@@ -68,12 +70,21 @@ A 2048 × 800 sprite sheet: 32 headings across by 25 tilts down, each cell
- **Tilts:** row = roll × 5 + pitch (each 0–4), so row 12 is the car on - **Tilts:** row = roll × 5 + pitch (each 0–4), so row 12 is the car on
flat ground. flat ground.
### `<id>-menu.png` (optional)
The car's 64 × 32 picture from the original select screen, in the same
four colours as the sprite sheet.
### `<id>.txt` ### `<id>.txt`
The display name and handling: The display name, driver and handling:
| Setting | Meaning | | Setting | Meaning |
|-----------|---------| |-----------|---------|
| `name` | name shown in the menu |
| `driver` | driver's name, shown next to it |
| `trailer` | `yes` for something towed behind cars (the caravan) rather than driven |
| `original`| the 16 values from the original `.set` file, kept for reference; the game doesn't use them yet |
| `top` | top speed on tarmac | | `top` | top speed on tarmac |
| `accel` | acceleration | | `accel` | acceleration |
| `brake` | braking | | `brake` | braking |
@@ -82,7 +93,39 @@ The display name and handling:
| `mass` | weight in collisions | | `mass` | weight in collisions |
| `offroad` | speed and grip multiplier on scenery | | `offroad` | speed and grip multiplier on scenery |
To add a car, add a new `<id>.png` / `<id>.txt` pair. The handling values are a reconstruction, so tune them freely. To add a
car, add a new `<id>.png` / `<id>.txt` pair.
## Sounds: `sounds/*.wav`
8-bit mono WAV files at the original Amiga sample rates. A loop point,
stored in the WAV's `smpl` chunk, makes a sound repeat from there; most
sample editors keep it. The game uses these names:
| Sound | Use |
|-------|-----|
| `motor3` | engine, pitched up and down with revs (falls back to `motor`, then `engine`) |
| `screech2` | tyres sliding (falls back to `screech3`, then `screech`) |
| `crash1`, `crash2`, `crash3` | collisions, picked at random |
| `backfire` | lifting off at high revs |
| `horn` | Right Shift (player 1) or Left Shift (player 2) |
| `beep`, `beep2` | countdown, then lights out |
Missing sounds fall back to synthesised ones. Any other WAV files are kept
but not used.
## Music: `music/*.med`
The tune, an OctaMED module. Edit it in a tracker that reads MED files,
such as OpenMPT. The game's player supports plain sample instruments (no
synth sounds) and the commands listed in `docs/FORMATS.md`. Preview it
with `skids music --data assets --out tune.wav`. If there are several
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.
## Palettes: `palettes/*.pal` (optional) ## Palettes: `palettes/*.pal` (optional)
Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

+3
View File
@@ -27,6 +27,9 @@ gate 600.6875 768.8125 488.4375 696.3125
gate 599.25 904.75 487.75 759.25 gate 599.25 904.75 487.75 759.25
gate 456.75 935 408.5 776.75 gate 456.75 935 408.5 776.75
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 458 107 start 458 107
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

+3
View File
@@ -36,6 +36,9 @@ gate 253.125 618.375 144.34375 626.03125
gate 311.4375 676.8125 239.25 719.28125 gate 311.4375 676.8125 239.25 719.28125
gate 357.25 723.75 333.1875 815.5625 gate 357.25 723.75 333.1875 815.5625
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 461 295 start 461 295
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+3
View File
@@ -31,6 +31,9 @@ gate 487.75 549.25 491.5 648.5
gate 708 556 686.5 649.5 gate 708 556 686.5 649.5
gate 766.5 529.5 861 616.5 gate 766.5 529.5 861 616.5
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 608 305 start 608 305
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

+3
View File
@@ -31,6 +31,9 @@ gate 761 667 688.5 575.5
gate 645 675 623 573 gate 645 675 623 573
gate 550.4375 729.8125 495.75 655.25 gate 550.4375 729.8125 495.75 655.25
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 608 305 start 608 305
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

+3
View File
@@ -32,6 +32,9 @@ gate 381.40625 687.25 449.78125 749.875
gate 472.28125 690.78125 551.53125 741.53125 gate 472.28125 690.78125 551.53125 741.53125
gate 468.5625 611.34375 549.5625 610.34375 gate 468.5625 611.34375 549.5625 610.34375
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 301 79 start 301 79
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

+3
View File
@@ -48,6 +48,9 @@ gate 535.65625 389.6875 437.78125 406.5625
gate 576.625 424.75 529.125 502.25 gate 576.625 424.75 529.125 502.25
gate 646.96875 425.4375 648.46875 504.4375 gate 646.96875 425.4375 648.46875 504.4375
# Name of the set of tracks this one belongs to, shown in the menu.
set Racing In The Name Of
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 434 354 start 434 354
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+3
View File
@@ -28,6 +28,9 @@ gate 360.5 837.5 380 756
gate 222.9375 708.375 281.25 659.65625 gate 222.9375 708.375 281.25 659.65625
gate 80.25 559.03125 229.5625 610.21875 gate 80.25 559.03125 229.5625 610.21875
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 474 167 start 474 167
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

+3
View File
@@ -33,6 +33,9 @@ gate 622.375 910.5 544.6875 822.6875
gate 508.6875 934.5 481.71875 820.71875 gate 508.6875 934.5 481.71875 820.71875
gate 418 903.5 421.75 805.75 gate 418 903.5 421.75 805.75
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 339 117 start 339 117
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+3
View File
@@ -40,6 +40,9 @@ gate 610 280 715.5 340.5
gate 627.25 212.75 703.5 208.5 gate 627.25 212.75 703.5 208.5
gate 565.5 156.5 587 89 gate 565.5 156.5 587 89
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 420 97 start 420 97
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+3
View File
@@ -47,6 +47,9 @@ gate 551.3125 264.84375 488.125 183.375
gate 504.84375 311.25 424.9375 247.21875 gate 504.84375 311.25 424.9375 247.21875
gate 465.90625 350.15625 376.78125 298.09375 gate 465.90625 350.15625 376.78125 298.09375
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 689 201 start 689 201
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+3
View File
@@ -51,6 +51,9 @@ gate 598.9375 295.90625 581.4375 233.40625
gate 486.375 293.34375 450.375 235.34375 gate 486.375 293.34375 450.375 235.34375
gate 350.8125 298.75 320.0625 235.5 gate 350.8125 298.75 320.0625 235.5
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 353 47 start 353 47
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

+3
View File
@@ -54,6 +54,9 @@ gate 722.125 791.0625 640.125 753.0625
gate 659.5 864.25 598.5 787.25 gate 659.5 864.25 598.5 787.25
gate 556.375 884.4375 560.75 793.5625 gate 556.375 884.4375 560.75 793.5625
# Name of the set of tracks this one belongs to, shown in the menu.
set Even Flow
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 353 47 start 353 47
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

+3
View File
@@ -44,6 +44,9 @@ gate 307.5 604.875 307.6875 694.3125
gate 358.5 603.5 386 696 gate 358.5 603.5 386 696
gate 437.34375 599.28125 412.75 695.25 gate 437.34375 599.28125 412.75 695.25
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 641 315 start 641 315
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

+3
View File
@@ -30,6 +30,9 @@ gate 498.8125 807.25 578.75 940.28125
gate 566.5 820.5 690.75 890.25 gate 566.5 820.5 690.75 890.25
gate 597.75 790.3125 707.8125 768.96875 gate 597.75 790.3125 707.8125 768.96875
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 475 288 start 475 288
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+3
View File
@@ -38,6 +38,9 @@ gate 635 363 636.75 487.25
gate 689.25 419.75 707.625 558.375 gate 689.25 419.75 707.625 558.375
gate 741.4375 471.8125 785.84375 638.65625 gate 741.4375 471.8125 785.84375 638.65625
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 809 244 start 809 244
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+3
View File
@@ -39,6 +39,9 @@ gate 573.59375 394.84375 716.59375 427.84375
gate 483.625 301.96875 614.125 331.46875 gate 483.625 301.96875 614.125 331.46875
gate 450.3125 217.03125 589.3125 298.03125 gate 450.3125 217.03125 589.3125 298.03125
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 796 294 start 796 294
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

+3
View File
@@ -36,6 +36,9 @@ gate 304.5 269.5 405 337
gate 335.75 207.5 415.75 287.5 gate 335.75 207.5 415.75 287.5
gate 386.125 161.5 465.375 240.25 gate 386.125 161.5 465.375 240.25
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 744 137 start 744 137
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

+3
View File
@@ -47,6 +47,9 @@ gate 796.78125 544.59375 896.84375 523.4375
gate 760.125 503.53125 816.78125 446.34375 gate 760.125 503.53125 816.78125 446.34375
gate 658.5 417.5 705.75 366.25 gate 658.5 417.5 705.75 366.25
# Name of the set of tracks this one belongs to, shown in the menu.
set Surf 'n' Slam
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 356 188 start 356 188
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

+3
View File
@@ -37,6 +37,9 @@ gate 402 504 402 378
gate 342.53125 501.71875 264 374.5 gate 342.53125 501.71875 264 374.5
gate 316.5625 513.03125 192.25 387 gate 316.5625 513.03125 192.25 387
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 213 275 start 213 275
flags 1 flags 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

+3
View File
@@ -47,6 +47,9 @@ gate 715.375 842.65625 586.40625 755
gate 701.84375 891.1875 585.65625 814.03125 gate 701.84375 891.1875 585.65625 814.03125
gate 663.3125 939.1875 581.0625 837.78125 gate 663.3125 939.1875 581.0625 837.78125
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 122 216 start 122 216
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+3
View File
@@ -40,6 +40,9 @@ gate 372.1875 581.84375 301.1875 652.34375
gate 402.625 626.75 392.625 759.25 gate 402.625 626.75 392.625 759.25
gate 435.0625 628.59375 502.1875 772.34375 gate 435.0625 628.59375 502.1875 772.34375
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 672 360 start 672 360
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

+3
View File
@@ -43,6 +43,9 @@ gate 293.90625 281.8125 414.1875 352
gate 347.40625 233.625 448.21875 353.125 gate 347.40625 233.625 448.21875 353.125
gate 423.4375 212.9375 488.71875 377.71875 gate 423.4375 212.9375 488.71875 377.71875
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 800 350 start 800 350
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

+3
View File
@@ -47,6 +47,9 @@ gate 265.75 441.875 165.75 375.875
gate 247.84375 489.21875 133.09375 438.96875 gate 247.84375 489.21875 133.09375 438.96875
gate 259.9375 536.59375 154.9375 525.59375 gate 259.9375 536.59375 154.9375 525.59375
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 362 264 start 362 264
flags 0 flags 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

+3
View File
@@ -42,6 +42,9 @@ gate 506.1875 355.09375 607.4375 450.84375
gate 628.3125 300 668.3125 426 gate 628.3125 300 668.3125 426
gate 841.875 457.5 766.875 502.5 gate 841.875 457.5 766.875 502.5
# Name of the set of tracks this one belongs to, shown in the menu.
set Sounf Of Speed
# As stored in the original data; not used by the game. # As stored in the original data; not used by the game.
start 501 153 start 501 153
flags 0 flags 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+23
View File
@@ -0,0 +1,23 @@
# Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is camaro.png: 32 headings across, 25 tilts down.
name CAMARO
driver ROD
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 54 35 32 8 7 22 22 16 49 14 53 0 0 0 0 0
# top speed on tarmac, world pixels per second
top 200
# acceleration and braking, world pixels per second squared
accel 170
brake 320
# sideways grip (higher = less sliding)
grip 5
# steering rate, radians per second
turn 3.6
# weight in collisions
mass 1
# speed and grip multiplier off the road
offroad 0.55
Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+23
View File
@@ -0,0 +1,23 @@
# Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is caravan.png: 32 headings across, 25 tilts down.
name CARAVAN
driver SIMON
# yes = towed behind cars (Caravans option) instead of driven
trailer yes
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 2 65532 65532 1 12 10 16 14 0 0 0 0 0 0 0 0
# top speed on tarmac, world pixels per second
top 200
# acceleration and braking, world pixels per second squared
accel 170
brake 320
# sideways grip (higher = less sliding)
grip 5
# steering rate, radians per second
turn 3.6
# weight in collisions
mass 1
# speed and grip multiplier off the road
offroad 0.55
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is cow.png: 32 headings across, 25 tilts down. # The sprite sheet is cow.png: 32 headings across, 25 tilts down.
name COW name COW
driver ROB
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 15 14 34 8 4 598 15 12 13 4 2003 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 170 top 170
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is f1.png: 32 headings across, 25 tilts down. # The sprite sheet is f1.png: 32 headings across, 25 tilts down.
name FORMULA 1 name FORMULA 1
driver SIMON
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 60 71 30 0 6 16 24 12 58 42 29 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 240 top 240
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is midget.png: 32 headings across, 25 tilts down. # The sprite sheet is midget.png: 32 headings across, 25 tilts down.
name MIDGET name MIDGET
driver ROB
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 51 21 30 16 3 45 16 13 44 13 93 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 205 top 205
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is mini.png: 32 headings across, 25 tilts down. # The sprite sheet is mini.png: 32 headings across, 25 tilts down.
name MINI name MINI
driver ROB
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 56 24 30 12 3 24 20 12 46 10 100 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 190 top 190
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is police.png: 32 headings across, 25 tilts down. # The sprite sheet is police.png: 32 headings across, 25 tilts down.
name POLICE name POLICE
driver SIMON
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 56 34 22 6 2 15 19 11 49 14 44 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 215 top 215
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is porsche.png: 32 headings across, 25 tilts down. # The sprite sheet is porsche.png: 32 headings across, 25 tilts down.
name PORSCHE name PORSCHE
driver IMAGINE
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 56 34 22 6 2 15 21 12 49 14 44 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 225 top 225
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is truck.png: 32 headings across, 25 tilts down. # The sprite sheet is truck.png: 32 headings across, 25 tilts down.
name TRUCK name TRUCK
driver ROD
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 55 26 28 18 2 29 20 17 54 19 35 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 195 top 195
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

+7
View File
@@ -1,6 +1,13 @@
# Skids vehicle settings. Lines starting with # are comments. # Skids vehicle settings. Lines starting with # are comments.
# The sprite sheet is vw.png: 32 headings across, 25 tilts down. # The sprite sheet is vw.png: 32 headings across, 25 tilts down.
name BEETLE name BEETLE
driver ROB
# yes = towed behind cars (Caravans option) instead of driven
trailer no
# The 16 values from the original .set file. Their meaning is not
# decoded yet, so the game does not use them; the handling below is
# a reconstruction.
original 57 28 32 12 3 29 20 14 51 15 63 0 0 0 0 0
# top speed on tarmac, world pixels per second # top speed on tarmac, world pixels per second
top 185 top 185
# acceleration and braking, world pixels per second squared # acceleration and braking, world pixels per second squared
+110 -39
View File
@@ -1,33 +1,39 @@
# Super Skidmarks data formats # Super Skidmarks data formats
Notes from reverse engineering the AGA/CD32 release of *Super Skidmarks* Notes from reverse engineering the AGA/CD32 release of *Super Skidmarks*
(Acid Software, 1995). Everything here was worked out from the data on disks (Acid Software, 1995), worked out from all six disks. The one thing not
2–6. Disk 1, which holds the executable, is missing from this archive, so yet decoded is the executable's game logic. See [Gaps](#gaps).
anything that lives only in the executable (palettes, sound, handling) is
unknown. See [Gaps](#gaps).
All multi-byte values are big-endian unless noted. All multi-byte values are big-endian unless noted.
## Disks ## Disks
Each disk is a standard 880 KB double-density ADF (1760 blocks of 512 bytes) Each disk is a standard 880 KB double-density ADF (1760 blocks of 512
formatted as OFS (`DOS\0`). The `[HD]` in the file names refers to the bytes). Disk 1 is FFS (`DOS\1`); disks 2–6 are OFS (`DOS\0`). The `[HD]` in
hard-drive-installable release, not high-density disks. Each disk holds a the file names refers to the hard-drive-installable release.
single file:
| Disk | File | Contents | | Disk | Archive | Game files |
|------|---------------|------------------------------------| |------|---------------|------------|
| 1 | `smcd32a.lha` | *missing* | | 1 | `smcd32a.lha` | Executables, `sfx/`, `gfx/`, the tune, `vehicles/*.set`, `Camaro.aga`, `caravan.aga` |
| 2 | `smcd32b.lha` | `vehicles/cow.aga`, `track/TRACK1` | | 2 | `smcd32b.lha` | `vehicles/cow.aga`, `track/TRACK1` |
| 3 | `smcd32c.lha` | `track/TRACK2`, `vehicles/TRUCK.aga` | | 3 | `smcd32c.lha` | `track/TRACK2`, `vehicles/TRUCK.aga` |
| 4 | `smcd32d.lha` | `track/TRACK3`, `vehicles/PORSCHE.aga` | | 4 | `smcd32d.lha` | `track/TRACK3`, `vehicles/PORSCHE.aga` |
| 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` | | 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` |
| 6 | `smcd32f.lha` | `vehicles/F1.aga`, `Midget.aga`, `mini.aga`, `Police.aga` | | 6 | `smcd32f.lha` | `vehicles/F1.aga`, `Midget.aga`, `mini.aga`, `Police.aga` |
Each disk's archive stands on its own (the set isn't one archive split Disk 1 also carries an Installer script that installs from all six disks,
across disks). They use level-0 headers and the `-lh0-` method (stored), and a few AmigaDOS commands.
under a top-level `super_skidmarks_cd³²\` directory. Most members are
individually packed with CrunchMania. Each archive stands on its own; the set isn't one archive split across
disks. They use level-0 headers under a top-level `super_skidmarks_cd³²\`
directory.
- **Compression:** disks 2–6 store their members (`-lh0-`), which are
themselves CrunchMania-packed. Disk 1 mostly uses `-lh5-` (LZSS with
static Huffman blocks, decoded by `src/formats/lzh.rs`).
- **Integrity:** every member is checked against its header CRC-16. One
file, `c/iconx` (an AmigaDOS utility the game doesn't use), fails its CRC
in this dump. The reference `lha` tool reports the same failure.
## CrunchMania ## CrunchMania
@@ -52,12 +58,25 @@ consumed to its first byte.
``` ```
0x00 7 × u32 end offset of sections 0..6 0x00 7 × u32 end offset of sections 0..6
0x1C … section 0: track-selector thumbnails (20622 bytes, raw) 0x1C … section 0: header for the whole file (20622 bytes, raw)
… sections 1..6: one track each, CrunchMania-packed … sections 1..6: one track each, CrunchMania-packed
``` ```
So the four files hold 24 tracks. Each track section unpacks to 281240 So the four files hold 24 tracks.
(`0x44A98`) bytes:
The last 142 bytes of section 0 hold:
| Offset | Size | Contents |
|--------|------|----------|
| 0 | 2 | File number (1–4) |
| 2 | 96 | The palette shared by the file's six tracks: 16 colours × R, G, B, each a `u16` from 0 to 15 (Amiga 12-bit colour) |
| 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.
Each track section unpacks to 281240 (`0x44A98`) bytes:
| Offset | Size | Contents | | Offset | Size | Contents |
|----------|--------|----------| |----------|--------|----------|
@@ -88,14 +107,15 @@ The stored start point is on the road for 14 of the 24 tracks read as screen
coordinates, and 10 read as world coordinates. It isn't used; the grid forms coordinates, and 10 read as world coordinates. It isn't used; the grid forms
up behind gate 0. up behind gate 0.
### Palette use ### Road and scenery
The track pixel indices are ordered from dark to light. Indices 0–7 are Colours 8–15 are the driving surface on every track, so the fourth
scenery and 8–15 the driving surface, so the fourth bitplane is effectively bitplane is effectively a road mask. Off the road means less grip and
a road mask. The game uses it as such here: off-road means less grip and
lower speed. lower speed.
## Vehicles (`vehicles/*.aga`) ## Vehicles
### Sprite banks (`vehicles/*.aga`)
CrunchMania-packed, 409600 bytes unpacked: CrunchMania-packed, 409600 bytes unpacked:
25 tilt blocks × 32 headings × 512-byte frames. 25 tilt blocks × 32 headings × 512-byte frames.
@@ -112,27 +132,78 @@ A frame is a 64 × 32 AGA hardware sprite. Each line holds 8 bytes of plane
- **Heading:** frame 12 faces screen-right, and frames advance clockwise in - **Heading:** frame 12 faces screen-right, and frames advance clockwise in
11.25° steps. In world terms, frame = 12 + (angle + 45°) / 11.25°. 11.25° steps. In world terms, frame = 12 + (angle + 45°) / 11.25°.
- **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level. - **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level. Block
Block 12 is the car on flat ground. 12 is the car on flat ground.
The caravan (`caravan.aga`) uses the same layout. It's a trailer with a tow
bar, not a car.
`Police.aga` reads as all zeros with one third-party ADF tool, but it's `Police.aga` reads as all zeros with one third-party ADF tool, but it's
intact when read directly from the disk image. intact when read directly from the disk image.
### Settings (`vehicles/*.set`)
- **Header:** the car's name and its driver's name, each followed by a
newline.
- **Settings:** 16 `u16` values. Eleven are used by the cars; the rest are
zero. The caravan's values differ in kind; two of them are −4.
- **Menu picture:** 512 bytes. A 64 × 32 picture in two bitplanes, plane 0
(256 bytes) then plane 1.
The settings are clearly handling values. The first is smallest for the
Cow, the second largest for the F1, and the Police car and Porsche are
nearly identical. What each one means lives in the executable and isn't
decoded yet.
## Sounds (`sfx/*.iff`, `gfx/*.iff`)
Uncompressed IFF 8SVX, 8-bit signed, mostly at Amiga rates (8363 Hz and
others). Looping sounds use the repeat part of `VHDR`. `gfx/` holds a few
extra and alternative sounds (`crash1`, `crash2`, `engine`, `motor`,
`screech`); `gfx/crash4.iff` isn't IFF.
## Pictures (`gfx/*.iff`)
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.
## Music (`sfx/tune.crn`)
A CrunchMania-packed OctaMED `MMD1` module: 4 tracks, 21 plain 8-bit
sample instruments, 70 blocks played in order. The main song is positions
0–65, ending with a jump back to the start. Positions 66–69 hold separate
short pieces that end with "stop".
The song uses these commands:
- `1`, `2` (slide up, slide down)
- `3` (portamento)
- `9` (speed)
- `B` (jump)
- `C` (volume, decimal)
- `D` (volume slide)
- `F00` (break), `F1` (play twice), `F2` (delay), `FD` (set pitch),
`FE` (stop)
`src/music.rs` plays these. Its loudness tracks OpenMPT's rendering of the
module second by second through the whole song (correlation 0.996 over the
first minute), and it loops at the same point.
## Executables
- **`SKIDMARKS_CD³²`** (237 KB): a single-hunk compiled Blitz Basic 2
program. It includes IFF ILBM/8SVX/ANIM loaders and a tracker replay
routine with note-period tables.
- **`skidmarks2`** (90 KB): a three-hunk program whose code is packed.
## Gaps ## Gaps
These almost certainly live on the missing disk 1: - **Game logic.** The handling, AI and rules in the executable aren't
decoded; this port's versions are reconstructions. Decoding the `.set`
- The main executable, and with it the real handling model, AI, rules, values would be the first step towards the original handling.
menus and fonts. This port's versions are reconstructions. - **Unknown data:** the 12 bytes after each track file's palette, the bit
- Colour palettes. Neither the track nor the vehicle files carry any. The pattern after the gates in each track section, the thumbnails, and
engine uses a reconstructed palette, and real ones can be dropped in as `ball.spr`.
`.pal` files (see the README).
- Sound effects and music.
- Any tracks or vehicles beyond the 24 and 8 above.
The engine's own editable formats, which `skids extract` converts all of The engine's own editable formats, which `skids extract` converts all of
the above into, are described in [assets/README.md](../assets/README.md). the above into, are described in [assets/README.md](../assets/README.md).
If disk 1 turns up, put it next to the others and run `skids info`.
Anything in a `track/` or `vehicles/` directory inside its archive is
picked up automatically.
+281 -49
View File
@@ -1,6 +1,6 @@
//! Front end: menu, race screens and results. //! 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::data::{palette, track, vehicle, GameData};
use crate::game::car::Controls; use crate::game::car::Controls;
use crate::game::{Driver, EntrantSetup, Phase, Race}; 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 struct Settings {
pub players: usize, pub players: usize,
pub track: usize, pub track: usize,
pub laps: usize, pub laps: usize,
pub cars: [usize; 2], pub cars: [usize; 2],
pub opponents: usize, pub opponents: usize,
/// Every car tows a caravan.
pub caravans: bool,
pub music: Music,
} }
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
@@ -62,6 +72,8 @@ enum Item {
Car1, Car1,
Car2, Car2,
Opponents, Opponents,
Caravans,
Music,
Quit, Quit,
} }
@@ -73,14 +85,29 @@ enum Screen {
race: Box<Race>, race: Box<Race>,
cams: [(f32, f32); 2], cams: [(f32, f32); 2],
paused: bool, paused: bool,
sounds: RaceSounds,
}, },
Results { Results {
race: Box<Race>, 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 struct App {
pub data: GameData, 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, pub settings: Settings,
screen: Screen, screen: Screen,
pub frame: Frame, pub frame: Frame,
@@ -95,21 +122,35 @@ pub struct App {
impl App { impl App {
pub fn new(data: GameData, audio: Audio) -> App { pub fn new(data: GameData, audio: Audio) -> App {
let cars = [ let drivable: Vec<usize> = (0..data.vehicles.len())
data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0), .filter(|&i| !data.vehicles[i].trailer)
.collect();
let pick = |id: &str| {
data.vehicles data.vehicles
.iter() .iter()
.position(|v| v.id == "mini") .position(|v| v.id == id)
.unwrap_or(0), .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 { App {
data, data,
cars: drivable,
title,
settings: Settings { settings: Settings {
players: 1, players: 1,
track: 0, track: 0,
laps: 3, laps: 3,
cars, cars,
opponents: 3, opponents: 3,
caravans: false,
music,
}, },
screen: Screen::Menu { cursor: 0 }, screen: Screen::Menu { cursor: 0 },
frame: Frame::new(), frame: Frame::new(),
@@ -134,7 +175,14 @@ impl App {
if self.settings.players == 2 { if self.settings.players == 2 {
v.push(Item::Car2); 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 v
} }
@@ -148,7 +196,11 @@ impl App {
seed: u32, seed: u32,
) -> Result<Race, String> { ) -> Result<Race, String> {
let track = self.data.load_track(track_index)?; 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(); let mut setup = Vec::new();
for (p, &car) in cars.iter().enumerate().take(humans) { for (p, &car) in cars.iter().enumerate().take(humans) {
setup.push(EntrantSetup { setup.push(EntrantSetup {
@@ -156,6 +208,7 @@ impl App {
driver: Driver::Human(p), driver: Driver::Human(p),
team: p, team: p,
name: format!("PLAYER {}", p + 1), name: format!("PLAYER {}", p + 1),
trailer,
}); });
} }
let mut rng = seed.wrapping_mul(2654435761) | 1; let mut rng = seed.wrapping_mul(2654435761) | 1;
@@ -164,10 +217,11 @@ impl App {
rng ^= rng >> 17; rng ^= rng >> 17;
rng ^= rng << 5; rng ^= rng << 5;
setup.push(EntrantSetup { setup.push(EntrantSetup {
vehicle: rng as usize % nv, vehicle: self.cars[rng as usize % self.cars.len()],
driver: Driver::Cpu, driver: Driver::Cpu,
team: humans + i, team: humans + i,
name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(), name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(),
trailer,
}); });
} }
// Humans start at the back, as is tradition. // Humans start at the back, as is tradition.
@@ -189,9 +243,15 @@ impl App {
self.tick += 1; self.tick += 1;
match &mut self.screen { match &mut self.screen {
Screen::Menu { .. } => self.update_menu(input), Screen::Menu { .. } => self.update_menu(input),
Screen::Race { race, paused, .. } => { Screen::Race {
race,
paused,
sounds,
cams,
} => {
if input.hit(Key::Escape) { if input.hit(Key::Escape) {
self.audio.silence(); self.audio.silence();
self.audio.set_music(self.settings.music != Music::Off);
self.screen = Screen::Menu { cursor: 0 }; self.screen = Screen::Menu { cursor: 0 };
return; return;
} }
@@ -216,22 +276,10 @@ impl App {
}; };
let p2 = input.controls(Key::W, Key::S, Key::A, Key::D); let p2 = input.controls(Key::W, Key::S, Key::A, Key::D);
race.step(&[p1, p2]); race.step(&[p1, p2]);
for (i, e) in race race_sounds(&self.audio, race, sounds, cams, input, self.tick);
.entrants
.iter()
.filter(|e| matches!(e.driver, Driver::Human(_)))
.enumerate()
{
let c = &e.car;
let skid = if c.on_road {
((c.slip - 50.0) / 100.0).clamp(0.0, 1.0)
} else {
0.0
};
self.audio.set_voice(i, true, c.rpm, skid, c.bump);
}
if race.phase == Phase::Done { if race.phase == Phase::Done {
self.audio.silence(); self.audio.silence();
self.audio.set_music(self.settings.music != Music::Off);
let Screen::Race { race, .. } = let Screen::Race { race, .. } =
std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 }) std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 })
else { else {
@@ -265,7 +313,6 @@ impl App {
let delta = input.hit(Key::Right) as i32 - input.hit(Key::Left) as i32; let delta = input.hit(Key::Right) as i32 - input.hit(Key::Left) as i32;
let item = items[cur]; let item = items[cur];
let s = &mut self.settings; 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; let wrap = |v: usize, n: usize, d: i32| (v as i32 + d).rem_euclid(n as i32) as usize;
if delta != 0 { if delta != 0 {
match item { match item {
@@ -275,9 +322,23 @@ impl App {
let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1); let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1);
s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)]; s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)];
} }
Item::Car1 => s.cars[0] = wrap(s.cars[0], nv, delta), Item::Car1 | Item::Car2 => {
Item::Car2 => s.cars[1] = wrap(s.cars[1], nv, delta), 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::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); s.opponents = s.opponents.min(8 - s.players);
@@ -294,6 +355,8 @@ impl App {
| Item::Car2 | Item::Car2
| Item::Laps | Item::Laps
| Item::Players | Item::Players
| Item::Caravans
| Item::Music
| Item::Opponents => { | Item::Opponents => {
let s = &self.settings; let s = &self.settings;
match self.build_race( match self.build_race(
@@ -310,7 +373,9 @@ impl App {
race: Box::new(r), race: Box::new(r),
cams, cams,
paused: false, paused: false,
sounds: RaceSounds::default(),
}; };
self.audio.set_music(self.settings.music == Music::Always);
if let Screen::Race { race, cams, .. } = &mut screen { if let Screen::Race { race, cams, .. } = &mut screen {
for (i, cam) in cams.iter_mut().enumerate() { for (i, cam) in cams.iter_mut().enumerate() {
if let Some(e) = if let Some(e) =
@@ -400,8 +465,17 @@ impl App {
f.clear(0x101820); f.clear(0x101820);
} }
f.text_centered(160, 10, "SKIDS", YELLOW, 4); let top = match &self.title {
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1); 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 cursor = cursor.min(items.len() - 1);
let s = &self.settings; let s = &self.settings;
@@ -414,9 +488,20 @@ impl App {
Item::Car1 => format!("P1 CAR < {} >", self.data.vehicles[s.cars[0]].name), 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::Car2 => format!("P2 CAR < {} >", self.data.vehicles[s.cars[1]].name),
Item::Opponents => format!("OPPONENTS < {} >", s.opponents), 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(), 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 }; let colour = if i == cursor { YELLOW } else { WHITE };
if i == cursor { if i == cursor {
f.text_shadow(8, y, ">", RED, 1); f.text_shadow(8, y, ">", RED, 1);
@@ -430,10 +515,22 @@ impl App {
if let Some(th) = self.thumb(track).cloned() { if let Some(th) = self.thumb(track).cloned() {
let f = &mut self.frame; let f = &mut self.frame;
f.frame_rect( 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, 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. // Car preview, slowly turning.
@@ -443,33 +540,155 @@ impl App {
let v = &self.data.vehicles[s.cars[which]]; let v = &self.data.vehicles[s.cars[which]];
let heading = (self.tick / 4) as usize % vehicle::HEADINGS; let heading = (self.tick / 4) as usize % vehicle::HEADINGS;
let pal = palette::car_palette(which); let pal = palette::car_palette(which);
let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, 150); let by = top + THUMB_H as i32 + 16;
f.fill(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), 0x203018); let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, by + 3);
f.frame_rect(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), WHITE); 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); 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_shadow(v.frame(vehicle::LEVEL, heading), cx + 3, cy + 3, clip);
f.sprite(v.frame(vehicle::LEVEL, heading), &pal, cx, cy, 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 { let help = if self.settings.players == 2 {
"P1: ARROWS P2: W A S D" "P1: ARROWS P2: W A S D"
} else { } else {
"DRIVE: ARROWS OR W A S D" "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( f.text_centered(
160, 160,
236, 242,
"UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO", "UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO",
GREY, GREY,
1, 1,
); );
if let Some(st) = &self.status { 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( pub fn draw_race(
f: &mut Frame, f: &mut Frame,
data: &GameData, data: &GameData,
@@ -536,24 +755,37 @@ pub fn draw_world(
} }
} }
let mut order: Vec<usize> = (0..race.entrants.len()).collect(); // Cars and caravans, back to front.
order.sort_by(|&a, &b| { let mut order: Vec<(f32, usize, bool)> = Vec::new();
race.entrants[a] for (i, e) in race.entrants.iter().enumerate() {
.car order.push((e.car.screen_pos().1, i, false));
.screen_pos() if let Some(t) = e.trailer {
.1 order.push((track::to_screen(t.x, t.y).1, i, true));
.total_cmp(&race.entrants[b].car.screen_pos().1) }
}); }
for i in order { order.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, i, is_trailer) in order {
let e = &race.entrants[i]; let e = &race.entrants[i];
let c = &e.car; 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 v = &data.vehicles[c.vehicle];
let (sx, sy) = c.screen_pos(); let (sx, sy) = c.screen_pos();
let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2); 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 y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2);
let pix = v.frame(c.tilt, vehicle::heading_frame(c.heading)); let pix = v.frame(c.tilt, vehicle::heading_frame(c.heading));
f.sprite_shadow(pix, x + 3, y + 2, view); 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 { if let Driver::Human(p) = e.driver {
let tag = format!("P{}", p + 1); let tag = format!("P{}", p + 1);
let tx = x + 32 - 5; let tx = x + 32 - 5;
+323 -92
View File
@@ -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 //! Each human player has an engine voice (the `motor3` loop, pitched with
//! is synthesised. Parameters are passed to the audio thread through atomics. //! 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 crate::formats::iff::Sound;
use std::sync::Arc; use crate::formats::med::Song;
use crate::music::Player;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
pub const VOICES: usize = 2; pub const VOICES: usize = 2;
#[derive(Default)] #[derive(Clone, Copy, Debug, PartialEq)]
struct VoiceParams { pub enum Sfx {
rpm: AtomicU32, /// Countdown tick.
skid: AtomicU32, Beep,
bump: AtomicU32, /// Lights out.
active: AtomicU32, 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)] #[derive(Default)]
pub struct Shared { struct Voice {
voices: [VoiceParams; VOICES], active: bool,
master: AtomicU32, rpm: f32,
skid: f32,
level: f32,
engine: Option<Play>,
tyres: Option<Play>,
synth: Synth,
} }
fn store(a: &AtomicU32, v: f32) { struct Mixer {
a.store(v.to_bits(), Ordering::Relaxed); 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 { impl Mixer {
f32::from_bits(a.load(Ordering::Relaxed)) 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 { pub struct Audio {
shared: Arc<Shared>, mixer: Arc<Mutex<Mixer>>,
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
_stream: Option<cpal::Stream>, _stream: Option<cpal::Stream>,
} }
impl Audio { impl Audio {
pub fn new(enabled: bool) -> Audio { pub fn new(enabled: bool, bank: Bank, song: Option<Arc<Song>>) -> Audio {
let shared = Arc::new(Shared::default()); let mixer = Arc::new(Mutex::new(Mixer {
store(&shared.master, 0.5); 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")] #[cfg(feature = "audio")]
{ {
let stream = if enabled { let stream = if enabled {
backend::open(shared.clone()) backend::open(mixer.clone())
} else { } else {
None None
}; };
Audio { Audio {
shared, mixer,
_stream: stream, _stream: stream,
} }
} }
#[cfg(not(feature = "audio"))] #[cfg(not(feature = "audio"))]
{ {
let _ = enabled; let _ = enabled;
Audio { shared } Audio { mixer }
} }
} }
pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32, bump: f32) { fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
let v = &self.shared.voices[i]; let mut m = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
store(&v.active, if active { 1.0 } else { 0.0 }); f(&mut m)
store(&v.rpm, rpm); }
store(&v.skid, skid);
// Knocks are latched until the audio thread consumes them. pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32) {
if bump > load(&v.bump) { self.with(|m| {
store(&v.bump, bump); 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) { pub fn silence(&self) {
for i in 0..VOICES { 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)] #[derive(Default)]
struct Synth { struct Synth {
phase: f32, phase: f32,
sub_phase: f32, sub_phase: f32,
rpm: f32,
skid: f32,
knock: f32,
noise: u32,
lp: f32, lp: f32,
noise: u32,
skid_lp: f32, skid_lp: f32,
level: f32,
} }
impl Synth { impl Synth {
fn sample(&mut self, p: &VoiceParams, rate: f32) -> f32 { fn white(&mut self) -> f32 {
let target_level = load(&p.active); self.noise = self
self.level += (target_level - self.level) * 0.0008; .noise
self.rpm += (load(&p.rpm) - self.rpm) * 0.002; .wrapping_mul(1_664_525)
self.skid += (load(&p.skid) - self.skid) * 0.004; .wrapping_add(1_013_904_223);
let b = load(&p.bump); (self.noise >> 8) as f32 / (1u32 << 23) as f32 - 1.0
if b > 0.0 { }
self.knock = self.knock.max(b);
store(&p.bump, 0.0);
}
self.noise ^= self.noise << 13; fn engine(&mut self, rpm: f32, rate: f32) -> f32 {
self.noise ^= self.noise >> 17; let freq = 38.0 + rpm * 150.0;
self.noise ^= self.noise << 5;
let white = (self.noise as f32 / u32::MAX as f32) * 2.0 - 1.0;
// Engine: a buzzy pulse with a sub-octave, low-passed.
let freq = 38.0 + self.rpm * 150.0;
self.phase = (self.phase + freq / rate).fract(); self.phase = (self.phase + freq / rate).fract();
self.sub_phase = (self.sub_phase + freq * 0.5 / 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 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 sub = if self.sub_phase < 0.5 { 0.5 } else { -0.5 };
let raw = pulse + sub + white * 0.15; let raw = pulse + sub + self.white() * 0.15;
self.lp += (raw - self.lp) * (0.06 + self.rpm * 0.12); self.lp += (raw - self.lp) * (0.06 + rpm * 0.12);
let engine = self.lp * 0.32; self.lp * 0.32
}
// Tyres: band-limited noise. fn squeal(&mut self, skid: f32) -> f32 {
self.skid_lp += (white - self.skid_lp) * 0.35; let w = self.white();
let squeal = (white - self.skid_lp) * self.skid.min(1.0) * 0.22; self.skid_lp += (w - self.skid_lp) * 0.35;
(w - self.skid_lp) * skid.min(1.0) * 0.22
let knock = white * self.knock * 0.6;
self.knock *= 0.9985;
(engine + squeal + knock) * self.level
} }
} }
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
mod backend { mod backend {
use super::{load, Shared, Synth, VOICES}; use super::Mixer;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample}; 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 host = cpal::default_host();
let device = host.default_output_device()?; let device = host.default_output_device()?;
let config = device.default_output_config().ok()?; let config = device.default_output_config().ok()?;
let fmt = config.sample_format(); let fmt = config.sample_format();
let config: cpal::StreamConfig = config.into(); let config: cpal::StreamConfig = config.into();
mixer.lock().ok()?.rate = config.sample_rate.0 as f32;
let stream = match fmt { let stream = match fmt {
cpal::SampleFormat::F32 => build::<f32>(&device, &config, shared), cpal::SampleFormat::F32 => build::<f32>(&device, &config, mixer),
cpal::SampleFormat::I16 => build::<i16>(&device, &config, shared), cpal::SampleFormat::I16 => build::<i16>(&device, &config, mixer),
cpal::SampleFormat::U16 => build::<u16>(&device, &config, shared), cpal::SampleFormat::U16 => build::<u16>(&device, &config, mixer),
_ => None, _ => None,
}?; }?;
stream.play().ok()?; stream.play().ok()?;
@@ -155,33 +368,20 @@ mod backend {
fn build<T>( fn build<T>(
device: &cpal::Device, device: &cpal::Device,
config: &cpal::StreamConfig, config: &cpal::StreamConfig,
shared: Arc<Shared>, mixer: Arc<Mutex<Mixer>>,
) -> Option<cpal::Stream> ) -> Option<cpal::Stream>
where where
T: SizedSample + FromSample<f32>, T: SizedSample + FromSample<f32>,
{ {
let rate = config.sample_rate.0 as f32;
let channels = config.channels as usize; 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 device
.build_output_stream( .build_output_stream(
config, config,
move |data: &mut [T], _| { 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) { for frame in data.chunks_mut(channels) {
let mut l = 0.0; let (l, r) = m.frame();
let mut r = 0.0; let (l, r) = (l.clamp(-1.0, 1.0), r.clamp(-1.0, 1.0));
for (i, s) in synths.iter_mut().enumerate() {
let v = s.sample(&shared.voices[i], rate);
// Player one leans left, player two right.
let pan = if i == 0 { 0.35 } else { 0.65 };
l += v * (1.0 - pan) * 2.0;
r += v * pan * 2.0;
}
let (l, r) = ((l * master).clamp(-1.0, 1.0), (r * master).clamp(-1.0, 1.0));
for (c, out) in frame.iter_mut().enumerate() { for (c, out) in frame.iter_mut().enumerate() {
let v = if channels == 1 { let v = if channels == 1 {
(l + r) * 0.5 (l + r) * 0.5
@@ -200,3 +400,34 @@ mod backend {
.ok() .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
View File
@@ -14,7 +14,8 @@ pub mod palette;
pub mod track; pub mod track;
pub mod vehicle; 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::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -80,11 +81,8 @@ impl FileSet {
Ok(entries) => { Ok(entries) => {
for e in entries { for e in entries {
match e.data { match e.data {
Some(d) => self.add(&e.path, d), Ok(d) => self.add(&e.path, d),
None => self.warnings.push(format!( Err(why) => self.warnings.push(format!("{path}: {}: {why}", e.path)),
"{path}: {} uses unsupported method {}",
e.path, e.method
)),
} }
} }
} }
@@ -116,7 +114,11 @@ fn logical_key(path: &str) -> Option<String> {
return None; return None;
} }
let (dir, name) = (&parts[n - 2], &parts[n - 1]); 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. /// Unpacks CrunchMania data, passing plain data through untouched.
@@ -144,6 +146,12 @@ pub struct GameData {
pub tracks: Vec<TrackEntry>, pub tracks: Vec<TrackEntry>,
pub track_files: Vec<TrackFile>, pub track_files: Vec<TrackFile>,
pub vehicles: Vec<Vehicle>, 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 files: FileSet,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -151,7 +159,17 @@ pub struct GameData {
impl GameData { impl GameData {
/// Loads the built-in data, overlaid with whatever is under `dir`. /// Loads the built-in data, overlaid with whatever is under `dir`.
pub fn load(dir: Option<&Path>) -> Result<GameData, String> { 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 { if let Some(dir) = dir {
files.scan(dir); files.scan(dir);
} }
@@ -205,6 +223,7 @@ impl GameData {
let Some(id) = key let Some(id) = key
.strip_prefix("vehicles/") .strip_prefix("vehicles/")
.and_then(|n| n.strip_suffix(".png")) .and_then(|n| n.strip_suffix(".png"))
.filter(|n| !n.ends_with("-menu"))
else { else {
continue; continue;
}; };
@@ -212,7 +231,14 @@ impl GameData {
.get(&format!("vehicles/{id}.txt")) .get(&format!("vehicles/{id}.txt"))
.map(String::from_utf8_lossy); .map(String::from_utf8_lossy);
match Vehicle::from_assets(id, data, txt.as_deref()) { 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}")), Err(e) => warnings.push(format!("{key}: {e}")),
} }
} }
@@ -227,7 +253,14 @@ impl GameData {
continue; continue;
} }
match Vehicle::parse(name, data) { 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")), Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
Err(e) => warnings.push(format!("{key}: {e}")), Err(e) => warnings.push(format!("{key}: {e}")),
} }
@@ -248,10 +281,14 @@ impl GameData {
} }
)); ));
} }
let (sounds, music, pictures) = load_media(&files, &mut warnings);
Ok(GameData { Ok(GameData {
tracks, tracks,
track_files, track_files,
vehicles, vehicles,
sounds,
music,
pictures,
files, files,
warnings, 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. /// Sort key that orders TRACK2-10 after TRACK2-9.
fn natural_key(name: &str) -> Vec<(String, u64)> { fn natural_key(name: &str) -> Vec<(String, u64)> {
let mut out = Vec::new(); let mut out = Vec::new();
+3 -5
View File
@@ -1,10 +1,8 @@
//! Colour palettes. //! Colour palettes.
//! //!
//! The track and vehicle files carry no colour tables; the retail game sets //! Each original track file carries the 16-colour palette its six tracks
//! them from its main executable, which lives on disk 1. Until that disk is //! share (see `track.rs`). `TRACK_DEFAULT` is only a fallback for track
//! available we use a reconstructed palette. Indices on the retail tracks are //! pictures that arrive without one: indices 0-7 scenery, 8-15 road.
//! 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 track asset's PNG carries its own palette, so the usual way to //! 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 //! recolour a track is to edit that. Palettes can also be dropped in as text
+36 -3
View File
@@ -3,6 +3,10 @@
//! File layout: //! File layout:
//! 0x00 7 x BE32 end offsets. Section 0 runs from 0x1c to the first offset //! 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. //! 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): //! Each track section (281240 bytes once decrunched):
//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels //! 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 struct TrackFile {
pub name: String, pub name: String,
pub number: u32, pub number: u32,
pub palette: [Rgb; 16],
pub set_name: String,
/// Packed track sections (index 0 = first playable track). /// Packed track sections (index 0 = first playable track).
pub sections: Vec<Vec<u8>>, pub sections: Vec<Vec<u8>>,
} }
@@ -67,6 +73,8 @@ impl Gate {
pub struct Track { pub struct Track {
pub name: String, pub name: String,
/// The set of six tracks this one belongs to.
pub set_name: String,
/// Palette indices, W x H. /// Palette indices, W x H.
pub pixels: Vec<u8>, pub pixels: Vec<u8>,
pub palette: [Rgb; 16], pub palette: [Rgb; 16],
@@ -104,9 +112,13 @@ impl TrackFile {
.trim_start_matches(|c: char| !c.is_ascii_digit()) .trim_start_matches(|c: char| !c.is_ascii_digit())
.parse() .parse()
.unwrap_or(u32::MAX); .unwrap_or(u32::MAX);
let (palette, set_name) = file_header(&d[SECTIONS * 4..ends[0]])
.unwrap_or((palette::TRACK_DEFAULT, String::new()));
Ok(TrackFile { Ok(TrackFile {
name: name.to_string(), name: name.to_string(),
number, number,
palette,
set_name,
sections, sections,
}) })
} }
@@ -117,10 +129,26 @@ impl TrackFile {
pub fn decode(&self, section: usize) -> Result<Track, String> { pub fn decode(&self, section: usize) -> Result<Track, String> {
let raw = unpack(&self.sections[section]).map_err(|e| e.to_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 { impl Track {
pub fn from_section(name: String, d: &[u8]) -> Result<Track, String> { pub fn from_section(name: String, d: &[u8]) -> Result<Track, String> {
if d.len() < SECTION_SIZE { if d.len() < SECTION_SIZE {
@@ -168,6 +196,7 @@ impl Track {
name, name,
pixels, pixels,
palette: palette::TRACK_DEFAULT, palette: palette::TRACK_DEFAULT,
set_name: String::new(),
height, height,
gates, gates,
start, start,
@@ -236,6 +265,7 @@ impl Track {
let mut gates = Vec::new(); let mut gates = Vec::new();
let mut start = (0.0, 0.0); let mut start = (0.0, 0.0);
let mut flags = 0; let mut flags = 0;
let mut set_name = String::new();
let mut height = Vec::with_capacity(HM_W * HM_H); let mut height = Vec::with_capacity(HM_W * HM_H);
let mut in_height = false; let mut in_height = false;
for (n, line) in txt.lines().enumerate() { for (n, line) in txt.lines().enumerate() {
@@ -287,6 +317,7 @@ impl Track {
.ok_or_else(|| err("bad flags"))? .ok_or_else(|| err("bad flags"))?
} }
"height" => in_height = true, "height" => in_height = true,
"set" => set_name = line[3..].trim().to_string(),
other => return Err(err(&format!("unknown entry `{other}`"))), other => return Err(err(&format!("unknown entry `{other}`"))),
} }
} }
@@ -304,6 +335,7 @@ impl Track {
name: name.to_string(), name: name.to_string(),
pixels: img.pixels, pixels: img.pixels,
palette: pal, palette: pal,
set_name,
height, height,
gates, gates,
start, 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!("gate {} {} {} {}\n", g.a.0, g.a.1, g.b.0, g.b.1));
} }
t.push_str(&format!( 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\ # height: 64 rows of 64 values, ground height x 256. Each value covers\n\
# 16x8 pixels of the picture.\nheight\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) { for row in self.height.chunks(HM_W) {
let line: Vec<String> = row.iter().map(|v| v.to_string()).collect(); let line: Vec<String> = row.iter().map(|v| v.to_string()).collect();
+114 -17
View File
@@ -6,6 +6,11 @@
//! Its colours are only a preview; cars are recoloured per player. The text //! Its colours are only a preview; cars are recoloured per player. The text
//! file holds the display name and handling. //! 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`): //! Original data (`vehicles/*.aga`):
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes. //! 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 //! 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 struct Vehicle {
pub id: String, pub id: String,
pub name: 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, pub spec: Spec,
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H. /// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
pub frames: Vec<u8>, pub frames: Vec<u8>,
@@ -166,12 +178,58 @@ impl Vehicle {
let id = file.trim_end_matches(".aga").to_lowercase(); let id = file.trim_end_matches(".aga").to_lowercase();
Ok(Some(Vehicle { Ok(Some(Vehicle {
name: display_name(&id), name: display_name(&id),
driver: String::new(),
trailer: id == "caravan",
original: Vec::new(),
menu_picture: None,
spec: default_spec(&id), spec: default_spec(&id),
id, id,
frames, 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. /// 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> { pub fn from_assets(id: &str, png: &[u8], txt: Option<&str>) -> Result<Vehicle, String> {
let img = image::read_indexed(png, &preview_palette())?; let img = image::read_indexed(png, &preview_palette())?;
@@ -198,6 +256,10 @@ impl Vehicle {
let mut v = Vehicle { let mut v = Vehicle {
id: id.to_string(), id: id.to_string(),
name: display_name(id), name: display_name(id),
driver: String::new(),
trailer: id == "caravan",
original: Vec::new(),
menu_picture: None,
spec: default_spec(id), spec: default_spec(id),
frames, frames,
}; };
@@ -227,6 +289,13 @@ impl Vehicle {
"# Skids vehicle settings. Lines starting with # are comments.\n\ "# Skids vehicle settings. Lines starting with # are comments.\n\
# The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\ # The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\
name {name}\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 speed on tarmac, world pixels per second\n\
top {top}\n\ top {top}\n\
# acceleration and braking, world pixels per second squared\n\ # acceleration and braking, world pixels per second squared\n\
@@ -242,6 +311,14 @@ impl Vehicle {
offroad {offroad}\n", offroad {offroad}\n",
id = self.id, id = self.id,
name = self.name, 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, top = s.top,
accel = s.accel, accel = s.accel,
brake = s.brake, brake = s.brake,
@@ -255,16 +332,32 @@ impl Vehicle {
fn apply_settings(&mut self, txt: &str) -> Result<(), String> { fn apply_settings(&mut self, txt: &str) -> Result<(), String> {
for (n, line) in txt.lines().enumerate() { for (n, line) in txt.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim(); 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; continue;
} }
return Err(format!("line {}: expected `key value`", n + 1)); "driver" => {
}; self.driver = val.to_uppercase();
let val = val.trim(); continue;
if key == "name" { }
self.name = val.to_uppercase(); "trailer" => {
continue; 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 let num: f32 = val
.parse() .parse()
@@ -338,24 +431,28 @@ mod tests {
#[test] #[test]
fn settings_round_trip() { fn settings_round_trip() {
let mut v = Vehicle { let blank = |frames| Vehicle {
id: "f1".into(), id: "f1".into(),
name: "X".into(), name: String::new(),
driver: String::new(),
trailer: false,
original: Vec::new(),
menu_picture: None,
spec: BASE_SPEC, 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.spec.grip = 6.25;
v.name = "FORMULA 1".into(); v.name = "FORMULA 1".into();
let txt = v.settings_text(); let txt = v.settings_text();
let mut w = Vehicle { let mut w = blank(Vec::new());
id: "f1".into(),
name: String::new(),
spec: BASE_SPEC,
frames: Vec::new(),
};
w.apply_settings(&txt).unwrap(); w.apply_settings(&txt).unwrap();
assert_eq!(w.spec, v.spec); assert_eq!(w.spec, v.spec);
assert_eq!(w.name, "FORMULA 1"); 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()); assert!(w.apply_settings("grip fast").is_err());
} }
+296
View File
@@ -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
View File
@@ -1,9 +1,11 @@
//! LHA/LZH archive reader (header levels 0, 1 and 2). //! LHA/LZH archive reader (header levels 0, 1 and 2).
//! //!
//! The game disks only use the `-lh0-` (stored) method because every file is //! Supports the stored (`-lh0-`, `-lz4-`) and LZSS/Huffman (`-lh5-`, `-lh6-`,
//! already CrunchMania-packed, so compressed methods are reported rather than //! `-lh7-`) methods. Every decoded file is checked against the header CRC.
//! decoded. //! 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 super::{latin1, le16, le32};
use std::fmt; use std::fmt;
@@ -28,8 +30,8 @@ pub struct LhaEntry {
/// Path inside the archive, `/`-separated. /// Path inside the archive, `/`-separated.
pub path: String, pub path: String,
pub method: String, pub method: String,
/// File contents for stored entries, `None` for methods we do not decode. /// File contents, or why they could not be read.
pub data: Option<Vec<u8>>, pub data: Result<Vec<u8>, String>,
} }
pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> { 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 method = latin1(method);
let mut packed = le32(d, p + 7) as usize; let mut packed = le32(d, p + 7) as usize;
let original = le32(d, p + 11) as usize;
let level = d[p + 20]; let level = d[p + 20];
let mut crc = None;
let mut name = String::new(); let mut name = String::new();
let mut dir = 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)); return Err(LhaError::Truncated(p));
} }
name = latin1(&d[p + 22..p + 22 + name_len]); 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; let mut q = p + header_len;
if level == 1 { if level == 1 {
// Extended headers follow the base header and are counted // 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 { if p + header_len > d.len() || header_len < 26 {
return Err(LhaError::Truncated(p)); return Err(LhaError::Truncated(p));
} }
crc = Some(le16(d, p + 21));
let mut q = p + 26; let mut q = p + 26;
let mut next = le16(d, p + 24) as usize; let mut next = le16(d, p + 24) as usize;
while next != 0 { while next != 0 {
@@ -101,10 +109,18 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
return Err(LhaError::Truncated(data_start)); return Err(LhaError::Truncated(data_start));
} }
let path = join_path(&dir, &name); let path = join_path(&dir, &name);
let data = match method.as_str() { let body = &d[data_start..data_end];
"-lh0-" | "-lz4-" => Some(d[data_start..data_end].to_vec()), let decoded = match method.as_str() {
_ => None, "-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-" { if method != "-lhd-" {
out.push(LhaEntry { path, method, data }); out.push(LhaEntry { path, method, data });
} }
@@ -149,7 +165,7 @@ mod tests {
h[20] = 0; h[20] = 0;
h[21] = name.len() as u8; h[21] = name.len() as u8;
h.extend_from_slice(name.as_bytes()); 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[0] = (h.len() - 2) as u8;
h.extend_from_slice(body); h.extend_from_slice(body);
h h
@@ -163,7 +179,7 @@ mod tests {
let e = parse(&arc).unwrap(); let e = parse(&arc).unwrap();
assert_eq!(e.len(), 2); assert_eq!(e.len(), 2);
assert_eq!(e[0].path, "dir/a.bin"); assert_eq!(e[0].path, "dir/a.bin");
assert_eq!(e[0].data.as_deref(), Some(&b"hello"[..])); assert_eq!(e[0].data.as_deref(), Ok(&b"hello"[..]));
assert_eq!(e[1].data.as_deref(), Some(&b"world!"[..])); assert_eq!(e[1].data.as_deref(), Ok(&b"world!"[..]));
} }
} }
+307
View File
@@ -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));
}
}

Some files were not shown because too many files have changed in this diff Show More