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.
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 166 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 148 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 301 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 365 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 398 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 360 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 376 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 341 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 412 B |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 381 B |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!"[..]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||