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
|
||||
*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
|
||||
of the six-disk set) into editable files in [`assets/`](assets/README.md).
|
||||
The images themselves are no longer kept in this repository.
|
||||
The game's data was converted from the six original ADF disk images into
|
||||
editable files in [`assets/`](assets/README.md). The images themselves
|
||||
aren't kept in this repository.
|
||||
|
||||
- **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.
|
||||
- **Each car:** a PNG sprite sheet, plus a text file with its name and
|
||||
handling.
|
||||
- **Each car:** a PNG sprite sheet and menu picture, plus a text file with
|
||||
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
|
||||
`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
|
||||
|
||||
- All 24 tracks across the four track files. Each has its isometric height
|
||||
map and lap gates.
|
||||
- All 8 vehicles (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police,
|
||||
Cow), with 32 headings and 25 body-tilt frames for hills.
|
||||
- All 24 tracks across the four track files, in their original palettes.
|
||||
Each has its isometric height map and lap gates.
|
||||
- All 9 cars (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police,
|
||||
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.
|
||||
- Power-slide handling, slopes, car-to-car bumping, and skid marks burnt
|
||||
into the track.
|
||||
- Lap and position tracking, lap times, and a results table.
|
||||
- Procedural engine, tyre and crash sound.
|
||||
- Engine note pitched with revs, tyre screech, crashes, backfires, a horn
|
||||
and start beeps, all from the original samples.
|
||||
|
||||
## What's reconstructed
|
||||
|
||||
Disk 1 of the six-disk set is missing from this archive. It held the
|
||||
executable, palettes, sound and music. As a result:
|
||||
The executable is on disk 1, but its game logic, a compiled Blitz Basic 2
|
||||
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
|
||||
original, not a translation of the original 68000 code.
|
||||
- **Colours** come from a reconstructed palette, built around how the
|
||||
tracks use their 16 colour indices. If you have the real palettes, put
|
||||
them in a `palettes/` folder next to the disks as `.pal` files (16 lines
|
||||
of `RRGGBB`):
|
||||
- `track1-3.pal` for one track
|
||||
- `track1.pal` for all six tracks in a file
|
||||
- `default.pal` for everything
|
||||
- **Sound** is synthesised.
|
||||
|
||||
If you find disk 1, drop it in with the others. Anything it contains under
|
||||
`track/` or `vehicles/` is picked up automatically. The format notes in
|
||||
[docs/FORMATS.md](docs/FORMATS.md) cover everything reverse engineered so
|
||||
far.
|
||||
Each car's original settings file has eleven handling numbers whose
|
||||
meaning lives in that code. They're kept in the car's settings asset for
|
||||
when it is decoded. [docs/FORMATS.md](docs/FORMATS.md) covers everything
|
||||
reverse engineered so far.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -76,6 +76,7 @@ exists both as an asset and on disk, the asset wins.
|
||||
| Accelerate | ↑ (or W in one-player games) | W |
|
||||
| Brake / reverse | ↓ (or S) | S |
|
||||
| Steer | ← → (or A D) | A D |
|
||||
| Horn | Right Shift | Left Shift |
|
||||
| Pause | P | |
|
||||
| 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 trace --data DIR --track 11 --out t.png # plot CPU racing lines
|
||||
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
|
||||
@@ -114,11 +117,12 @@ GitHub Actions builds all three platforms on every push
|
||||
```
|
||||
assets/ the game data as editable PNG and text files
|
||||
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/game/ physics, AI and race rules
|
||||
src/render/ software renderer at 320×256 (PAL low-res) and a bitmap font
|
||||
src/audio.rs synthesised sound (cpal)
|
||||
src/audio.rs sample mixer and sound effects (cpal)
|
||||
src/music.rs OctaMED player
|
||||
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
|
||||
number covers a 16 × 8 pixel cell of the picture. Slopes push cars
|
||||
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
|
||||
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
|
||||
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`
|
||||
|
||||
The display name and handling:
|
||||
The display name, driver and handling:
|
||||
|
||||
| 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 |
|
||||
| `accel` | acceleration |
|
||||
| `brake` | braking |
|
||||
@@ -82,7 +93,39 @@ The display name and handling:
|
||||
| `mass` | weight in collisions |
|
||||
| `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)
|
||||
|
||||
|
||||
|
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 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.
|
||||
start 458 107
|
||||
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 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.
|
||||
start 461 295
|
||||
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 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.
|
||||
start 608 305
|
||||
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 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.
|
||||
start 608 305
|
||||
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 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.
|
||||
start 301 79
|
||||
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 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.
|
||||
start 434 354
|
||||
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 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.
|
||||
start 474 167
|
||||
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 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.
|
||||
start 339 117
|
||||
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 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.
|
||||
start 420 97
|
||||
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 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.
|
||||
start 689 201
|
||||
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 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.
|
||||
start 353 47
|
||||
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 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.
|
||||
start 353 47
|
||||
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 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.
|
||||
start 641 315
|
||||
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 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.
|
||||
start 475 288
|
||||
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 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.
|
||||
start 809 244
|
||||
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 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.
|
||||
start 796 294
|
||||
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 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.
|
||||
start 744 137
|
||||
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 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.
|
||||
start 356 188
|
||||
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 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.
|
||||
start 213 275
|
||||
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 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.
|
||||
start 122 216
|
||||
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 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.
|
||||
start 672 360
|
||||
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 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.
|
||||
start 800 350
|
||||
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 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.
|
||||
start 362 264
|
||||
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 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.
|
||||
start 501 153
|
||||
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.
|
||||
# The sprite sheet is cow.png: 32 headings across, 25 tilts down.
|
||||
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 170
|
||||
# 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.
|
||||
# The sprite sheet is f1.png: 32 headings across, 25 tilts down.
|
||||
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 240
|
||||
# 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.
|
||||
# The sprite sheet is midget.png: 32 headings across, 25 tilts down.
|
||||
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 205
|
||||
# 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.
|
||||
# The sprite sheet is mini.png: 32 headings across, 25 tilts down.
|
||||
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 190
|
||||
# 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.
|
||||
# The sprite sheet is police.png: 32 headings across, 25 tilts down.
|
||||
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 215
|
||||
# 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.
|
||||
# The sprite sheet is porsche.png: 32 headings across, 25 tilts down.
|
||||
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 225
|
||||
# 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.
|
||||
# The sprite sheet is truck.png: 32 headings across, 25 tilts down.
|
||||
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 195
|
||||
# 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.
|
||||
# The sprite sheet is vw.png: 32 headings across, 25 tilts down.
|
||||
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 185
|
||||
# acceleration and braking, world pixels per second squared
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
# Super Skidmarks data formats
|
||||
|
||||
Notes from reverse engineering the AGA/CD32 release of *Super Skidmarks*
|
||||
(Acid Software, 1995). Everything here was worked out from the data on disks
|
||||
2–6. Disk 1, which holds the executable, is missing from this archive, so
|
||||
anything that lives only in the executable (palettes, sound, handling) is
|
||||
unknown. See [Gaps](#gaps).
|
||||
(Acid Software, 1995), worked out from all six disks. The one thing not
|
||||
yet decoded is the executable's game logic. See [Gaps](#gaps).
|
||||
|
||||
All multi-byte values are big-endian unless noted.
|
||||
|
||||
## Disks
|
||||
|
||||
Each disk is a standard 880 KB double-density ADF (1760 blocks of 512 bytes)
|
||||
formatted as OFS (`DOS\0`). The `[HD]` in the file names refers to the
|
||||
hard-drive-installable release, not high-density disks. Each disk holds a
|
||||
single file:
|
||||
Each disk is a standard 880 KB double-density ADF (1760 blocks of 512
|
||||
bytes). Disk 1 is FFS (`DOS\1`); disks 2–6 are OFS (`DOS\0`). The `[HD]` in
|
||||
the file names refers to the hard-drive-installable release.
|
||||
|
||||
| Disk | File | Contents |
|
||||
|------|---------------|------------------------------------|
|
||||
| 1 | `smcd32a.lha` | *missing* |
|
||||
| Disk | Archive | Game files |
|
||||
|------|---------------|------------|
|
||||
| 1 | `smcd32a.lha` | Executables, `sfx/`, `gfx/`, the tune, `vehicles/*.set`, `Camaro.aga`, `caravan.aga` |
|
||||
| 2 | `smcd32b.lha` | `vehicles/cow.aga`, `track/TRACK1` |
|
||||
| 3 | `smcd32c.lha` | `track/TRACK2`, `vehicles/TRUCK.aga` |
|
||||
| 4 | `smcd32d.lha` | `track/TRACK3`, `vehicles/PORSCHE.aga` |
|
||||
| 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` |
|
||||
| 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` |
|
||||
| 6 | `smcd32f.lha` | `vehicles/F1.aga`, `Midget.aga`, `mini.aga`, `Police.aga` |
|
||||
|
||||
Each disk's archive stands on its own (the set isn't one archive split
|
||||
across disks). They use level-0 headers and the `-lh0-` method (stored),
|
||||
under a top-level `super_skidmarks_cd³²\` directory. Most members are
|
||||
individually packed with CrunchMania.
|
||||
Disk 1 also carries an Installer script that installs from all six disks,
|
||||
and a few AmigaDOS commands.
|
||||
|
||||
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
|
||||
|
||||
@@ -52,12 +58,25 @@ consumed to its first byte.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
So the four files hold 24 tracks. Each track section unpacks to 281240
|
||||
(`0x44A98`) bytes:
|
||||
So the four files hold 24 tracks.
|
||||
|
||||
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 |
|
||||
|----------|--------|----------|
|
||||
@@ -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
|
||||
up behind gate 0.
|
||||
|
||||
### Palette use
|
||||
### Road and scenery
|
||||
|
||||
The track pixel indices are ordered from dark to light. Indices 0–7 are
|
||||
scenery and 8–15 the driving surface, so the fourth bitplane is effectively
|
||||
a road mask. The game uses it as such here: off-road means less grip and
|
||||
Colours 8–15 are the driving surface on every track, so the fourth
|
||||
bitplane is effectively a road mask. Off the road means less grip and
|
||||
lower speed.
|
||||
|
||||
## Vehicles (`vehicles/*.aga`)
|
||||
## Vehicles
|
||||
|
||||
### Sprite banks (`vehicles/*.aga`)
|
||||
|
||||
CrunchMania-packed, 409600 bytes unpacked:
|
||||
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
|
||||
11.25° steps. In world terms, frame = 12 + (angle + 45°) / 11.25°.
|
||||
- **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level.
|
||||
Block 12 is the car on flat ground.
|
||||
- **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level. Block
|
||||
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
|
||||
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
|
||||
|
||||
These almost certainly live on the missing disk 1:
|
||||
|
||||
- The main executable, and with it the real handling model, AI, rules,
|
||||
menus and fonts. This port's versions are reconstructions.
|
||||
- Colour palettes. Neither the track nor the vehicle files carry any. The
|
||||
engine uses a reconstructed palette, and real ones can be dropped in as
|
||||
`.pal` files (see the README).
|
||||
- Sound effects and music.
|
||||
- Any tracks or vehicles beyond the 24 and 8 above.
|
||||
- **Game logic.** The handling, AI and rules in the executable aren't
|
||||
decoded; this port's versions are reconstructions. Decoding the `.set`
|
||||
values would be the first step towards the original handling.
|
||||
- **Unknown data:** the 12 bytes after each track file's palette, the bit
|
||||
pattern after the gates in each track section, the thumbnails, and
|
||||
`ball.spr`.
|
||||
|
||||
The engine's own editable formats, which `skids extract` converts all of
|
||||
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.
|
||||
|
||||
use crate::audio::Audio;
|
||||
use crate::audio::{Audio, Sfx};
|
||||
use crate::data::{palette, track, vehicle, GameData};
|
||||
use crate::game::car::Controls;
|
||||
use crate::game::{Driver, EntrantSetup, Phase, Race};
|
||||
@@ -45,12 +45,22 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Music {
|
||||
Menus,
|
||||
Always,
|
||||
Off,
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
pub players: usize,
|
||||
pub track: usize,
|
||||
pub laps: usize,
|
||||
pub cars: [usize; 2],
|
||||
pub opponents: usize,
|
||||
/// Every car tows a caravan.
|
||||
pub caravans: bool,
|
||||
pub music: Music,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -62,6 +72,8 @@ enum Item {
|
||||
Car1,
|
||||
Car2,
|
||||
Opponents,
|
||||
Caravans,
|
||||
Music,
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -73,14 +85,29 @@ enum Screen {
|
||||
race: Box<Race>,
|
||||
cams: [(f32, f32); 2],
|
||||
paused: bool,
|
||||
sounds: RaceSounds,
|
||||
},
|
||||
Results {
|
||||
race: Box<Race>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the race screen remembers between frames to decide when to play
|
||||
/// one-shot sounds.
|
||||
#[derive(Default)]
|
||||
struct RaceSounds {
|
||||
countdown: Option<u32>,
|
||||
go_played: bool,
|
||||
bumps: Vec<f32>,
|
||||
throttle: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub data: GameData,
|
||||
/// Indices of vehicles that can be driven (not trailers).
|
||||
cars: Vec<usize>,
|
||||
/// The title picture at half size, if the data has one.
|
||||
title: Option<(usize, usize, Vec<u32>)>,
|
||||
pub settings: Settings,
|
||||
screen: Screen,
|
||||
pub frame: Frame,
|
||||
@@ -95,21 +122,35 @@ pub struct App {
|
||||
|
||||
impl App {
|
||||
pub fn new(data: GameData, audio: Audio) -> App {
|
||||
let cars = [
|
||||
data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0),
|
||||
let drivable: Vec<usize> = (0..data.vehicles.len())
|
||||
.filter(|&i| !data.vehicles[i].trailer)
|
||||
.collect();
|
||||
let pick = |id: &str| {
|
||||
data.vehicles
|
||||
.iter()
|
||||
.position(|v| v.id == "mini")
|
||||
.unwrap_or(0),
|
||||
];
|
||||
.position(|v| v.id == id)
|
||||
.unwrap_or(drivable[0])
|
||||
};
|
||||
let cars = [pick("f1"), pick("mini")];
|
||||
let title = data.pictures.get("skidtoon").map(half_size);
|
||||
let music = if audio.has_music() {
|
||||
Music::Menus
|
||||
} else {
|
||||
Music::Off
|
||||
};
|
||||
audio.set_music(music != Music::Off);
|
||||
App {
|
||||
data,
|
||||
cars: drivable,
|
||||
title,
|
||||
settings: Settings {
|
||||
players: 1,
|
||||
track: 0,
|
||||
laps: 3,
|
||||
cars,
|
||||
opponents: 3,
|
||||
caravans: false,
|
||||
music,
|
||||
},
|
||||
screen: Screen::Menu { cursor: 0 },
|
||||
frame: Frame::new(),
|
||||
@@ -134,7 +175,14 @@ impl App {
|
||||
if self.settings.players == 2 {
|
||||
v.push(Item::Car2);
|
||||
}
|
||||
v.extend([Item::Opponents, Item::Quit]);
|
||||
v.push(Item::Opponents);
|
||||
if self.data.vehicles.iter().any(|v| v.trailer) {
|
||||
v.push(Item::Caravans);
|
||||
}
|
||||
if self.audio.has_music() {
|
||||
v.push(Item::Music);
|
||||
}
|
||||
v.push(Item::Quit);
|
||||
v
|
||||
}
|
||||
|
||||
@@ -148,7 +196,11 @@ impl App {
|
||||
seed: u32,
|
||||
) -> Result<Race, String> {
|
||||
let track = self.data.load_track(track_index)?;
|
||||
let nv = self.data.vehicles.len();
|
||||
let trailer = if self.settings.caravans {
|
||||
self.data.vehicles.iter().position(|v| v.trailer)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut setup = Vec::new();
|
||||
for (p, &car) in cars.iter().enumerate().take(humans) {
|
||||
setup.push(EntrantSetup {
|
||||
@@ -156,6 +208,7 @@ impl App {
|
||||
driver: Driver::Human(p),
|
||||
team: p,
|
||||
name: format!("PLAYER {}", p + 1),
|
||||
trailer,
|
||||
});
|
||||
}
|
||||
let mut rng = seed.wrapping_mul(2654435761) | 1;
|
||||
@@ -164,10 +217,11 @@ impl App {
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
setup.push(EntrantSetup {
|
||||
vehicle: rng as usize % nv,
|
||||
vehicle: self.cars[rng as usize % self.cars.len()],
|
||||
driver: Driver::Cpu,
|
||||
team: humans + i,
|
||||
name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(),
|
||||
trailer,
|
||||
});
|
||||
}
|
||||
// Humans start at the back, as is tradition.
|
||||
@@ -189,9 +243,15 @@ impl App {
|
||||
self.tick += 1;
|
||||
match &mut self.screen {
|
||||
Screen::Menu { .. } => self.update_menu(input),
|
||||
Screen::Race { race, paused, .. } => {
|
||||
Screen::Race {
|
||||
race,
|
||||
paused,
|
||||
sounds,
|
||||
cams,
|
||||
} => {
|
||||
if input.hit(Key::Escape) {
|
||||
self.audio.silence();
|
||||
self.audio.set_music(self.settings.music != Music::Off);
|
||||
self.screen = Screen::Menu { cursor: 0 };
|
||||
return;
|
||||
}
|
||||
@@ -216,22 +276,10 @@ impl App {
|
||||
};
|
||||
let p2 = input.controls(Key::W, Key::S, Key::A, Key::D);
|
||||
race.step(&[p1, p2]);
|
||||
for (i, e) in race
|
||||
.entrants
|
||||
.iter()
|
||||
.filter(|e| matches!(e.driver, Driver::Human(_)))
|
||||
.enumerate()
|
||||
{
|
||||
let c = &e.car;
|
||||
let skid = if c.on_road {
|
||||
((c.slip - 50.0) / 100.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.audio.set_voice(i, true, c.rpm, skid, c.bump);
|
||||
}
|
||||
race_sounds(&self.audio, race, sounds, cams, input, self.tick);
|
||||
if race.phase == Phase::Done {
|
||||
self.audio.silence();
|
||||
self.audio.set_music(self.settings.music != Music::Off);
|
||||
let Screen::Race { race, .. } =
|
||||
std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 })
|
||||
else {
|
||||
@@ -265,7 +313,6 @@ impl App {
|
||||
let delta = input.hit(Key::Right) as i32 - input.hit(Key::Left) as i32;
|
||||
let item = items[cur];
|
||||
let s = &mut self.settings;
|
||||
let nv = self.data.vehicles.len();
|
||||
let wrap = |v: usize, n: usize, d: i32| (v as i32 + d).rem_euclid(n as i32) as usize;
|
||||
if delta != 0 {
|
||||
match item {
|
||||
@@ -275,9 +322,23 @@ impl App {
|
||||
let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1);
|
||||
s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)];
|
||||
}
|
||||
Item::Car1 => s.cars[0] = wrap(s.cars[0], nv, delta),
|
||||
Item::Car2 => s.cars[1] = wrap(s.cars[1], nv, delta),
|
||||
Item::Car1 | Item::Car2 => {
|
||||
let slot = (item == Item::Car2) as usize;
|
||||
let at = self
|
||||
.cars
|
||||
.iter()
|
||||
.position(|&c| c == s.cars[slot])
|
||||
.unwrap_or(0);
|
||||
s.cars[slot] = self.cars[wrap(at, self.cars.len(), delta)];
|
||||
}
|
||||
Item::Opponents => s.opponents = wrap(s.opponents, 8 - s.players + 1, delta),
|
||||
Item::Caravans => s.caravans = !s.caravans,
|
||||
Item::Music => {
|
||||
const ORDER: [Music; 3] = [Music::Menus, Music::Always, Music::Off];
|
||||
let at = ORDER.iter().position(|&m| m == s.music).unwrap_or(0);
|
||||
s.music = ORDER[wrap(at, 3, delta)];
|
||||
self.audio.set_music(s.music != Music::Off);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.opponents = s.opponents.min(8 - s.players);
|
||||
@@ -294,6 +355,8 @@ impl App {
|
||||
| Item::Car2
|
||||
| Item::Laps
|
||||
| Item::Players
|
||||
| Item::Caravans
|
||||
| Item::Music
|
||||
| Item::Opponents => {
|
||||
let s = &self.settings;
|
||||
match self.build_race(
|
||||
@@ -310,7 +373,9 @@ impl App {
|
||||
race: Box::new(r),
|
||||
cams,
|
||||
paused: false,
|
||||
sounds: RaceSounds::default(),
|
||||
};
|
||||
self.audio.set_music(self.settings.music == Music::Always);
|
||||
if let Screen::Race { race, cams, .. } = &mut screen {
|
||||
for (i, cam) in cams.iter_mut().enumerate() {
|
||||
if let Some(e) =
|
||||
@@ -400,8 +465,17 @@ impl App {
|
||||
f.clear(0x101820);
|
||||
}
|
||||
|
||||
f.text_centered(160, 10, "SKIDS", YELLOW, 4);
|
||||
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1);
|
||||
let top = match &self.title {
|
||||
Some((w, h, px)) => {
|
||||
f.image(px, *w, *h, (SCREEN_W as i32 - *w as i32) / 2, 2);
|
||||
*h as i32 + 6
|
||||
}
|
||||
None => {
|
||||
f.text_centered(160, 10, "SKIDS", YELLOW, 4);
|
||||
f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1);
|
||||
60
|
||||
}
|
||||
};
|
||||
|
||||
let cursor = cursor.min(items.len() - 1);
|
||||
let s = &self.settings;
|
||||
@@ -414,9 +488,20 @@ impl App {
|
||||
Item::Car1 => format!("P1 CAR < {} >", self.data.vehicles[s.cars[0]].name),
|
||||
Item::Car2 => format!("P2 CAR < {} >", self.data.vehicles[s.cars[1]].name),
|
||||
Item::Opponents => format!("OPPONENTS < {} >", s.opponents),
|
||||
Item::Caravans => {
|
||||
format!("CARAVANS < {} >", if s.caravans { "ON" } else { "OFF" })
|
||||
}
|
||||
Item::Music => format!(
|
||||
"MUSIC < {} >",
|
||||
match s.music {
|
||||
Music::Menus => "MENUS",
|
||||
Music::Always => "ALWAYS",
|
||||
Music::Off => "OFF",
|
||||
}
|
||||
),
|
||||
Item::Quit => "QUIT".to_string(),
|
||||
};
|
||||
let y = 64 + i as i32 * 14;
|
||||
let y = top + i as i32 * 12;
|
||||
let colour = if i == cursor { YELLOW } else { WHITE };
|
||||
if i == cursor {
|
||||
f.text_shadow(8, y, ">", RED, 1);
|
||||
@@ -430,10 +515,22 @@ impl App {
|
||||
if let Some(th) = self.thumb(track).cloned() {
|
||||
let f = &mut self.frame;
|
||||
f.frame_rect(
|
||||
Rect::new(px - 2, 62, THUMB_W as i32 + 4, THUMB_H as i32 + 4),
|
||||
Rect::new(px - 2, top - 2, THUMB_W as i32 + 4, THUMB_H as i32 + 4),
|
||||
WHITE,
|
||||
);
|
||||
f.image(&th, THUMB_W, THUMB_H, px, 64);
|
||||
f.image(&th, THUMB_W, THUMB_H, px, top);
|
||||
let set = self
|
||||
.data
|
||||
.load_track(track)
|
||||
.map(|t| t.set_name)
|
||||
.unwrap_or_default();
|
||||
f.text_centered(
|
||||
px + THUMB_W as i32 / 2,
|
||||
top + THUMB_H as i32 + 4,
|
||||
&truncate(&set.to_uppercase(), 21),
|
||||
GREY,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Car preview, slowly turning.
|
||||
@@ -443,33 +540,155 @@ impl App {
|
||||
let v = &self.data.vehicles[s.cars[which]];
|
||||
let heading = (self.tick / 4) as usize % vehicle::HEADINGS;
|
||||
let pal = palette::car_palette(which);
|
||||
let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, 150);
|
||||
f.fill(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), 0x203018);
|
||||
f.frame_rect(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), WHITE);
|
||||
let by = top + THUMB_H as i32 + 16;
|
||||
let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, by + 3);
|
||||
f.fill(Rect::new(px - 2, by, THUMB_W as i32 + 4, 48), 0x203018);
|
||||
f.frame_rect(Rect::new(px - 2, by, THUMB_W as i32 + 4, 48), WHITE);
|
||||
let clip = Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32);
|
||||
f.sprite_shadow(v.frame(vehicle::LEVEL, heading), cx + 3, cy + 3, clip);
|
||||
f.sprite(v.frame(vehicle::LEVEL, heading), &pal, cx, cy, clip);
|
||||
f.text_centered(px + THUMB_W as i32 / 2, 182, &v.name, WHITE, 1);
|
||||
let label = if v.driver.is_empty() {
|
||||
v.name.clone()
|
||||
} else {
|
||||
format!("{} - {}", v.name, v.driver)
|
||||
};
|
||||
f.text_centered(
|
||||
px + THUMB_W as i32 / 2,
|
||||
by + 38,
|
||||
&truncate(&label, 21),
|
||||
WHITE,
|
||||
1,
|
||||
);
|
||||
|
||||
let help = if self.settings.players == 2 {
|
||||
"P1: ARROWS P2: W A S D"
|
||||
} else {
|
||||
"DRIVE: ARROWS OR W A S D"
|
||||
};
|
||||
f.text_centered(160, 222, help, GREY, 1);
|
||||
f.text_centered(160, 230, help, GREY, 1);
|
||||
f.text_centered(
|
||||
160,
|
||||
236,
|
||||
242,
|
||||
"UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO",
|
||||
GREY,
|
||||
1,
|
||||
);
|
||||
if let Some(st) = &self.status {
|
||||
f.text_centered(160, 200, st, RED, 1);
|
||||
f.text_centered(160, 218, st, RED, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the sounds a race frame calls for: engines and tyres for each
|
||||
/// player, countdown beeps, horns, backfires, and crashes near a camera.
|
||||
fn race_sounds(
|
||||
audio: &Audio,
|
||||
race: &Race,
|
||||
s: &mut RaceSounds,
|
||||
cams: &[(f32, f32); 2],
|
||||
input: &Input,
|
||||
tick: u64,
|
||||
) {
|
||||
let humans: Vec<usize> = (0..race.entrants.len())
|
||||
.filter(|&i| matches!(race.entrants[i].driver, Driver::Human(_)))
|
||||
.collect();
|
||||
for (slot, &i) in humans.iter().enumerate().take(2) {
|
||||
let c = &race.entrants[i].car;
|
||||
let skid = if c.on_road {
|
||||
((c.slip - 50.0) / 100.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
audio.set_voice(slot, true, c.rpm, skid);
|
||||
let pan = if humans.len() == 2 {
|
||||
[0.3, 0.7][slot]
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let throttle = c.controls.throttle;
|
||||
// Lifting off at high revs pops now and then.
|
||||
if s.throttle[slot] > 0.0
|
||||
&& throttle == 0.0
|
||||
&& c.rpm > 0.7
|
||||
&& (tick.wrapping_mul(2654435761)).is_multiple_of(4)
|
||||
{
|
||||
audio.play(Sfx::Backfire, pan);
|
||||
}
|
||||
s.throttle[slot] = throttle;
|
||||
}
|
||||
// Player one's horn is Right Shift, player two's Left Shift.
|
||||
let horn_keys = [Key::RightShift, Key::LeftShift];
|
||||
for (slot, key) in horn_keys.iter().enumerate().take(humans.len().max(1)) {
|
||||
if input.hit(*key) {
|
||||
audio.play(
|
||||
Sfx::Horn,
|
||||
if humans.len() == 2 {
|
||||
[0.3, 0.7][slot]
|
||||
} else {
|
||||
0.5
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match race.countdown() {
|
||||
Some(n) if s.countdown != Some(n) => {
|
||||
s.countdown = Some(n);
|
||||
audio.play(Sfx::Beep, 0.5);
|
||||
}
|
||||
None if !s.go_played && race.phase != Phase::Countdown => {
|
||||
s.go_played = true;
|
||||
audio.play(Sfx::Go, 0.5);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
s.bumps.resize(race.entrants.len(), 0.0);
|
||||
for (i, e) in race.entrants.iter().enumerate() {
|
||||
let b = e.car.bump;
|
||||
if b > 0.3 && b > s.bumps[i] + 0.2 {
|
||||
let (sx, sy) = e.car.screen_pos();
|
||||
let near = cams
|
||||
.iter()
|
||||
.take(humans.len().max(1))
|
||||
.map(|c| (c.0 - sx).hypot(c.1 - sy))
|
||||
.fold(f32::MAX, f32::min);
|
||||
if near < 220.0 {
|
||||
let pan = (0.5 + (sx - cams[0].0) / 400.0).clamp(0.1, 0.9);
|
||||
audio.play(Sfx::Crash(b), pan);
|
||||
}
|
||||
}
|
||||
s.bumps[i] = b;
|
||||
}
|
||||
}
|
||||
|
||||
/// Halves a picture with a 2x2 box filter, for the menu header.
|
||||
fn half_size(p: &crate::formats::iff::Picture) -> (usize, usize, Vec<u32>) {
|
||||
let (w, h) = (p.w / 2, p.h / 2);
|
||||
let rgb = |x: usize, y: usize| {
|
||||
let c = p
|
||||
.palette
|
||||
.get(p.pixels[y * p.w + x] as usize)
|
||||
.copied()
|
||||
.unwrap_or((0, 0, 0));
|
||||
(c.0 as u32, c.1 as u32, c.2 as u32)
|
||||
};
|
||||
let mut out = Vec::with_capacity(w * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let px = [
|
||||
rgb(2 * x, 2 * y),
|
||||
rgb(2 * x + 1, 2 * y),
|
||||
rgb(2 * x, 2 * y + 1),
|
||||
rgb(2 * x + 1, 2 * y + 1),
|
||||
];
|
||||
let avg = |f: fn(&(u32, u32, u32)) -> u32| px.iter().map(f).sum::<u32>() / 4;
|
||||
out.push((avg(|c| c.0) << 16) | (avg(|c| c.1) << 8) | avg(|c| c.2));
|
||||
}
|
||||
}
|
||||
(w, h, out)
|
||||
}
|
||||
|
||||
pub fn draw_race(
|
||||
f: &mut Frame,
|
||||
data: &GameData,
|
||||
@@ -536,24 +755,37 @@ pub fn draw_world(
|
||||
}
|
||||
}
|
||||
|
||||
let mut order: Vec<usize> = (0..race.entrants.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
race.entrants[a]
|
||||
.car
|
||||
.screen_pos()
|
||||
.1
|
||||
.total_cmp(&race.entrants[b].car.screen_pos().1)
|
||||
});
|
||||
for i in order {
|
||||
// Cars and caravans, back to front.
|
||||
let mut order: Vec<(f32, usize, bool)> = Vec::new();
|
||||
for (i, e) in race.entrants.iter().enumerate() {
|
||||
order.push((e.car.screen_pos().1, i, false));
|
||||
if let Some(t) = e.trailer {
|
||||
order.push((track::to_screen(t.x, t.y).1, i, true));
|
||||
}
|
||||
}
|
||||
order.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (_, i, is_trailer) in order {
|
||||
let e = &race.entrants[i];
|
||||
let c = &e.car;
|
||||
let pal = palette::car_palette(c.team);
|
||||
if is_trailer {
|
||||
let t = e.trailer.unwrap();
|
||||
let (sx, sy) = track::to_screen(t.x, t.y);
|
||||
let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2);
|
||||
let y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2);
|
||||
let pix =
|
||||
data.vehicles[t.vehicle].frame(vehicle::LEVEL, vehicle::heading_frame(t.heading));
|
||||
f.sprite_shadow(pix, x + 3, y + 2, view);
|
||||
f.sprite(pix, &pal, x, y, view);
|
||||
continue;
|
||||
}
|
||||
let v = &data.vehicles[c.vehicle];
|
||||
let (sx, sy) = c.screen_pos();
|
||||
let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2);
|
||||
let y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2);
|
||||
let pix = v.frame(c.tilt, vehicle::heading_frame(c.heading));
|
||||
f.sprite_shadow(pix, x + 3, y + 2, view);
|
||||
f.sprite(pix, &palette::car_palette(c.team), x, y, view);
|
||||
f.sprite(pix, &pal, x, y, view);
|
||||
if let Driver::Human(p) = e.driver {
|
||||
let tag = format!("P{}", p + 1);
|
||||
let tx = x + 32 - 5;
|
||||
|
||||
@@ -1,151 +1,364 @@
|
||||
//! Procedural sound: an engine note per human player, tyre squeal and knocks.
|
||||
//! Sound: the game's own samples and tune, mixed in software.
|
||||
//!
|
||||
//! The retail samples are on disk 1, which is not in this dump, so everything
|
||||
//! is synthesised. Parameters are passed to the audio thread through atomics.
|
||||
//! Each human player has an engine voice (the `motor3` loop, pitched with
|
||||
//! revs) and a tyre voice (`screech2`). One-shots cover crashes, backfires,
|
||||
//! the horn and the start beeps; the OctaMED tune plays on top. When a
|
||||
//! sample is missing, a simple synthesised stand-in is used instead.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use crate::formats::iff::Sound;
|
||||
use crate::formats::med::Song;
|
||||
use crate::music::Player;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub const VOICES: usize = 2;
|
||||
|
||||
#[derive(Default)]
|
||||
struct VoiceParams {
|
||||
rpm: AtomicU32,
|
||||
skid: AtomicU32,
|
||||
bump: AtomicU32,
|
||||
active: AtomicU32,
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Sfx {
|
||||
/// Countdown tick.
|
||||
Beep,
|
||||
/// Lights out.
|
||||
Go,
|
||||
Horn,
|
||||
Backfire,
|
||||
/// Strength 0..1.
|
||||
Crash(f32),
|
||||
}
|
||||
|
||||
/// The samples the game uses, picked out of the sound assets by name.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Bank {
|
||||
engine: Option<Arc<Sound>>,
|
||||
skid: Option<Arc<Sound>>,
|
||||
crashes: Vec<Arc<Sound>>,
|
||||
beep: Option<Arc<Sound>>,
|
||||
go: Option<Arc<Sound>>,
|
||||
horn: Option<Arc<Sound>>,
|
||||
backfire: Option<Arc<Sound>>,
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
pub fn from_sounds(sounds: &BTreeMap<String, Sound>) -> Bank {
|
||||
let get = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| sounds.get(*n))
|
||||
.map(|s| Arc::new(s.clone()))
|
||||
};
|
||||
Bank {
|
||||
engine: get(&["motor3", "motor", "engine"]),
|
||||
skid: get(&["screech2", "screech3", "screech"]),
|
||||
crashes: ["crash1", "crash2", "crash3"]
|
||||
.iter()
|
||||
.filter_map(|n| sounds.get(*n))
|
||||
.map(|s| Arc::new(s.clone()))
|
||||
.collect(),
|
||||
beep: get(&["beep"]),
|
||||
go: get(&["beep2", "beep"]),
|
||||
horn: get(&["horn"]),
|
||||
backfire: get(&["backfire"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample playing from `pos`, `step` source frames per output frame.
|
||||
struct Play {
|
||||
sound: Arc<Sound>,
|
||||
pos: f64,
|
||||
step: f64,
|
||||
volume: f32,
|
||||
pan: f32,
|
||||
/// Stop after this many output frames (0 = play to the end).
|
||||
frames_left: usize,
|
||||
}
|
||||
|
||||
impl Play {
|
||||
fn new(sound: Arc<Sound>, rate: f32, volume: f32, pan: f32) -> Play {
|
||||
let step = sound.rate as f64 / rate as f64;
|
||||
Play {
|
||||
sound,
|
||||
pos: 0.0,
|
||||
step,
|
||||
volume,
|
||||
pan,
|
||||
frames_left: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Next sample value, or None when finished.
|
||||
fn next(&mut self, pitch: f64) -> Option<f32> {
|
||||
let s = &self.sound;
|
||||
let i = self.pos as usize;
|
||||
if i >= s.data.len() {
|
||||
return None;
|
||||
}
|
||||
let v = s.data[i] as f32 / 128.0 * s.volume;
|
||||
self.pos += self.step * pitch;
|
||||
if self.pos as usize >= s.data.len() {
|
||||
if let Some(start) = s.loop_start {
|
||||
let len = (s.data.len() - start).max(1) as f64;
|
||||
while self.pos as usize >= s.data.len() {
|
||||
self.pos -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
voices: [VoiceParams; VOICES],
|
||||
master: AtomicU32,
|
||||
struct Voice {
|
||||
active: bool,
|
||||
rpm: f32,
|
||||
skid: f32,
|
||||
level: f32,
|
||||
engine: Option<Play>,
|
||||
tyres: Option<Play>,
|
||||
synth: Synth,
|
||||
}
|
||||
|
||||
fn store(a: &AtomicU32, v: f32) {
|
||||
a.store(v.to_bits(), Ordering::Relaxed);
|
||||
struct Mixer {
|
||||
rate: f32,
|
||||
bank: Bank,
|
||||
voices: [Voice; VOICES],
|
||||
oneshots: Vec<Play>,
|
||||
music: Option<Player>,
|
||||
song: Option<Arc<Song>>,
|
||||
music_on: bool,
|
||||
music_volume: f32,
|
||||
noise: u32,
|
||||
}
|
||||
|
||||
fn load(a: &AtomicU32) -> f32 {
|
||||
f32::from_bits(a.load(Ordering::Relaxed))
|
||||
impl Mixer {
|
||||
fn frame(&mut self) -> (f32, f32) {
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
let rate = self.rate;
|
||||
for (i, v) in self.voices.iter_mut().enumerate() {
|
||||
let target = if v.active { 1.0 } else { 0.0 };
|
||||
v.level += (target - v.level) * 0.0008;
|
||||
if v.level < 1e-4 {
|
||||
continue;
|
||||
}
|
||||
let pan = if i == 0 { 0.35 } else { 0.65 };
|
||||
let mut out = 0.0;
|
||||
match (&mut v.engine, &self.bank.engine) {
|
||||
(Some(p), _) => out += p.next(0.55 + v.rpm as f64 * 1.35).unwrap_or(0.0) * 0.55,
|
||||
(None, Some(s)) => v.engine = Some(Play::new(s.clone(), rate, 1.0, pan)),
|
||||
(None, None) => out += v.synth.engine(v.rpm, rate),
|
||||
}
|
||||
match (&mut v.tyres, &self.bank.skid) {
|
||||
(Some(p), _) => {
|
||||
out += p.next(0.9 + v.skid as f64 * 0.2).unwrap_or(0.0) * v.skid.min(1.0) * 0.45
|
||||
}
|
||||
(None, Some(s)) => v.tyres = Some(Play::new(s.clone(), rate, 1.0, pan)),
|
||||
(None, None) => out += v.synth.squeal(v.skid),
|
||||
}
|
||||
out *= v.level;
|
||||
l += out * (1.0 - pan) * 2.0;
|
||||
r += out * pan * 2.0;
|
||||
}
|
||||
self.oneshots.retain_mut(|p| {
|
||||
if p.frames_left == 1 {
|
||||
return false;
|
||||
}
|
||||
p.frames_left = p.frames_left.saturating_sub(1);
|
||||
match p.next(1.0) {
|
||||
Some(v) => {
|
||||
l += v * p.volume * (1.0 - p.pan) * 2.0;
|
||||
r += v * p.volume * p.pan * 2.0;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
});
|
||||
if self.music_on {
|
||||
if let Some(m) = &mut self.music {
|
||||
let (ml, mr) = m.next();
|
||||
l += ml * self.music_volume;
|
||||
r += mr * self.music_volume;
|
||||
}
|
||||
}
|
||||
(l * 0.5, r * 0.5)
|
||||
}
|
||||
|
||||
fn rand(&mut self) -> u32 {
|
||||
self.noise ^= self.noise << 13;
|
||||
self.noise ^= self.noise >> 17;
|
||||
self.noise ^= self.noise << 5;
|
||||
self.noise
|
||||
}
|
||||
|
||||
fn play(&mut self, sfx: Sfx, pan: f32) {
|
||||
let (sound, volume, max_secs) = match sfx {
|
||||
Sfx::Beep => (self.bank.beep.clone(), 0.6, 0.35),
|
||||
Sfx::Go => (self.bank.go.clone(), 0.7, 0.8),
|
||||
Sfx::Horn => (self.bank.horn.clone(), 0.8, 0.6),
|
||||
Sfx::Backfire => (self.bank.backfire.clone(), 0.7, 0.0),
|
||||
Sfx::Crash(strength) => {
|
||||
let n = self.bank.crashes.len();
|
||||
let r = self.rand() as usize;
|
||||
let pick = if n > 0 {
|
||||
Some(self.bank.crashes[r % n].clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(pick, 0.3 + 0.7 * strength.clamp(0.0, 1.0), 0.0)
|
||||
}
|
||||
};
|
||||
let Some(sound) = sound else { return };
|
||||
// A crash pile-up should not stack dozens of copies.
|
||||
if self.oneshots.len() >= 12 {
|
||||
self.oneshots.remove(0);
|
||||
}
|
||||
let mut p = Play::new(sound, self.rate, volume, pan);
|
||||
// Looped samples (beeps, horn) are cut off after a fixed time.
|
||||
p.frames_left = if max_secs > 0.0 {
|
||||
(max_secs * self.rate) as usize + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.oneshots.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
shared: Arc<Shared>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
#[cfg(feature = "audio")]
|
||||
_stream: Option<cpal::Stream>,
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(enabled: bool) -> Audio {
|
||||
let shared = Arc::new(Shared::default());
|
||||
store(&shared.master, 0.5);
|
||||
pub fn new(enabled: bool, bank: Bank, song: Option<Arc<Song>>) -> Audio {
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
rate: 44100.0,
|
||||
bank,
|
||||
voices: Default::default(),
|
||||
oneshots: Vec::new(),
|
||||
music: None,
|
||||
song,
|
||||
music_on: false,
|
||||
music_volume: 0.7,
|
||||
noise: 0x2468_ace1,
|
||||
}));
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
let stream = if enabled {
|
||||
backend::open(shared.clone())
|
||||
backend::open(mixer.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Audio {
|
||||
shared,
|
||||
mixer,
|
||||
_stream: stream,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
{
|
||||
let _ = enabled;
|
||||
Audio { shared }
|
||||
Audio { mixer }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32, bump: f32) {
|
||||
let v = &self.shared.voices[i];
|
||||
store(&v.active, if active { 1.0 } else { 0.0 });
|
||||
store(&v.rpm, rpm);
|
||||
store(&v.skid, skid);
|
||||
// Knocks are latched until the audio thread consumes them.
|
||||
if bump > load(&v.bump) {
|
||||
store(&v.bump, bump);
|
||||
}
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
|
||||
let mut m = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut m)
|
||||
}
|
||||
|
||||
pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32) {
|
||||
self.with(|m| {
|
||||
let v = &mut m.voices[i];
|
||||
v.active = active;
|
||||
v.rpm = rpm;
|
||||
v.skid = skid;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn play(&self, sfx: Sfx, pan: f32) {
|
||||
self.with(|m| m.play(sfx, pan.clamp(0.0, 1.0)));
|
||||
}
|
||||
|
||||
/// Starts the tune from the top, or stops it.
|
||||
pub fn set_music(&self, on: bool) {
|
||||
self.with(|m| {
|
||||
if on && !m.music_on {
|
||||
m.music = m.song.clone().map(|s| Player::new(s, m.rate as f64));
|
||||
}
|
||||
m.music_on = on;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn has_music(&self) -> bool {
|
||||
self.with(|m| m.song.is_some())
|
||||
}
|
||||
|
||||
pub fn silence(&self) {
|
||||
for i in 0..VOICES {
|
||||
self.set_voice(i, false, 0.0, 0.0, 0.0);
|
||||
self.set_voice(i, false, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `frames` stereo frames without a sound device (for tests).
|
||||
#[cfg(test)]
|
||||
fn render(&self, frames: usize) -> Vec<(f32, f32)> {
|
||||
self.with(|m| (0..frames).map(|_| m.frame()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Synth state for one voice, run on the audio thread.
|
||||
/// Stand-in sounds for when the samples are not available.
|
||||
#[derive(Default)]
|
||||
struct Synth {
|
||||
phase: f32,
|
||||
sub_phase: f32,
|
||||
rpm: f32,
|
||||
skid: f32,
|
||||
knock: f32,
|
||||
noise: u32,
|
||||
lp: f32,
|
||||
noise: u32,
|
||||
skid_lp: f32,
|
||||
level: f32,
|
||||
}
|
||||
|
||||
impl Synth {
|
||||
fn sample(&mut self, p: &VoiceParams, rate: f32) -> f32 {
|
||||
let target_level = load(&p.active);
|
||||
self.level += (target_level - self.level) * 0.0008;
|
||||
self.rpm += (load(&p.rpm) - self.rpm) * 0.002;
|
||||
self.skid += (load(&p.skid) - self.skid) * 0.004;
|
||||
let b = load(&p.bump);
|
||||
if b > 0.0 {
|
||||
self.knock = self.knock.max(b);
|
||||
store(&p.bump, 0.0);
|
||||
}
|
||||
fn white(&mut self) -> f32 {
|
||||
self.noise = self
|
||||
.noise
|
||||
.wrapping_mul(1_664_525)
|
||||
.wrapping_add(1_013_904_223);
|
||||
(self.noise >> 8) as f32 / (1u32 << 23) as f32 - 1.0
|
||||
}
|
||||
|
||||
self.noise ^= self.noise << 13;
|
||||
self.noise ^= self.noise >> 17;
|
||||
self.noise ^= self.noise << 5;
|
||||
let white = (self.noise as f32 / u32::MAX as f32) * 2.0 - 1.0;
|
||||
|
||||
// Engine: a buzzy pulse with a sub-octave, low-passed.
|
||||
let freq = 38.0 + self.rpm * 150.0;
|
||||
fn engine(&mut self, rpm: f32, rate: f32) -> f32 {
|
||||
let freq = 38.0 + rpm * 150.0;
|
||||
self.phase = (self.phase + freq / rate).fract();
|
||||
self.sub_phase = (self.sub_phase + freq * 0.5 / rate).fract();
|
||||
let pulse = if self.phase < 0.3 { 1.0 } else { -0.45 };
|
||||
let sub = if self.sub_phase < 0.5 { 0.5 } else { -0.5 };
|
||||
let raw = pulse + sub + white * 0.15;
|
||||
self.lp += (raw - self.lp) * (0.06 + self.rpm * 0.12);
|
||||
let engine = self.lp * 0.32;
|
||||
let raw = pulse + sub + self.white() * 0.15;
|
||||
self.lp += (raw - self.lp) * (0.06 + rpm * 0.12);
|
||||
self.lp * 0.32
|
||||
}
|
||||
|
||||
// Tyres: band-limited noise.
|
||||
self.skid_lp += (white - self.skid_lp) * 0.35;
|
||||
let squeal = (white - self.skid_lp) * self.skid.min(1.0) * 0.22;
|
||||
|
||||
let knock = white * self.knock * 0.6;
|
||||
self.knock *= 0.9985;
|
||||
|
||||
(engine + squeal + knock) * self.level
|
||||
fn squeal(&mut self, skid: f32) -> f32 {
|
||||
let w = self.white();
|
||||
self.skid_lp += (w - self.skid_lp) * 0.35;
|
||||
(w - self.skid_lp) * skid.min(1.0) * 0.22
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
mod backend {
|
||||
use super::{load, Shared, Synth, VOICES};
|
||||
use super::Mixer;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn open(shared: Arc<Shared>) -> Option<cpal::Stream> {
|
||||
pub fn open(mixer: Arc<Mutex<Mixer>>) -> Option<cpal::Stream> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device()?;
|
||||
let config = device.default_output_config().ok()?;
|
||||
let fmt = config.sample_format();
|
||||
let config: cpal::StreamConfig = config.into();
|
||||
mixer.lock().ok()?.rate = config.sample_rate.0 as f32;
|
||||
let stream = match fmt {
|
||||
cpal::SampleFormat::F32 => build::<f32>(&device, &config, shared),
|
||||
cpal::SampleFormat::I16 => build::<i16>(&device, &config, shared),
|
||||
cpal::SampleFormat::U16 => build::<u16>(&device, &config, shared),
|
||||
cpal::SampleFormat::F32 => build::<f32>(&device, &config, mixer),
|
||||
cpal::SampleFormat::I16 => build::<i16>(&device, &config, mixer),
|
||||
cpal::SampleFormat::U16 => build::<u16>(&device, &config, mixer),
|
||||
_ => None,
|
||||
}?;
|
||||
stream.play().ok()?;
|
||||
@@ -155,33 +368,20 @@ mod backend {
|
||||
fn build<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
shared: Arc<Shared>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Option<cpal::Stream>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let rate = config.sample_rate.0 as f32;
|
||||
let channels = config.channels as usize;
|
||||
let mut synths: [Synth; VOICES] = Default::default();
|
||||
for (i, s) in synths.iter_mut().enumerate() {
|
||||
s.noise = 0x1234_5678 ^ (i as u32 * 0x9e37_79b9);
|
||||
}
|
||||
device
|
||||
.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _| {
|
||||
let master = load(&shared.master);
|
||||
let mut m = mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let mut l = 0.0;
|
||||
let mut r = 0.0;
|
||||
for (i, s) in synths.iter_mut().enumerate() {
|
||||
let v = s.sample(&shared.voices[i], rate);
|
||||
// Player one leans left, player two right.
|
||||
let pan = if i == 0 { 0.35 } else { 0.65 };
|
||||
l += v * (1.0 - pan) * 2.0;
|
||||
r += v * pan * 2.0;
|
||||
}
|
||||
let (l, r) = ((l * master).clamp(-1.0, 1.0), (r * master).clamp(-1.0, 1.0));
|
||||
let (l, r) = m.frame();
|
||||
let (l, r) = (l.clamp(-1.0, 1.0), r.clamp(-1.0, 1.0));
|
||||
for (c, out) in frame.iter_mut().enumerate() {
|
||||
let v = if channels == 1 {
|
||||
(l + r) * 0.5
|
||||
@@ -200,3 +400,34 @@ mod backend {
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tone(rate: u32, looped: bool) -> Sound {
|
||||
let data = (0..400)
|
||||
.map(|i| ((i as f32 * 0.3).sin() * 100.0) as i8)
|
||||
.collect();
|
||||
Sound {
|
||||
rate,
|
||||
data,
|
||||
loop_start: looped.then_some(0),
|
||||
volume: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_mix_and_one_shots_finish() {
|
||||
let mut sounds = BTreeMap::new();
|
||||
sounds.insert("motor3".to_string(), tone(13714, true));
|
||||
sounds.insert("crash1".to_string(), tone(8363, false));
|
||||
let a = Audio::new(false, Bank::from_sounds(&sounds), None);
|
||||
a.set_voice(0, true, 0.5, 0.0);
|
||||
a.play(Sfx::Crash(1.0), 0.5);
|
||||
let out = a.render(44100);
|
||||
assert!(out.iter().any(|f| f.0.abs() > 0.01), "engine is audible");
|
||||
// A 400-sample crash at 8363 Hz lasts about 2100 output frames.
|
||||
assert!(a.with(|m| m.oneshots.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ pub mod palette;
|
||||
pub mod track;
|
||||
pub mod vehicle;
|
||||
|
||||
use crate::formats::{adf::Adf, crm, lha};
|
||||
use crate::formats::iff::{self, Picture, Sound};
|
||||
use crate::formats::{adf::Adf, crm, lha, med};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -80,11 +81,8 @@ impl FileSet {
|
||||
Ok(entries) => {
|
||||
for e in entries {
|
||||
match e.data {
|
||||
Some(d) => self.add(&e.path, d),
|
||||
None => self.warnings.push(format!(
|
||||
"{path}: {} uses unsupported method {}",
|
||||
e.path, e.method
|
||||
)),
|
||||
Ok(d) => self.add(&e.path, d),
|
||||
Err(why) => self.warnings.push(format!("{path}: {}: {why}", e.path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +114,11 @@ fn logical_key(path: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let (dir, name) = (&parts[n - 2], &parts[n - 1]);
|
||||
matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}"))
|
||||
matches!(
|
||||
dir.as_str(),
|
||||
"track" | "vehicles" | "palettes" | "sfx" | "gfx" | "sounds" | "music"
|
||||
)
|
||||
.then(|| format!("{dir}/{name}"))
|
||||
}
|
||||
|
||||
/// Unpacks CrunchMania data, passing plain data through untouched.
|
||||
@@ -144,6 +146,12 @@ pub struct GameData {
|
||||
pub tracks: Vec<TrackEntry>,
|
||||
pub track_files: Vec<TrackFile>,
|
||||
pub vehicles: Vec<Vehicle>,
|
||||
/// Sound effects by lower-case name (`motor3`, `screech2`, ...).
|
||||
pub sounds: BTreeMap<String, Sound>,
|
||||
/// The tune as an OctaMED module, and the name it is stored under.
|
||||
pub music: Option<(String, Vec<u8>)>,
|
||||
/// Pictures by lower-case name (`skidtoon`, ...).
|
||||
pub pictures: BTreeMap<String, Picture>,
|
||||
pub files: FileSet,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -151,7 +159,17 @@ pub struct GameData {
|
||||
impl GameData {
|
||||
/// Loads the built-in data, overlaid with whatever is under `dir`.
|
||||
pub fn load(dir: Option<&Path>) -> Result<GameData, String> {
|
||||
let mut files = FileSet::embedded();
|
||||
Self::load_from(dir, true)
|
||||
}
|
||||
|
||||
/// As `load`, optionally without the built-in data (used when
|
||||
/// converting original disks, so the old assets do not shadow them).
|
||||
pub fn load_from(dir: Option<&Path>, built_in: bool) -> Result<GameData, String> {
|
||||
let mut files = if built_in {
|
||||
FileSet::embedded()
|
||||
} else {
|
||||
FileSet::default()
|
||||
};
|
||||
if let Some(dir) = dir {
|
||||
files.scan(dir);
|
||||
}
|
||||
@@ -205,6 +223,7 @@ impl GameData {
|
||||
let Some(id) = key
|
||||
.strip_prefix("vehicles/")
|
||||
.and_then(|n| n.strip_suffix(".png"))
|
||||
.filter(|n| !n.ends_with("-menu"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -212,7 +231,14 @@ impl GameData {
|
||||
.get(&format!("vehicles/{id}.txt"))
|
||||
.map(String::from_utf8_lossy);
|
||||
match Vehicle::from_assets(id, data, txt.as_deref()) {
|
||||
Ok(v) => vehicles.push(v),
|
||||
Ok(mut v) => {
|
||||
if let Some(png) = files.get(&format!("vehicles/{id}-menu.png")) {
|
||||
if let Err(e) = v.set_menu_png(png) {
|
||||
warnings.push(format!("vehicles/{id}-menu.png: {e}"));
|
||||
}
|
||||
}
|
||||
vehicles.push(v)
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -227,7 +253,14 @@ impl GameData {
|
||||
continue;
|
||||
}
|
||||
match Vehicle::parse(name, data) {
|
||||
Ok(Some(v)) => vehicles.push(v),
|
||||
Ok(Some(mut v)) => {
|
||||
if let Some(set) = files.get(&format!("vehicles/{}.set", v.id)) {
|
||||
if let Err(e) = v.apply_set(set) {
|
||||
warnings.push(format!("vehicles/{}.set: {e}", v.id));
|
||||
}
|
||||
}
|
||||
vehicles.push(v)
|
||||
}
|
||||
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
@@ -248,10 +281,14 @@ impl GameData {
|
||||
}
|
||||
));
|
||||
}
|
||||
let (sounds, music, pictures) = load_media(&files, &mut warnings);
|
||||
Ok(GameData {
|
||||
tracks,
|
||||
track_files,
|
||||
vehicles,
|
||||
sounds,
|
||||
music,
|
||||
pictures,
|
||||
files,
|
||||
warnings,
|
||||
})
|
||||
@@ -274,6 +311,105 @@ impl GameData {
|
||||
}
|
||||
}
|
||||
|
||||
type Media = (
|
||||
BTreeMap<String, Sound>,
|
||||
Option<(String, Vec<u8>)>,
|
||||
BTreeMap<String, Picture>,
|
||||
);
|
||||
|
||||
/// Sounds, music and pictures. Assets (`sounds/*.wav`, `music/*.med`,
|
||||
/// `gfx/*.png`) win over the originals (`sfx/` and `gfx/` IFF files, and the
|
||||
/// CrunchMania-packed tune).
|
||||
fn load_media(files: &FileSet, warnings: &mut Vec<String>) -> Media {
|
||||
let stem = |key: &str, dir: &str, ext: &str| -> Option<String> {
|
||||
let name = key.strip_prefix(dir)?.strip_prefix('/')?;
|
||||
name.strip_suffix(ext).map(str::to_string)
|
||||
};
|
||||
let mut sounds = BTreeMap::new();
|
||||
// Lowest priority first; later inserts replace earlier ones.
|
||||
for dir in ["gfx", "sfx"] {
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, dir, ".iff") {
|
||||
if iff::is_8svx(data) {
|
||||
match iff::read_8svx(data) {
|
||||
Ok(s) => {
|
||||
sounds.insert(name, s);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "sounds", ".wav") {
|
||||
match iff::wav_to_sound(data) {
|
||||
Ok(s) => {
|
||||
sounds.insert(name, s);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut music = None;
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "sfx", ".crn") {
|
||||
match unpack(data) {
|
||||
Ok(d) if med::is_med(&d) => music = Some((name, d)),
|
||||
Ok(_) => warnings.push(format!("{key}: not an OctaMED module")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((key, data)) = files
|
||||
.files
|
||||
.iter()
|
||||
.find(|(k, _)| k.starts_with("music/") && k.ends_with(".med"))
|
||||
{
|
||||
music = Some((stem(key, "music", ".med").unwrap_or_default(), data.clone()));
|
||||
}
|
||||
|
||||
let mut pictures = BTreeMap::new();
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "gfx", ".iff") {
|
||||
if data.get(8..12) == Some(b"ILBM") {
|
||||
match iff::read_ilbm(data) {
|
||||
Ok(p) => {
|
||||
pictures.insert(name, p);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = stem(key, "gfx", ".png") {
|
||||
match image::read_indexed(data, &palette::TRACK_DEFAULT) {
|
||||
Ok(img) => {
|
||||
let palette = img
|
||||
.palette
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| (c.0, c.1, c.2))
|
||||
.collect();
|
||||
pictures.insert(
|
||||
name,
|
||||
Picture {
|
||||
w: img.w,
|
||||
h: img.h,
|
||||
pixels: img.pixels,
|
||||
palette,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(sounds, music, pictures)
|
||||
}
|
||||
|
||||
/// Sort key that orders TRACK2-10 after TRACK2-9.
|
||||
fn natural_key(name: &str) -> Vec<(String, u64)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Colour palettes.
|
||||
//!
|
||||
//! The track and vehicle files carry no colour tables; the retail game sets
|
||||
//! them from its main executable, which lives on disk 1. Until that disk is
|
||||
//! available we use a reconstructed palette. Indices on the retail tracks are
|
||||
//! ordered dark-to-light, with 0-7 used for scenery and 8-15 for the driving
|
||||
//! surface, so two ramps give a faithful-looking result.
|
||||
//! Each original track file carries the 16-colour palette its six tracks
|
||||
//! share (see `track.rs`). `TRACK_DEFAULT` is only a fallback for track
|
||||
//! pictures that arrive without one: indices 0-7 scenery, 8-15 road.
|
||||
//!
|
||||
//! Each track asset's PNG carries its own palette, so the usual way to
|
||||
//! recolour a track is to edit that. Palettes can also be dropped in as text
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! File layout:
|
||||
//! 0x00 7 x BE32 end offsets. Section 0 runs from 0x1c to the first offset
|
||||
//! and holds the selector thumbnails; sections 1..6 are tracks.
|
||||
//! The last 142 bytes of section 0 are: BE16 file number, the 16
|
||||
//! colour palette shared by all six tracks as 48 BE16 values (R, G, B
|
||||
//! per colour, 0-15 each), 12 bytes of unknown settings, then the
|
||||
//! name of the set of tracks, NUL-terminated.
|
||||
//!
|
||||
//! Each track section (281240 bytes once decrunched):
|
||||
//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels
|
||||
@@ -49,6 +53,8 @@ pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool {
|
||||
pub struct TrackFile {
|
||||
pub name: String,
|
||||
pub number: u32,
|
||||
pub palette: [Rgb; 16],
|
||||
pub set_name: String,
|
||||
/// Packed track sections (index 0 = first playable track).
|
||||
pub sections: Vec<Vec<u8>>,
|
||||
}
|
||||
@@ -67,6 +73,8 @@ impl Gate {
|
||||
|
||||
pub struct Track {
|
||||
pub name: String,
|
||||
/// The set of six tracks this one belongs to.
|
||||
pub set_name: String,
|
||||
/// Palette indices, W x H.
|
||||
pub pixels: Vec<u8>,
|
||||
pub palette: [Rgb; 16],
|
||||
@@ -104,9 +112,13 @@ impl TrackFile {
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit())
|
||||
.parse()
|
||||
.unwrap_or(u32::MAX);
|
||||
let (palette, set_name) = file_header(&d[SECTIONS * 4..ends[0]])
|
||||
.unwrap_or((palette::TRACK_DEFAULT, String::new()));
|
||||
Ok(TrackFile {
|
||||
name: name.to_string(),
|
||||
number,
|
||||
palette,
|
||||
set_name,
|
||||
sections,
|
||||
})
|
||||
}
|
||||
@@ -117,10 +129,26 @@ impl TrackFile {
|
||||
|
||||
pub fn decode(&self, section: usize) -> Result<Track, String> {
|
||||
let raw = unpack(&self.sections[section]).map_err(|e| e.to_string())?;
|
||||
Track::from_section(self.track_name(section), &raw)
|
||||
let mut t = Track::from_section(self.track_name(section), &raw)?;
|
||||
t.palette = self.palette;
|
||||
t.set_name = self.set_name.clone();
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette and set name from the tail of section 0.
|
||||
fn file_header(s: &[u8]) -> Option<([Rgb; 16], String)> {
|
||||
let tail = s.get(s.len().checked_sub(142)?..)?;
|
||||
let mut pal = [Rgb(0, 0, 0); 16];
|
||||
for (i, c) in pal.iter_mut().enumerate() {
|
||||
let v = |k: usize| (be16(tail, 2 + (i * 3 + k) * 2).min(15) * 17) as u8;
|
||||
*c = Rgb(v(0), v(1), v(2));
|
||||
}
|
||||
let name = &tail[110..];
|
||||
let end = name.iter().position(|&b| b == 0).unwrap_or(name.len());
|
||||
Some((pal, crate::formats::latin1(&name[..end]).trim().to_string()))
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn from_section(name: String, d: &[u8]) -> Result<Track, String> {
|
||||
if d.len() < SECTION_SIZE {
|
||||
@@ -168,6 +196,7 @@ impl Track {
|
||||
name,
|
||||
pixels,
|
||||
palette: palette::TRACK_DEFAULT,
|
||||
set_name: String::new(),
|
||||
height,
|
||||
gates,
|
||||
start,
|
||||
@@ -236,6 +265,7 @@ impl Track {
|
||||
let mut gates = Vec::new();
|
||||
let mut start = (0.0, 0.0);
|
||||
let mut flags = 0;
|
||||
let mut set_name = String::new();
|
||||
let mut height = Vec::with_capacity(HM_W * HM_H);
|
||||
let mut in_height = false;
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
@@ -287,6 +317,7 @@ impl Track {
|
||||
.ok_or_else(|| err("bad flags"))?
|
||||
}
|
||||
"height" => in_height = true,
|
||||
"set" => set_name = line[3..].trim().to_string(),
|
||||
other => return Err(err(&format!("unknown entry `{other}`"))),
|
||||
}
|
||||
}
|
||||
@@ -304,6 +335,7 @@ impl Track {
|
||||
name: name.to_string(),
|
||||
pixels: img.pixels,
|
||||
palette: pal,
|
||||
set_name,
|
||||
height,
|
||||
gates,
|
||||
start,
|
||||
@@ -331,10 +363,11 @@ impl Track {
|
||||
t.push_str(&format!("gate {} {} {} {}\n", g.a.0, g.a.1, g.b.0, g.b.1));
|
||||
}
|
||||
t.push_str(&format!(
|
||||
"\n# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
|
||||
"\n# Name of the set of tracks this one belongs to, shown in the menu.\nset {}\n\n\
|
||||
# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
|
||||
# height: 64 rows of 64 values, ground height x 256. Each value covers\n\
|
||||
# 16x8 pixels of the picture.\nheight\n",
|
||||
self.start.0, self.start.1, self.flags
|
||||
self.set_name, self.start.0, self.start.1, self.flags
|
||||
));
|
||||
for row in self.height.chunks(HM_W) {
|
||||
let line: Vec<String> = row.iter().map(|v| v.to_string()).collect();
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! Its colours are only a preview; cars are recoloured per player. The text
|
||||
//! file holds the display name and handling.
|
||||
//!
|
||||
//! Settings (`vehicles/*.set`, disk 1): car name and driver name, each
|
||||
//! ending in a newline, then 544 bytes: 16 BE16 values (11 used; their
|
||||
//! meaning lives in the executable and is not decoded yet) and a 64x32
|
||||
//! two-colour-plane picture for the menu, plane 0 then plane 1.
|
||||
//!
|
||||
//! Original data (`vehicles/*.aga`):
|
||||
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes.
|
||||
//! Each frame is a 64x32 AGA sprite with two bitplanes stored per line
|
||||
@@ -135,6 +140,13 @@ pub fn default_spec(id: &str) -> Spec {
|
||||
pub struct Vehicle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub driver: String,
|
||||
/// Towed behind cars rather than driven (the caravan).
|
||||
pub trailer: bool,
|
||||
/// The 16 values from the original `.set` file, kept for reference.
|
||||
pub original: Vec<u16>,
|
||||
/// 64x32 menu picture (indices 0-3), from the `.set` file.
|
||||
pub menu_picture: Option<Vec<u8>>,
|
||||
pub spec: Spec,
|
||||
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
|
||||
pub frames: Vec<u8>,
|
||||
@@ -166,12 +178,58 @@ impl Vehicle {
|
||||
let id = file.trim_end_matches(".aga").to_lowercase();
|
||||
Ok(Some(Vehicle {
|
||||
name: display_name(&id),
|
||||
driver: String::new(),
|
||||
trailer: id == "caravan",
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: default_spec(&id),
|
||||
id,
|
||||
frames,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Applies an original `.set` file.
|
||||
pub fn apply_set(&mut self, d: &[u8]) -> Result<(), String> {
|
||||
let nl = |from: usize| d[from..].iter().position(|&b| b == b'\n').map(|p| p + from);
|
||||
let a = nl(0).ok_or("no name line")?;
|
||||
let b = nl(a + 1).ok_or("no driver line")?;
|
||||
let body = &d[b + 1..];
|
||||
if body.len() < 32 + 512 {
|
||||
return Err(format!("{} bytes of settings, expected 544", body.len()));
|
||||
}
|
||||
let name = crate::formats::latin1(&d[..a]).trim().to_uppercase();
|
||||
if !matches!(self.id.as_str(), "f1" | "vw") && !name.is_empty() {
|
||||
self.name = name;
|
||||
}
|
||||
self.driver = crate::formats::latin1(&d[a + 1..b]).trim().to_uppercase();
|
||||
self.original = (0..16).map(|i| crate::formats::be16(body, i * 2)).collect();
|
||||
let pic = &body[32..32 + 512];
|
||||
let mut px = vec![0u8; SPR_W * SPR_H];
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (byte, mask) = (i / 8, 0x80 >> (i % 8));
|
||||
*p = (pic[byte] & mask != 0) as u8 | (((pic[256 + byte] & mask) != 0) as u8) << 1;
|
||||
}
|
||||
self.menu_picture = Some(px);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn menu_png(&self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.menu_picture
|
||||
.as_ref()
|
||||
.map(|px| image::write_indexed(SPR_W, SPR_H, &preview_palette(), true, px))
|
||||
}
|
||||
|
||||
pub fn set_menu_png(&mut self, png: &[u8]) -> Result<(), String> {
|
||||
let img = image::read_indexed(png, &preview_palette())?;
|
||||
if (img.w, img.h) != (SPR_W, SPR_H) || img.pixels.iter().any(|&p| p > 3) {
|
||||
return Err(format!(
|
||||
"menu picture must be {SPR_W}x{SPR_H} using colours 0-3"
|
||||
));
|
||||
}
|
||||
self.menu_picture = Some(img.pixels);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads an editable asset: sprite sheet PNG plus optional settings text.
|
||||
pub fn from_assets(id: &str, png: &[u8], txt: Option<&str>) -> Result<Vehicle, String> {
|
||||
let img = image::read_indexed(png, &preview_palette())?;
|
||||
@@ -198,6 +256,10 @@ impl Vehicle {
|
||||
let mut v = Vehicle {
|
||||
id: id.to_string(),
|
||||
name: display_name(id),
|
||||
driver: String::new(),
|
||||
trailer: id == "caravan",
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: default_spec(id),
|
||||
frames,
|
||||
};
|
||||
@@ -227,6 +289,13 @@ impl Vehicle {
|
||||
"# Skids vehicle settings. Lines starting with # are comments.\n\
|
||||
# The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\
|
||||
name {name}\n\
|
||||
driver {driver}\n\
|
||||
# yes = towed behind cars (Caravans option) instead of driven\n\
|
||||
trailer {trailer}\n\
|
||||
# The 16 values from the original .set file. Their meaning is not\n\
|
||||
# decoded yet, so the game does not use them; the handling below is\n\
|
||||
# a reconstruction.\n\
|
||||
original {original}\n\
|
||||
# top speed on tarmac, world pixels per second\n\
|
||||
top {top}\n\
|
||||
# acceleration and braking, world pixels per second squared\n\
|
||||
@@ -242,6 +311,14 @@ impl Vehicle {
|
||||
offroad {offroad}\n",
|
||||
id = self.id,
|
||||
name = self.name,
|
||||
driver = self.driver,
|
||||
trailer = if self.trailer { "yes" } else { "no" },
|
||||
original = self
|
||||
.original
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
top = s.top,
|
||||
accel = s.accel,
|
||||
brake = s.brake,
|
||||
@@ -255,16 +332,32 @@ impl Vehicle {
|
||||
fn apply_settings(&mut self, txt: &str) -> Result<(), String> {
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
let Some((key, val)) = line.split_once(char::is_whitespace) else {
|
||||
if line.is_empty() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (key, val) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let val = val.trim();
|
||||
match key {
|
||||
"name" => {
|
||||
self.name = val.to_uppercase();
|
||||
continue;
|
||||
}
|
||||
return Err(format!("line {}: expected `key value`", n + 1));
|
||||
};
|
||||
let val = val.trim();
|
||||
if key == "name" {
|
||||
self.name = val.to_uppercase();
|
||||
continue;
|
||||
"driver" => {
|
||||
self.driver = val.to_uppercase();
|
||||
continue;
|
||||
}
|
||||
"trailer" => {
|
||||
self.trailer = matches!(val, "yes" | "true" | "1");
|
||||
continue;
|
||||
}
|
||||
"original" => {
|
||||
self.original = val
|
||||
.split_whitespace()
|
||||
.filter_map(|x| x.parse().ok())
|
||||
.collect();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let num: f32 = val
|
||||
.parse()
|
||||
@@ -338,24 +431,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let mut v = Vehicle {
|
||||
let blank = |frames| Vehicle {
|
||||
id: "f1".into(),
|
||||
name: "X".into(),
|
||||
name: String::new(),
|
||||
driver: String::new(),
|
||||
trailer: false,
|
||||
original: Vec::new(),
|
||||
menu_picture: None,
|
||||
spec: BASE_SPEC,
|
||||
frames: vec![0; TILTS * HEADINGS * SPR_W * SPR_H],
|
||||
frames,
|
||||
};
|
||||
let mut v = blank(vec![0; TILTS * HEADINGS * SPR_W * SPR_H]);
|
||||
v.driver = "SIMON".into();
|
||||
v.original = vec![60, 71, 30];
|
||||
v.spec.grip = 6.25;
|
||||
v.name = "FORMULA 1".into();
|
||||
let txt = v.settings_text();
|
||||
let mut w = Vehicle {
|
||||
id: "f1".into(),
|
||||
name: String::new(),
|
||||
spec: BASE_SPEC,
|
||||
frames: Vec::new(),
|
||||
};
|
||||
let mut w = blank(Vec::new());
|
||||
w.apply_settings(&txt).unwrap();
|
||||
assert_eq!(w.spec, v.spec);
|
||||
assert_eq!(w.name, "FORMULA 1");
|
||||
assert_eq!(w.driver, "SIMON");
|
||||
assert_eq!(w.original, v.original);
|
||||
assert!(w.apply_settings("grip fast").is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! IFF readers: 8SVX sounds and ILBM pictures.
|
||||
|
||||
use super::{be16, be32};
|
||||
|
||||
/// An 8-bit signed sample, as stored in an 8SVX `BODY`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Sound {
|
||||
pub rate: u32,
|
||||
pub data: Vec<i8>,
|
||||
/// Loop start in samples, if the sound repeats.
|
||||
pub loop_start: Option<usize>,
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Picture {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
/// Palette indices, w * h.
|
||||
pub pixels: Vec<u8>,
|
||||
/// 8-bit RGB triples.
|
||||
pub palette: Vec<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// (chunk id, chunk body) pairs.
|
||||
type Chunks<'a> = Vec<(&'a [u8], &'a [u8])>;
|
||||
|
||||
fn chunks(d: &[u8]) -> Option<(&[u8], Chunks<'_>)> {
|
||||
if d.len() < 12 || &d[0..4] != b"FORM" {
|
||||
return None;
|
||||
}
|
||||
let end = (be32(d, 4) as usize + 8).min(d.len());
|
||||
let form = &d[8..12];
|
||||
let mut out = Vec::new();
|
||||
let mut p = 12;
|
||||
while p + 8 <= end {
|
||||
let id = &d[p..p + 4];
|
||||
let n = be32(d, p + 4) as usize;
|
||||
let body = d.get(p + 8..p + 8 + n)?;
|
||||
out.push((id, body));
|
||||
p += 8 + n + (n & 1);
|
||||
}
|
||||
Some((form, out))
|
||||
}
|
||||
|
||||
pub fn is_8svx(d: &[u8]) -> bool {
|
||||
d.len() >= 12 && &d[0..4] == b"FORM" && &d[8..12] == b"8SVX"
|
||||
}
|
||||
|
||||
pub fn read_8svx(d: &[u8]) -> Result<Sound, String> {
|
||||
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
|
||||
if form != b"8SVX" {
|
||||
return Err("not an 8SVX sound".into());
|
||||
}
|
||||
let vhdr = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"VHDR")
|
||||
.ok_or("no VHDR chunk")?
|
||||
.1;
|
||||
let body = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BODY")
|
||||
.ok_or("no BODY chunk")?
|
||||
.1;
|
||||
if vhdr.len() < 20 {
|
||||
return Err("short VHDR".into());
|
||||
}
|
||||
let one_shot = be32(vhdr, 0) as usize;
|
||||
let repeat = be32(vhdr, 4) as usize;
|
||||
let rate = be16(vhdr, 12) as u32;
|
||||
if vhdr[15] != 0 {
|
||||
return Err("compressed 8SVX is not supported".into());
|
||||
}
|
||||
let volume = (be32(vhdr, 16) as f32 / 65536.0).clamp(0.0, 1.0);
|
||||
// Only the first octave is used; multi-octave sounds store more after it.
|
||||
let len = if one_shot + repeat > 0 {
|
||||
(one_shot + repeat).min(body.len())
|
||||
} else {
|
||||
body.len()
|
||||
};
|
||||
let data = body[..len].iter().map(|&b| b as i8).collect();
|
||||
let loop_start = (repeat > 2).then_some(one_shot);
|
||||
Ok(Sound {
|
||||
rate: rate.max(1000),
|
||||
data,
|
||||
loop_start,
|
||||
volume,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_ilbm(d: &[u8]) -> Result<Picture, String> {
|
||||
let (form, chunks) = chunks(d).ok_or("not an IFF file")?;
|
||||
if form != b"ILBM" {
|
||||
return Err("not an ILBM picture".into());
|
||||
}
|
||||
let bmhd = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BMHD")
|
||||
.ok_or("no BMHD chunk")?
|
||||
.1;
|
||||
let body = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"BODY")
|
||||
.ok_or("no BODY chunk")?
|
||||
.1;
|
||||
let w = be16(bmhd, 0) as usize;
|
||||
let h = be16(bmhd, 2) as usize;
|
||||
let planes = bmhd[8] as usize;
|
||||
let masking = bmhd[9];
|
||||
let compression = bmhd[10];
|
||||
if planes == 0 || planes > 8 || w == 0 || h == 0 {
|
||||
return Err("unsupported bitmap layout".into());
|
||||
}
|
||||
let palette = chunks
|
||||
.iter()
|
||||
.find(|c| c.0 == b"CMAP")
|
||||
.map(|c| c.1.chunks_exact(3).map(|p| (p[0], p[1], p[2])).collect())
|
||||
.unwrap_or_else(|| {
|
||||
(0..1u16 << planes)
|
||||
.map(|i| {
|
||||
let v = (i * 255 / ((1 << planes) - 1)) as u8;
|
||||
(v, v, v)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let row_bytes = w.div_ceil(16) * 2;
|
||||
let rows_per_line = planes + (masking == 1) as usize;
|
||||
let mut raw = Vec::with_capacity(row_bytes * rows_per_line * h);
|
||||
match compression {
|
||||
0 => raw.extend_from_slice(body),
|
||||
1 => {
|
||||
// ByteRun1
|
||||
let mut i = 0;
|
||||
while i < body.len() && raw.len() < row_bytes * rows_per_line * h {
|
||||
let n = body[i] as i8;
|
||||
i += 1;
|
||||
if n >= 0 {
|
||||
let k = n as usize + 1;
|
||||
raw.extend_from_slice(body.get(i..i + k).ok_or("ByteRun1 data ended early")?);
|
||||
i += k;
|
||||
} else if n != -128 {
|
||||
let b = *body.get(i).ok_or("ByteRun1 data ended early")?;
|
||||
raw.extend(std::iter::repeat_n(b, (1 - n as isize) as usize));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown ILBM compression {compression}")),
|
||||
}
|
||||
if raw.len() < row_bytes * rows_per_line * h {
|
||||
return Err("BODY is shorter than the picture".into());
|
||||
}
|
||||
let mut pixels = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
for p in 0..planes {
|
||||
let row = &raw[(y * rows_per_line + p) * row_bytes..][..row_bytes];
|
||||
for x in 0..w {
|
||||
if row[x >> 3] & (0x80 >> (x & 7)) != 0 {
|
||||
pixels[y * w + x] |= 1 << p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Picture {
|
||||
w,
|
||||
h,
|
||||
pixels,
|
||||
palette,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes 8-bit unsigned mono WAV, with the loop start in a `smpl` chunk so
|
||||
/// sample editors keep it.
|
||||
pub fn sound_to_wav(s: &Sound) -> Vec<u8> {
|
||||
let data: Vec<u8> = s.data.iter().map(|&v| (v as i16 + 128) as u8).collect();
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = Vec::new();
|
||||
let mut fmt = Vec::new();
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&s.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&s.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&1u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&8u16.to_le_bytes());
|
||||
chunks.push((b"fmt ", fmt));
|
||||
chunks.push((b"data", data));
|
||||
if let Some(start) = s.loop_start {
|
||||
let mut smpl = vec![0u8; 36];
|
||||
smpl[28..32].copy_from_slice(&1u32.to_le_bytes());
|
||||
let mut lp = vec![0u8; 24];
|
||||
lp[8..12].copy_from_slice(&(start as u32).to_le_bytes());
|
||||
lp[12..16].copy_from_slice(&(s.data.len() as u32 - 1).to_le_bytes());
|
||||
smpl.extend(lp);
|
||||
chunks.push((b"smpl", smpl));
|
||||
}
|
||||
let mut out = b"RIFF\0\0\0\0WAVE".to_vec();
|
||||
for (id, body) in chunks {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
if body.len() & 1 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
let n = (out.len() - 8) as u32;
|
||||
out[4..8].copy_from_slice(&n.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads mono or stereo 8/16-bit PCM WAV (stereo is mixed down).
|
||||
pub fn wav_to_sound(d: &[u8]) -> Result<Sound, String> {
|
||||
if d.len() < 12 || &d[0..4] != b"RIFF" || &d[8..12] != b"WAVE" {
|
||||
return Err("not a WAV file".into());
|
||||
}
|
||||
let le16 = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
let le32 = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
||||
let (mut fmt, mut data, mut loop_start) = (None, None, None);
|
||||
let mut p = 12;
|
||||
while p + 8 <= d.len() {
|
||||
let n = le32(p + 4) as usize;
|
||||
let body = p + 8..(p + 8 + n).min(d.len());
|
||||
match &d[p..p + 4] {
|
||||
b"fmt " if n >= 16 => {
|
||||
fmt = Some((le16(p + 8), le16(p + 10), le32(p + 12), le16(p + 22)))
|
||||
}
|
||||
b"data" => data = Some(body),
|
||||
b"smpl" if n >= 60 && le32(p + 8 + 28) > 0 => {
|
||||
loop_start = Some(le32(p + 8 + 36 + 8) as usize)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p += 8 + n + (n & 1);
|
||||
}
|
||||
let (format, channels, rate, bits) = fmt.ok_or("no fmt chunk")?;
|
||||
let body = &d[data.ok_or("no data chunk")?];
|
||||
if format != 1 || channels == 0 || !(bits == 8 || bits == 16) {
|
||||
return Err("only 8- or 16-bit PCM WAV is supported".into());
|
||||
}
|
||||
let step = channels as usize * (bits as usize / 8);
|
||||
let sample = |frame: &[u8]| -> i32 {
|
||||
let mut acc = 0i32;
|
||||
for c in 0..channels as usize {
|
||||
acc += if bits == 8 {
|
||||
(frame[c] as i32 - 128) << 8
|
||||
} else {
|
||||
i16::from_le_bytes([frame[2 * c], frame[2 * c + 1]]) as i32
|
||||
};
|
||||
}
|
||||
acc / channels as i32
|
||||
};
|
||||
let data: Vec<i8> = body
|
||||
.chunks_exact(step)
|
||||
.map(|f| (sample(f) >> 8) as i8)
|
||||
.collect();
|
||||
let loop_start = loop_start.filter(|&s| s + 2 < data.len());
|
||||
Ok(Sound {
|
||||
rate: rate.max(1000),
|
||||
data,
|
||||
loop_start,
|
||||
volume: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wav_round_trip() {
|
||||
let s = Sound {
|
||||
rate: 8363,
|
||||
data: (-50..50).map(|v| v as i8).collect(),
|
||||
loop_start: Some(20),
|
||||
volume: 1.0,
|
||||
};
|
||||
let back = wav_to_sound(&sound_to_wav(&s)).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byterun1_picture() {
|
||||
// 16x1, 1 plane: run of 2 x 0xff.
|
||||
let mut d = b"FORM\0\0\0\0ILBMBMHD\0\0\0\x14".to_vec();
|
||||
let mut bmhd = vec![0u8; 20];
|
||||
bmhd[1] = 16;
|
||||
bmhd[3] = 1;
|
||||
bmhd[8] = 1;
|
||||
bmhd[10] = 1;
|
||||
d.extend(bmhd);
|
||||
d.extend(b"BODY\0\0\0\x02\xff\xff");
|
||||
let n = (d.len() - 8) as u32;
|
||||
d[4..8].copy_from_slice(&n.to_be_bytes());
|
||||
let p = read_ilbm(&d).unwrap();
|
||||
assert_eq!(p.pixels, vec![1; 16]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! LHA/LZH archive reader (header levels 0, 1 and 2).
|
||||
//!
|
||||
//! The game disks only use the `-lh0-` (stored) method because every file is
|
||||
//! already CrunchMania-packed, so compressed methods are reported rather than
|
||||
//! decoded.
|
||||
//! Supports the stored (`-lh0-`, `-lz4-`) and LZSS/Huffman (`-lh5-`, `-lh6-`,
|
||||
//! `-lh7-`) methods. Every decoded file is checked against the header CRC.
|
||||
//! Entries in other methods, or that fail to decode, carry the reason in
|
||||
//! `data` instead of their contents.
|
||||
|
||||
use super::lzh;
|
||||
use super::{latin1, le16, le32};
|
||||
use std::fmt;
|
||||
|
||||
@@ -28,8 +30,8 @@ pub struct LhaEntry {
|
||||
/// Path inside the archive, `/`-separated.
|
||||
pub path: String,
|
||||
pub method: String,
|
||||
/// File contents for stored entries, `None` for methods we do not decode.
|
||||
pub data: Option<Vec<u8>>,
|
||||
/// File contents, or why they could not be read.
|
||||
pub data: Result<Vec<u8>, String>,
|
||||
}
|
||||
|
||||
pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
@@ -45,7 +47,9 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
}
|
||||
let method = latin1(method);
|
||||
let mut packed = le32(d, p + 7) as usize;
|
||||
let original = le32(d, p + 11) as usize;
|
||||
let level = d[p + 20];
|
||||
let mut crc = None;
|
||||
|
||||
let mut name = String::new();
|
||||
let mut dir = String::new();
|
||||
@@ -59,6 +63,9 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
return Err(LhaError::Truncated(p));
|
||||
}
|
||||
name = latin1(&d[p + 22..p + 22 + name_len]);
|
||||
if p + 24 + name_len <= d.len() {
|
||||
crc = Some(le16(d, p + 22 + name_len));
|
||||
}
|
||||
let mut q = p + header_len;
|
||||
if level == 1 {
|
||||
// Extended headers follow the base header and are counted
|
||||
@@ -81,6 +88,7 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
if p + header_len > d.len() || header_len < 26 {
|
||||
return Err(LhaError::Truncated(p));
|
||||
}
|
||||
crc = Some(le16(d, p + 21));
|
||||
let mut q = p + 26;
|
||||
let mut next = le16(d, p + 24) as usize;
|
||||
while next != 0 {
|
||||
@@ -101,10 +109,18 @@ pub fn parse(d: &[u8]) -> Result<Vec<LhaEntry>, LhaError> {
|
||||
return Err(LhaError::Truncated(data_start));
|
||||
}
|
||||
let path = join_path(&dir, &name);
|
||||
let data = match method.as_str() {
|
||||
"-lh0-" | "-lz4-" => Some(d[data_start..data_end].to_vec()),
|
||||
_ => None,
|
||||
let body = &d[data_start..data_end];
|
||||
let decoded = match method.as_str() {
|
||||
"-lh0-" | "-lz4-" => Ok(body.to_vec()),
|
||||
"-lh5-" => lzh::decode(body, original, 13).map_err(|e| e.to_string()),
|
||||
"-lh6-" => lzh::decode(body, original, 15).map_err(|e| e.to_string()),
|
||||
"-lh7-" => lzh::decode(body, original, 16).map_err(|e| e.to_string()),
|
||||
other => Err(format!("unsupported method {other}")),
|
||||
};
|
||||
let data = decoded.and_then(|v| match crc {
|
||||
Some(c) if lzh::crc16(&v) != c => Err("CRC mismatch".to_string()),
|
||||
_ => Ok(v),
|
||||
});
|
||||
if method != "-lhd-" {
|
||||
out.push(LhaEntry { path, method, data });
|
||||
}
|
||||
@@ -149,7 +165,7 @@ mod tests {
|
||||
h[20] = 0;
|
||||
h[21] = name.len() as u8;
|
||||
h.extend_from_slice(name.as_bytes());
|
||||
h.extend_from_slice(&[0, 0]); // CRC, unchecked
|
||||
h.extend_from_slice(&lzh::crc16(body).to_le_bytes());
|
||||
h[0] = (h.len() - 2) as u8;
|
||||
h.extend_from_slice(body);
|
||||
h
|
||||
@@ -163,7 +179,7 @@ mod tests {
|
||||
let e = parse(&arc).unwrap();
|
||||
assert_eq!(e.len(), 2);
|
||||
assert_eq!(e[0].path, "dir/a.bin");
|
||||
assert_eq!(e[0].data.as_deref(), Some(&b"hello"[..]));
|
||||
assert_eq!(e[1].data.as_deref(), Some(&b"world!"[..]));
|
||||
assert_eq!(e[0].data.as_deref(), Ok(&b"hello"[..]));
|
||||
assert_eq!(e[1].data.as_deref(), Ok(&b"world!"[..]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Decoder for the LHA `-lh5-`, `-lh6-` and `-lh7-` methods: LZSS with
|
||||
//! static Huffman blocks, as used by LHA 2.x on the Amiga.
|
||||
//!
|
||||
//! Each block starts with a 16-bit symbol count, then three code-length
|
||||
//! tables: one for the code-length codes (19 symbols), one for literals and
|
||||
//! lengths (510 symbols) and one for match offsets. Codes are canonical,
|
||||
//! assigned in symbol order within each length, and read MSB first.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum LzhError {
|
||||
Truncated,
|
||||
BadTable,
|
||||
BadReference,
|
||||
}
|
||||
|
||||
impl fmt::Display for LzhError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
LzhError::Truncated => "compressed data ended early",
|
||||
LzhError::BadTable => "invalid Huffman table",
|
||||
LzhError::BadReference => "match reaches before the start of the file",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LzhError {}
|
||||
|
||||
const NC: usize = 510;
|
||||
const NT: usize = 19;
|
||||
const TBIT: u32 = 5;
|
||||
const CBIT: u32 = 9;
|
||||
|
||||
struct Bits<'a> {
|
||||
d: &'a [u8],
|
||||
pos: usize,
|
||||
buf: u64,
|
||||
len: u32,
|
||||
}
|
||||
|
||||
impl Bits<'_> {
|
||||
fn fill(&mut self) {
|
||||
while self.len <= 56 {
|
||||
// Reading past the end yields zero bits; callers detect overrun
|
||||
// through `overrun()` once a block has been decoded.
|
||||
let b = self.d.get(self.pos).copied().unwrap_or(0);
|
||||
self.pos += 1;
|
||||
self.buf |= (b as u64) << (56 - self.len);
|
||||
self.len += 8;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&mut self, n: u32) -> u32 {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.fill();
|
||||
(self.buf >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
fn skip(&mut self, n: u32) {
|
||||
self.fill();
|
||||
self.buf <<= n;
|
||||
self.len -= n;
|
||||
}
|
||||
|
||||
fn get(&mut self, n: u32) -> u32 {
|
||||
let v = self.peek(n);
|
||||
self.skip(n);
|
||||
v
|
||||
}
|
||||
|
||||
fn overrun(&self) -> bool {
|
||||
self.pos > self.d.len() + 8
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Huffman decoder built from code lengths.
|
||||
struct Table {
|
||||
/// A table with one symbol consumes no bits.
|
||||
single: Option<u16>,
|
||||
count: [u16; 17],
|
||||
symbols: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn single(sym: u16) -> Table {
|
||||
Table {
|
||||
single: Some(sym),
|
||||
count: [0; 17],
|
||||
symbols: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_lengths(lengths: &[u8]) -> Result<Table, LzhError> {
|
||||
let mut count = [0u16; 17];
|
||||
for &l in lengths {
|
||||
if l as usize > 16 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
count[l as usize] += 1;
|
||||
}
|
||||
count[0] = 0;
|
||||
let mut symbols = Vec::new();
|
||||
for len in 1..=16u8 {
|
||||
for (s, &l) in lengths.iter().enumerate() {
|
||||
if l == len {
|
||||
symbols.push(s as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Over-subscribed code sets are corrupt; incomplete ones are allowed.
|
||||
let mut left = 1i32;
|
||||
for &c in &count[1..] {
|
||||
left = left * 2 - c as i32;
|
||||
if left < 0 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
}
|
||||
Ok(Table {
|
||||
single: None,
|
||||
count,
|
||||
symbols,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(&self, bits: &mut Bits) -> Result<u16, LzhError> {
|
||||
if let Some(s) = self.single {
|
||||
return Ok(s);
|
||||
}
|
||||
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
|
||||
for len in 1..=16 {
|
||||
code |= bits.get(1) as i32;
|
||||
let n = self.count[len] as i32;
|
||||
if code - first < n {
|
||||
return Ok(self.symbols[(index + code - first) as usize]);
|
||||
}
|
||||
index += n;
|
||||
first = (first + n) << 1;
|
||||
code <<= 1;
|
||||
}
|
||||
Err(LzhError::BadTable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the code-length or offset table (`read_pt_len` in LHA).
|
||||
fn read_pt(
|
||||
bits: &mut Bits,
|
||||
nn: usize,
|
||||
nbit: u32,
|
||||
special: Option<usize>,
|
||||
) -> Result<Table, LzhError> {
|
||||
let n = bits.get(nbit) as usize;
|
||||
if n == 0 {
|
||||
return Ok(Table::single(bits.get(nbit) as u16));
|
||||
}
|
||||
if n > nn {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
let mut lens = vec![0u8; nn];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut c = bits.peek(3);
|
||||
if c == 7 {
|
||||
// Lengths of 7 and above: unary extension after the first 3 bits.
|
||||
let mut mask = 1u32 << 12;
|
||||
let look = bits.peek(16);
|
||||
while look & mask != 0 {
|
||||
mask >>= 1;
|
||||
c += 1;
|
||||
if c > 16 {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
}
|
||||
bits.skip(c - 3);
|
||||
} else {
|
||||
bits.skip(3);
|
||||
}
|
||||
lens[i] = c as u8;
|
||||
i += 1;
|
||||
if Some(i) == special {
|
||||
let zeros = bits.get(2) as usize;
|
||||
i = (i + zeros).min(nn);
|
||||
}
|
||||
}
|
||||
Table::from_lengths(&lens)
|
||||
}
|
||||
|
||||
/// Reads the literal/length table (`read_c_len` in LHA).
|
||||
fn read_c(bits: &mut Bits, pt: &Table) -> Result<Table, LzhError> {
|
||||
let n = bits.get(CBIT) as usize;
|
||||
if n == 0 {
|
||||
return Ok(Table::single(bits.get(CBIT) as u16));
|
||||
}
|
||||
if n > NC {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
let mut lens = vec![0u8; NC];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
match pt.decode(bits)? {
|
||||
0 => i += 1,
|
||||
1 => i += bits.get(4) as usize + 3,
|
||||
2 => i += bits.get(CBIT) as usize + 20,
|
||||
c => {
|
||||
lens[i] = (c - 2) as u8;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if i > NC {
|
||||
return Err(LzhError::BadTable);
|
||||
}
|
||||
Table::from_lengths(&lens)
|
||||
}
|
||||
|
||||
/// Decodes `original_size` bytes. `dict_bits` is 13 for lh5, 15 for lh6
|
||||
/// and 16 for lh7.
|
||||
pub fn decode(d: &[u8], original_size: usize, dict_bits: u32) -> Result<Vec<u8>, LzhError> {
|
||||
let np = dict_bits as usize + 1;
|
||||
let pbit = if dict_bits >= 15 { 5 } else { 4 };
|
||||
let mut bits = Bits {
|
||||
d,
|
||||
pos: 0,
|
||||
buf: 0,
|
||||
len: 0,
|
||||
};
|
||||
let mut out = Vec::with_capacity(original_size);
|
||||
while out.len() < original_size {
|
||||
let mut left = bits.get(16);
|
||||
if left == 0 {
|
||||
return Err(LzhError::Truncated);
|
||||
}
|
||||
let pt = read_pt(&mut bits, NT, TBIT, Some(3))?;
|
||||
let c = read_c(&mut bits, &pt)?;
|
||||
let p = read_pt(&mut bits, np, pbit, None)?;
|
||||
while left > 0 && out.len() < original_size {
|
||||
left -= 1;
|
||||
let sym = c.decode(&mut bits)? as usize;
|
||||
if sym < 256 {
|
||||
out.push(sym as u8);
|
||||
continue;
|
||||
}
|
||||
let length = sym - 256 + 3;
|
||||
let mut dist = p.decode(&mut bits)? as u32;
|
||||
if dist > 1 {
|
||||
dist = (1 << (dist - 1)) + bits.get(dist - 1);
|
||||
}
|
||||
let dist = dist as usize + 1;
|
||||
if dist > out.len() {
|
||||
return Err(LzhError::BadReference);
|
||||
}
|
||||
let start = out.len() - dist;
|
||||
for k in 0..length.min(original_size - out.len()) {
|
||||
out.push(out[start + k]);
|
||||
}
|
||||
}
|
||||
if bits.overrun() {
|
||||
return Err(LzhError::Truncated);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-16 as used in LHA headers (polynomial 0xA001, reflected, init 0).
|
||||
pub fn crc16(d: &[u8]) -> u16 {
|
||||
let mut crc = 0u16;
|
||||
for &b in d {
|
||||
crc ^= b as u16;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 {
|
||||
(crc >> 1) ^ 0xa001
|
||||
} else {
|
||||
crc >> 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_check_value() {
|
||||
// Standard check value for CRC-16/ARC.
|
||||
assert_eq!(crc16(b"123456789"), 0xbb3d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_decode() {
|
||||
// Lengths a=1, b=2, c=2 give codes 0, 10, 11.
|
||||
let t = Table::from_lengths(&[1, 2, 2]).unwrap();
|
||||
let data = [0b0101_1000u8];
|
||||
let mut bits = Bits {
|
||||
d: &data,
|
||||
pos: 0,
|
||||
buf: 0,
|
||||
len: 0,
|
||||
};
|
||||
assert_eq!(t.decode(&mut bits), Ok(0));
|
||||
assert_eq!(t.decode(&mut bits), Ok(1));
|
||||
assert_eq!(t.decode(&mut bits), Ok(2));
|
||||
}
|
||||
}
|
||||