diff --git a/README.md b/README.md index 6017d70..e11fdc7 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/assets/README.md b/assets/README.md index 15e8e79..c195555 100644 --- a/assets/README.md +++ b/assets/README.md @@ -42,10 +42,19 @@ 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. -To add a track, make a new folder with both files. Tracks are listed in +### `thumb.png` (optional) + +The track's 123 × 32 thumbnail from the original track-select screen, in +the track's palette. It's drawn at double height in the menu, because the +original's pixels are half-width. Without one, the menu shows a shrunken +`track.png`. + +To add a track, make a new folder with `track.png` and `track.txt`. Tracks are listed in name order. ## Vehicles: `vehicles/.png` and `.txt` @@ -68,12 +77,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. +### `-menu.png` (optional) + +The car's 64 × 32 picture from the original select screen, in the same +four colours as the sprite sheet. + ### `.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 +100,44 @@ The display name and handling: | `mass` | weight in collisions | | `offroad` | speed and grip multiplier on scenery | -To add a car, add a new `.png` / `.txt` pair. +The handling values are a reconstruction, so tune them freely. To add a +car, add a new `.png` / `.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: + +- `ball.png`: 64 frames of a spinning ball with the Acid logo, 16 frames + across, each 32 × 33. Its real colours aren't known, so it has a grey + palette. +- `chat2.png`, `mouse.png`, `sign.png`: small odds and ends. ## Palettes: `palettes/*.pal` (optional) diff --git a/assets/gfx/ball.png b/assets/gfx/ball.png new file mode 100644 index 0000000..af73698 Binary files /dev/null and b/assets/gfx/ball.png differ diff --git a/assets/gfx/chat2.png b/assets/gfx/chat2.png new file mode 100644 index 0000000..2268e3b Binary files /dev/null and b/assets/gfx/chat2.png differ diff --git a/assets/gfx/mouse.png b/assets/gfx/mouse.png new file mode 100644 index 0000000..1bd9944 Binary files /dev/null and b/assets/gfx/mouse.png differ diff --git a/assets/gfx/sign.png b/assets/gfx/sign.png new file mode 100644 index 0000000..f725ea0 Binary files /dev/null and b/assets/gfx/sign.png differ diff --git a/assets/gfx/skidtoon.png b/assets/gfx/skidtoon.png new file mode 100644 index 0000000..3238f64 Binary files /dev/null and b/assets/gfx/skidtoon.png differ diff --git a/assets/music/tune.med b/assets/music/tune.med new file mode 100644 index 0000000..3d91c34 Binary files /dev/null and b/assets/music/tune.med differ diff --git a/assets/sounds/backfire.wav b/assets/sounds/backfire.wav new file mode 100644 index 0000000..98e7eb2 Binary files /dev/null and b/assets/sounds/backfire.wav differ diff --git a/assets/sounds/beep.wav b/assets/sounds/beep.wav new file mode 100644 index 0000000..5d9024c Binary files /dev/null and b/assets/sounds/beep.wav differ diff --git a/assets/sounds/beep2.wav b/assets/sounds/beep2.wav new file mode 100644 index 0000000..7b36af5 Binary files /dev/null and b/assets/sounds/beep2.wav differ diff --git a/assets/sounds/crash1.wav b/assets/sounds/crash1.wav new file mode 100644 index 0000000..5a5acbd Binary files /dev/null and b/assets/sounds/crash1.wav differ diff --git a/assets/sounds/crash2.wav b/assets/sounds/crash2.wav new file mode 100644 index 0000000..37d07d6 Binary files /dev/null and b/assets/sounds/crash2.wav differ diff --git a/assets/sounds/crash3.wav b/assets/sounds/crash3.wav new file mode 100644 index 0000000..5915735 Binary files /dev/null and b/assets/sounds/crash3.wav differ diff --git a/assets/sounds/engine.wav b/assets/sounds/engine.wav new file mode 100644 index 0000000..f1d29a0 Binary files /dev/null and b/assets/sounds/engine.wav differ diff --git a/assets/sounds/horn.wav b/assets/sounds/horn.wav new file mode 100644 index 0000000..6e68b6a Binary files /dev/null and b/assets/sounds/horn.wav differ diff --git a/assets/sounds/motor.wav b/assets/sounds/motor.wav new file mode 100644 index 0000000..cd5967d Binary files /dev/null and b/assets/sounds/motor.wav differ diff --git a/assets/sounds/motor3.wav b/assets/sounds/motor3.wav new file mode 100644 index 0000000..226df0f Binary files /dev/null and b/assets/sounds/motor3.wav differ diff --git a/assets/sounds/screech.wav b/assets/sounds/screech.wav new file mode 100644 index 0000000..b396e6e Binary files /dev/null and b/assets/sounds/screech.wav differ diff --git a/assets/sounds/screech2.wav b/assets/sounds/screech2.wav new file mode 100644 index 0000000..370a1fa Binary files /dev/null and b/assets/sounds/screech2.wav differ diff --git a/assets/sounds/screech3.wav b/assets/sounds/screech3.wav new file mode 100644 index 0000000..481d2c0 Binary files /dev/null and b/assets/sounds/screech3.wav differ diff --git a/assets/tracks/track1-1/thumb.png b/assets/tracks/track1-1/thumb.png new file mode 100644 index 0000000..113c77e Binary files /dev/null and b/assets/tracks/track1-1/thumb.png differ diff --git a/assets/tracks/track1-1/track.png b/assets/tracks/track1-1/track.png index cd3c03e..e254105 100644 Binary files a/assets/tracks/track1-1/track.png and b/assets/tracks/track1-1/track.png differ diff --git a/assets/tracks/track1-1/track.txt b/assets/tracks/track1-1/track.txt index 4ec564a..b38f9b6 100644 --- a/assets/tracks/track1-1/track.txt +++ b/assets/tracks/track1-1/track.txt @@ -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 diff --git a/assets/tracks/track1-2/thumb.png b/assets/tracks/track1-2/thumb.png new file mode 100644 index 0000000..b1c8289 Binary files /dev/null and b/assets/tracks/track1-2/thumb.png differ diff --git a/assets/tracks/track1-2/track.png b/assets/tracks/track1-2/track.png index 2b016fe..4fa1b6d 100644 Binary files a/assets/tracks/track1-2/track.png and b/assets/tracks/track1-2/track.png differ diff --git a/assets/tracks/track1-2/track.txt b/assets/tracks/track1-2/track.txt index d66dc0f..f1718d8 100644 --- a/assets/tracks/track1-2/track.txt +++ b/assets/tracks/track1-2/track.txt @@ -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 diff --git a/assets/tracks/track1-3/thumb.png b/assets/tracks/track1-3/thumb.png new file mode 100644 index 0000000..a95fc6b Binary files /dev/null and b/assets/tracks/track1-3/thumb.png differ diff --git a/assets/tracks/track1-3/track.png b/assets/tracks/track1-3/track.png index 4cc87e6..d92666b 100644 Binary files a/assets/tracks/track1-3/track.png and b/assets/tracks/track1-3/track.png differ diff --git a/assets/tracks/track1-3/track.txt b/assets/tracks/track1-3/track.txt index cecec04..ef721b9 100644 --- a/assets/tracks/track1-3/track.txt +++ b/assets/tracks/track1-3/track.txt @@ -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 diff --git a/assets/tracks/track1-4/thumb.png b/assets/tracks/track1-4/thumb.png new file mode 100644 index 0000000..3feb7c2 Binary files /dev/null and b/assets/tracks/track1-4/thumb.png differ diff --git a/assets/tracks/track1-4/track.png b/assets/tracks/track1-4/track.png index 5c64086..22b6514 100644 Binary files a/assets/tracks/track1-4/track.png and b/assets/tracks/track1-4/track.png differ diff --git a/assets/tracks/track1-4/track.txt b/assets/tracks/track1-4/track.txt index e9e9960..0149810 100644 --- a/assets/tracks/track1-4/track.txt +++ b/assets/tracks/track1-4/track.txt @@ -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 diff --git a/assets/tracks/track1-5/thumb.png b/assets/tracks/track1-5/thumb.png new file mode 100644 index 0000000..dd4ab57 Binary files /dev/null and b/assets/tracks/track1-5/thumb.png differ diff --git a/assets/tracks/track1-5/track.png b/assets/tracks/track1-5/track.png index 3f10d94..9ba0206 100644 Binary files a/assets/tracks/track1-5/track.png and b/assets/tracks/track1-5/track.png differ diff --git a/assets/tracks/track1-5/track.txt b/assets/tracks/track1-5/track.txt index 96fe681..be020d4 100644 --- a/assets/tracks/track1-5/track.txt +++ b/assets/tracks/track1-5/track.txt @@ -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 diff --git a/assets/tracks/track1-6/thumb.png b/assets/tracks/track1-6/thumb.png new file mode 100644 index 0000000..58fea65 Binary files /dev/null and b/assets/tracks/track1-6/thumb.png differ diff --git a/assets/tracks/track1-6/track.png b/assets/tracks/track1-6/track.png index de23f0a..9379e89 100644 Binary files a/assets/tracks/track1-6/track.png and b/assets/tracks/track1-6/track.png differ diff --git a/assets/tracks/track1-6/track.txt b/assets/tracks/track1-6/track.txt index 432f2c3..3f71a25 100644 --- a/assets/tracks/track1-6/track.txt +++ b/assets/tracks/track1-6/track.txt @@ -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 diff --git a/assets/tracks/track2-1/thumb.png b/assets/tracks/track2-1/thumb.png new file mode 100644 index 0000000..682df1c Binary files /dev/null and b/assets/tracks/track2-1/thumb.png differ diff --git a/assets/tracks/track2-1/track.png b/assets/tracks/track2-1/track.png index 9c328f8..26a4eb5 100644 Binary files a/assets/tracks/track2-1/track.png and b/assets/tracks/track2-1/track.png differ diff --git a/assets/tracks/track2-1/track.txt b/assets/tracks/track2-1/track.txt index 160eb12..f1ddc41 100644 --- a/assets/tracks/track2-1/track.txt +++ b/assets/tracks/track2-1/track.txt @@ -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 diff --git a/assets/tracks/track2-2/thumb.png b/assets/tracks/track2-2/thumb.png new file mode 100644 index 0000000..dd41a8f Binary files /dev/null and b/assets/tracks/track2-2/thumb.png differ diff --git a/assets/tracks/track2-2/track.png b/assets/tracks/track2-2/track.png index 19dc2c8..45bf830 100644 Binary files a/assets/tracks/track2-2/track.png and b/assets/tracks/track2-2/track.png differ diff --git a/assets/tracks/track2-2/track.txt b/assets/tracks/track2-2/track.txt index 955b8ff..c821728 100644 --- a/assets/tracks/track2-2/track.txt +++ b/assets/tracks/track2-2/track.txt @@ -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 diff --git a/assets/tracks/track2-3/thumb.png b/assets/tracks/track2-3/thumb.png new file mode 100644 index 0000000..f037ccb Binary files /dev/null and b/assets/tracks/track2-3/thumb.png differ diff --git a/assets/tracks/track2-3/track.png b/assets/tracks/track2-3/track.png index 4d62ac2..b746d53 100644 Binary files a/assets/tracks/track2-3/track.png and b/assets/tracks/track2-3/track.png differ diff --git a/assets/tracks/track2-3/track.txt b/assets/tracks/track2-3/track.txt index 531dfd6..f4a4bed 100644 --- a/assets/tracks/track2-3/track.txt +++ b/assets/tracks/track2-3/track.txt @@ -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 diff --git a/assets/tracks/track2-4/thumb.png b/assets/tracks/track2-4/thumb.png new file mode 100644 index 0000000..6a4cf3a Binary files /dev/null and b/assets/tracks/track2-4/thumb.png differ diff --git a/assets/tracks/track2-4/track.png b/assets/tracks/track2-4/track.png index 1cb3e8e..b44ef7a 100644 Binary files a/assets/tracks/track2-4/track.png and b/assets/tracks/track2-4/track.png differ diff --git a/assets/tracks/track2-4/track.txt b/assets/tracks/track2-4/track.txt index 9e237bd..c5e6ea9 100644 --- a/assets/tracks/track2-4/track.txt +++ b/assets/tracks/track2-4/track.txt @@ -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 diff --git a/assets/tracks/track2-5/thumb.png b/assets/tracks/track2-5/thumb.png new file mode 100644 index 0000000..cd17654 Binary files /dev/null and b/assets/tracks/track2-5/thumb.png differ diff --git a/assets/tracks/track2-5/track.png b/assets/tracks/track2-5/track.png index 8dbbc09..eee6ed5 100644 Binary files a/assets/tracks/track2-5/track.png and b/assets/tracks/track2-5/track.png differ diff --git a/assets/tracks/track2-5/track.txt b/assets/tracks/track2-5/track.txt index 618574a..534c1da 100644 --- a/assets/tracks/track2-5/track.txt +++ b/assets/tracks/track2-5/track.txt @@ -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 diff --git a/assets/tracks/track2-6/thumb.png b/assets/tracks/track2-6/thumb.png new file mode 100644 index 0000000..e490b5d Binary files /dev/null and b/assets/tracks/track2-6/thumb.png differ diff --git a/assets/tracks/track2-6/track.png b/assets/tracks/track2-6/track.png index ba64ee6..6c3d150 100644 Binary files a/assets/tracks/track2-6/track.png and b/assets/tracks/track2-6/track.png differ diff --git a/assets/tracks/track2-6/track.txt b/assets/tracks/track2-6/track.txt index 75a6ae1..644edbe 100644 --- a/assets/tracks/track2-6/track.txt +++ b/assets/tracks/track2-6/track.txt @@ -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 diff --git a/assets/tracks/track3-1/thumb.png b/assets/tracks/track3-1/thumb.png new file mode 100644 index 0000000..0c1ceec Binary files /dev/null and b/assets/tracks/track3-1/thumb.png differ diff --git a/assets/tracks/track3-1/track.png b/assets/tracks/track3-1/track.png index 2fe73aa..1af4448 100644 Binary files a/assets/tracks/track3-1/track.png and b/assets/tracks/track3-1/track.png differ diff --git a/assets/tracks/track3-1/track.txt b/assets/tracks/track3-1/track.txt index 43076c4..e3ec483 100644 --- a/assets/tracks/track3-1/track.txt +++ b/assets/tracks/track3-1/track.txt @@ -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 diff --git a/assets/tracks/track3-2/thumb.png b/assets/tracks/track3-2/thumb.png new file mode 100644 index 0000000..4617418 Binary files /dev/null and b/assets/tracks/track3-2/thumb.png differ diff --git a/assets/tracks/track3-2/track.png b/assets/tracks/track3-2/track.png index a557fce..9eae5b2 100644 Binary files a/assets/tracks/track3-2/track.png and b/assets/tracks/track3-2/track.png differ diff --git a/assets/tracks/track3-2/track.txt b/assets/tracks/track3-2/track.txt index 4cf94ef..1d72498 100644 --- a/assets/tracks/track3-2/track.txt +++ b/assets/tracks/track3-2/track.txt @@ -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 diff --git a/assets/tracks/track3-3/thumb.png b/assets/tracks/track3-3/thumb.png new file mode 100644 index 0000000..748dbaa Binary files /dev/null and b/assets/tracks/track3-3/thumb.png differ diff --git a/assets/tracks/track3-3/track.png b/assets/tracks/track3-3/track.png index 9b50388..6bc19f1 100644 Binary files a/assets/tracks/track3-3/track.png and b/assets/tracks/track3-3/track.png differ diff --git a/assets/tracks/track3-3/track.txt b/assets/tracks/track3-3/track.txt index dd34afe..def0b42 100644 --- a/assets/tracks/track3-3/track.txt +++ b/assets/tracks/track3-3/track.txt @@ -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 diff --git a/assets/tracks/track3-4/thumb.png b/assets/tracks/track3-4/thumb.png new file mode 100644 index 0000000..2675dff Binary files /dev/null and b/assets/tracks/track3-4/thumb.png differ diff --git a/assets/tracks/track3-4/track.png b/assets/tracks/track3-4/track.png index 8c993ae..c1746e6 100644 Binary files a/assets/tracks/track3-4/track.png and b/assets/tracks/track3-4/track.png differ diff --git a/assets/tracks/track3-4/track.txt b/assets/tracks/track3-4/track.txt index 8ea54b9..f837bee 100644 --- a/assets/tracks/track3-4/track.txt +++ b/assets/tracks/track3-4/track.txt @@ -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 diff --git a/assets/tracks/track3-5/thumb.png b/assets/tracks/track3-5/thumb.png new file mode 100644 index 0000000..33511ef Binary files /dev/null and b/assets/tracks/track3-5/thumb.png differ diff --git a/assets/tracks/track3-5/track.png b/assets/tracks/track3-5/track.png index 68c173e..9f728cc 100644 Binary files a/assets/tracks/track3-5/track.png and b/assets/tracks/track3-5/track.png differ diff --git a/assets/tracks/track3-5/track.txt b/assets/tracks/track3-5/track.txt index ab4b934..a1b37fc 100644 --- a/assets/tracks/track3-5/track.txt +++ b/assets/tracks/track3-5/track.txt @@ -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 diff --git a/assets/tracks/track3-6/thumb.png b/assets/tracks/track3-6/thumb.png new file mode 100644 index 0000000..7caaab1 Binary files /dev/null and b/assets/tracks/track3-6/thumb.png differ diff --git a/assets/tracks/track3-6/track.png b/assets/tracks/track3-6/track.png index 650072a..4beb847 100644 Binary files a/assets/tracks/track3-6/track.png and b/assets/tracks/track3-6/track.png differ diff --git a/assets/tracks/track3-6/track.txt b/assets/tracks/track3-6/track.txt index ef8fed3..69893c1 100644 --- a/assets/tracks/track3-6/track.txt +++ b/assets/tracks/track3-6/track.txt @@ -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 diff --git a/assets/tracks/track4-1/thumb.png b/assets/tracks/track4-1/thumb.png new file mode 100644 index 0000000..7cc3f70 Binary files /dev/null and b/assets/tracks/track4-1/thumb.png differ diff --git a/assets/tracks/track4-1/track.png b/assets/tracks/track4-1/track.png index 792682d..9467576 100644 Binary files a/assets/tracks/track4-1/track.png and b/assets/tracks/track4-1/track.png differ diff --git a/assets/tracks/track4-1/track.txt b/assets/tracks/track4-1/track.txt index 08f7aee..1589b5c 100644 --- a/assets/tracks/track4-1/track.txt +++ b/assets/tracks/track4-1/track.txt @@ -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 diff --git a/assets/tracks/track4-2/thumb.png b/assets/tracks/track4-2/thumb.png new file mode 100644 index 0000000..e2d15bc Binary files /dev/null and b/assets/tracks/track4-2/thumb.png differ diff --git a/assets/tracks/track4-2/track.png b/assets/tracks/track4-2/track.png index 442cfb0..cd60973 100644 Binary files a/assets/tracks/track4-2/track.png and b/assets/tracks/track4-2/track.png differ diff --git a/assets/tracks/track4-2/track.txt b/assets/tracks/track4-2/track.txt index 95d41a2..6920a75 100644 --- a/assets/tracks/track4-2/track.txt +++ b/assets/tracks/track4-2/track.txt @@ -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 diff --git a/assets/tracks/track4-3/thumb.png b/assets/tracks/track4-3/thumb.png new file mode 100644 index 0000000..16e2dd4 Binary files /dev/null and b/assets/tracks/track4-3/thumb.png differ diff --git a/assets/tracks/track4-3/track.png b/assets/tracks/track4-3/track.png index c784d91..0e3f6b8 100644 Binary files a/assets/tracks/track4-3/track.png and b/assets/tracks/track4-3/track.png differ diff --git a/assets/tracks/track4-3/track.txt b/assets/tracks/track4-3/track.txt index f055732..bebca03 100644 --- a/assets/tracks/track4-3/track.txt +++ b/assets/tracks/track4-3/track.txt @@ -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 diff --git a/assets/tracks/track4-4/thumb.png b/assets/tracks/track4-4/thumb.png new file mode 100644 index 0000000..f04711b Binary files /dev/null and b/assets/tracks/track4-4/thumb.png differ diff --git a/assets/tracks/track4-4/track.png b/assets/tracks/track4-4/track.png index 2d90a14..61c3e07 100644 Binary files a/assets/tracks/track4-4/track.png and b/assets/tracks/track4-4/track.png differ diff --git a/assets/tracks/track4-4/track.txt b/assets/tracks/track4-4/track.txt index 3db98fc..efd1411 100644 --- a/assets/tracks/track4-4/track.txt +++ b/assets/tracks/track4-4/track.txt @@ -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 diff --git a/assets/tracks/track4-5/thumb.png b/assets/tracks/track4-5/thumb.png new file mode 100644 index 0000000..9cbb425 Binary files /dev/null and b/assets/tracks/track4-5/thumb.png differ diff --git a/assets/tracks/track4-5/track.png b/assets/tracks/track4-5/track.png index ecbc933..591e337 100644 Binary files a/assets/tracks/track4-5/track.png and b/assets/tracks/track4-5/track.png differ diff --git a/assets/tracks/track4-5/track.txt b/assets/tracks/track4-5/track.txt index 7eee00c..57fb8cb 100644 --- a/assets/tracks/track4-5/track.txt +++ b/assets/tracks/track4-5/track.txt @@ -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 diff --git a/assets/tracks/track4-6/thumb.png b/assets/tracks/track4-6/thumb.png new file mode 100644 index 0000000..916ec25 Binary files /dev/null and b/assets/tracks/track4-6/thumb.png differ diff --git a/assets/tracks/track4-6/track.png b/assets/tracks/track4-6/track.png index fe20e12..28601b8 100644 Binary files a/assets/tracks/track4-6/track.png and b/assets/tracks/track4-6/track.png differ diff --git a/assets/tracks/track4-6/track.txt b/assets/tracks/track4-6/track.txt index 692c6a2..c4446a6 100644 --- a/assets/tracks/track4-6/track.txt +++ b/assets/tracks/track4-6/track.txt @@ -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 diff --git a/assets/vehicles/camaro-menu.png b/assets/vehicles/camaro-menu.png new file mode 100644 index 0000000..430774c Binary files /dev/null and b/assets/vehicles/camaro-menu.png differ diff --git a/assets/vehicles/camaro.png b/assets/vehicles/camaro.png new file mode 100644 index 0000000..7cb8817 Binary files /dev/null and b/assets/vehicles/camaro.png differ diff --git a/assets/vehicles/camaro.txt b/assets/vehicles/camaro.txt new file mode 100644 index 0000000..f18d8dd --- /dev/null +++ b/assets/vehicles/camaro.txt @@ -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 diff --git a/assets/vehicles/caravan-menu.png b/assets/vehicles/caravan-menu.png new file mode 100644 index 0000000..a9ebb02 Binary files /dev/null and b/assets/vehicles/caravan-menu.png differ diff --git a/assets/vehicles/caravan.png b/assets/vehicles/caravan.png new file mode 100644 index 0000000..0d7af33 Binary files /dev/null and b/assets/vehicles/caravan.png differ diff --git a/assets/vehicles/caravan.txt b/assets/vehicles/caravan.txt new file mode 100644 index 0000000..0960842 --- /dev/null +++ b/assets/vehicles/caravan.txt @@ -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 diff --git a/assets/vehicles/cow-menu.png b/assets/vehicles/cow-menu.png new file mode 100644 index 0000000..80497c9 Binary files /dev/null and b/assets/vehicles/cow-menu.png differ diff --git a/assets/vehicles/cow.txt b/assets/vehicles/cow.txt index 6e19e4a..9447bd8 100644 --- a/assets/vehicles/cow.txt +++ b/assets/vehicles/cow.txt @@ -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 diff --git a/assets/vehicles/f1-menu.png b/assets/vehicles/f1-menu.png new file mode 100644 index 0000000..c5780a6 Binary files /dev/null and b/assets/vehicles/f1-menu.png differ diff --git a/assets/vehicles/f1.txt b/assets/vehicles/f1.txt index 8fba9eb..0dcd68d 100644 --- a/assets/vehicles/f1.txt +++ b/assets/vehicles/f1.txt @@ -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 diff --git a/assets/vehicles/midget-menu.png b/assets/vehicles/midget-menu.png new file mode 100644 index 0000000..0b7811a Binary files /dev/null and b/assets/vehicles/midget-menu.png differ diff --git a/assets/vehicles/midget.txt b/assets/vehicles/midget.txt index 0d4fea5..71a33cd 100644 --- a/assets/vehicles/midget.txt +++ b/assets/vehicles/midget.txt @@ -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 diff --git a/assets/vehicles/mini-menu.png b/assets/vehicles/mini-menu.png new file mode 100644 index 0000000..908b1c5 Binary files /dev/null and b/assets/vehicles/mini-menu.png differ diff --git a/assets/vehicles/mini.txt b/assets/vehicles/mini.txt index 919bb43..79b2599 100644 --- a/assets/vehicles/mini.txt +++ b/assets/vehicles/mini.txt @@ -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 diff --git a/assets/vehicles/police-menu.png b/assets/vehicles/police-menu.png new file mode 100644 index 0000000..8e8d8f2 Binary files /dev/null and b/assets/vehicles/police-menu.png differ diff --git a/assets/vehicles/police.txt b/assets/vehicles/police.txt index 036c277..8305e8b 100644 --- a/assets/vehicles/police.txt +++ b/assets/vehicles/police.txt @@ -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 diff --git a/assets/vehicles/porsche-menu.png b/assets/vehicles/porsche-menu.png new file mode 100644 index 0000000..b05eb04 Binary files /dev/null and b/assets/vehicles/porsche-menu.png differ diff --git a/assets/vehicles/porsche.txt b/assets/vehicles/porsche.txt index a91b4a5..b4ea473 100644 --- a/assets/vehicles/porsche.txt +++ b/assets/vehicles/porsche.txt @@ -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 diff --git a/assets/vehicles/truck-menu.png b/assets/vehicles/truck-menu.png new file mode 100644 index 0000000..d3cf402 Binary files /dev/null and b/assets/vehicles/truck-menu.png differ diff --git a/assets/vehicles/truck.txt b/assets/vehicles/truck.txt index ccd505f..21423ef 100644 --- a/assets/vehicles/truck.txt +++ b/assets/vehicles/truck.txt @@ -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 diff --git a/assets/vehicles/vw-menu.png b/assets/vehicles/vw-menu.png new file mode 100644 index 0000000..eac3fc2 Binary files /dev/null and b/assets/vehicles/vw-menu.png differ diff --git a/assets/vehicles/vw.txt b/assets/vehicles/vw.txt index 4b98d46..2256e14 100644 --- a/assets/vehicles/vw.txt +++ b/assets/vehicles/vw.txt @@ -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 diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 9fb69db..5b91597 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -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,33 @@ 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: thumbnails and palette 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 first 20480 bytes of section 0 are the track-selector thumbnails: +- **Picture:** one 640 × 64, 16-colour picture in the file's palette, + stored as four bitplanes one after another (80 bytes per row, 5120 bytes + per plane). +- **Layout:** the six tracks' thumbnails, 123 × 32 each, sit in a 3 × 2 + grid at its top left, in track order: top row tracks 1–3, bottom row + 4–6. +- **Display:** the picture is Amiga hi-res, whose pixels are half as wide + as low-res ones. So a thumbnail shows at 123 × 64 next to the low-res + track pictures, the same shape as the track. + +Each track section unpacks to 281240 (`0x44A98`) bytes: | Offset | Size | Contents | |----------|--------|----------| @@ -88,14 +115,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 +140,89 @@ 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` is a bank of 64 saved hardware sprites: a spinning ball with +the Acid logo. Each 544-byte frame is: + +- **Header** (16 bytes): `u32` saved memory pointer, `u16` lines (33), + `u16` strips (4), `u16` flags (`0x8020`), `u16` bytes per strip (132), + 4 unused bytes. +- **Strips:** four 16-pixel sprites, each line two bitplane words. + +Flag bit 15 marks attached sprites. The strips pair up into two 16-colour +columns, so a frame is 32 × 33 pixels. The bank carries no colours. + +## Music (`sfx/tune.crn`) + +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, and the colours of + `ball.spr` (probably set by the executable). The engine's own editable formats, which `skids extract` converts all of the above into, are described in [assets/README.md](../assets/README.md). - -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. diff --git a/src/app.rs b/src/app.rs index d073606..32b8bf4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, cams: [(f32, f32); 2], paused: bool, + sounds: RaceSounds, }, Results { race: Box, }, } +/// What the race screen remembers between frames to decide when to play +/// one-shot sounds. +#[derive(Default)] +struct RaceSounds { + countdown: Option, + go_played: bool, + bumps: Vec, + throttle: [f32; 2], +} + pub struct App { pub data: GameData, + /// Indices of vehicles that can be driven (not trailers). + cars: Vec, + /// The title picture at half size, if the data has one. + title: Option<(usize, usize, Vec)>, 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 = (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 { 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. @@ -179,8 +233,22 @@ impl App { fn thumb(&mut self, i: usize) -> Option<&Vec> { if !self.thumbs.contains_key(&i) { let t = self.data.load_track(i).ok()?; - self.thumbs - .insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H)); + let img = match &t.thumbnail { + // The original thumbnail: Amiga hi-res pixels, so each row is + // shown twice to keep its shape. + Some(px) => { + let pal: Vec = t.palette.iter().map(|c| c.to_u32()).collect(); + let mut out = vec![0x101010u32; THUMB_W * THUMB_H]; + for y in 0..THUMB_H { + for x in 0..track::THUMB_W.min(THUMB_W) { + out[y * THUMB_W + x] = pal[px[(y / 2) * track::THUMB_W + x] as usize]; + } + } + out + } + None => render::downscale(&t.rgb(), THUMB_W, THUMB_H), + }; + self.thumbs.insert(i, img); } self.thumbs.get(&i) } @@ -189,9 +257,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 +290,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 +327,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 +336,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 +369,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 +387,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 +479,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 +502,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 +529,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 +554,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 = (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) { + 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::() / 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 +769,37 @@ pub fn draw_world( } } - let mut order: Vec = (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; diff --git a/src/audio.rs b/src/audio.rs index b5d8feb..2e32168 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -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>, + skid: Option>, + crashes: Vec>, + beep: Option>, + go: Option>, + horn: Option>, + backfire: Option>, +} + +impl Bank { + pub fn from_sounds(sounds: &BTreeMap) -> 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, + 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, 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 { + 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, + tyres: Option, + 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, + music: Option, + song: Option>, + 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, + mixer: Arc>, #[cfg(feature = "audio")] _stream: Option, } 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>) -> 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(&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) -> Option { + pub fn open(mixer: Arc>) -> Option { 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::(&device, &config, shared), - cpal::SampleFormat::I16 => build::(&device, &config, shared), - cpal::SampleFormat::U16 => build::(&device, &config, shared), + cpal::SampleFormat::F32 => build::(&device, &config, mixer), + cpal::SampleFormat::I16 => build::(&device, &config, mixer), + cpal::SampleFormat::U16 => build::(&device, &config, mixer), _ => None, }?; stream.play().ok()?; @@ -155,33 +368,20 @@ mod backend { fn build( device: &cpal::Device, config: &cpal::StreamConfig, - shared: Arc, + mixer: Arc>, ) -> Option where T: SizedSample + FromSample, { - 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())); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs index 91e4b42..70a12b1 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -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 { 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, pub track_files: Vec, pub vehicles: Vec, + /// Sound effects by lower-case name (`motor3`, `screech2`, ...). + pub sounds: BTreeMap, + /// The tune as an OctaMED module, and the name it is stored under. + pub music: Option<(String, Vec)>, + /// Pictures by lower-case name (`skidtoon`, ...). + pub pictures: BTreeMap, pub files: FileSet, pub warnings: Vec, } @@ -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 { - 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 { + 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, }) @@ -262,8 +299,14 @@ impl GameData { let mut t = match &entry.source { TrackSource::Asset { png, txt } => { let txt = String::from_utf8_lossy(self.files.get(txt).unwrap_or_default()); - Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt) - .map_err(|e| format!("{}: {e}", entry.name))? + let mut t = + Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt) + .map_err(|e| format!("{}: {e}", entry.name))?; + let thumb = png.replace("/track.png", "/thumb.png"); + if let Some(d) = self.files.get(&thumb) { + t.set_thumb_png(d).map_err(|e| format!("{thumb}: {e}"))?; + } + t } TrackSource::Packed { file, section } => self.track_files[*file].decode(*section)?, }; @@ -274,6 +317,115 @@ impl GameData { } } +type Media = ( + BTreeMap, + Option<(String, Vec)>, + BTreeMap, +); + +/// 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) -> Media { + let stem = |key: &str, dir: &str, ext: &str| -> Option { + 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", ".spr") { + match iff::read_sprite_bank(data) { + Ok(p) => { + pictures.insert(name, p); + } + Err(e) => warnings.push(format!("{key}: {e}")), + } + } + } + for (key, data) in &files.files { + if let Some(name) = stem(key, "gfx", ".png") { + match image::read_indexed(data, &palette::TRACK_DEFAULT) { + 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(); diff --git a/src/data/palette.rs b/src/data/palette.rs index 822b881..c1e6e1f 100644 --- a/src/data/palette.rs +++ b/src/data/palette.rs @@ -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 diff --git a/src/data/track.rs b/src/data/track.rs index bbe4f05..f6733d9 100644 --- a/src/data/track.rs +++ b/src/data/track.rs @@ -3,6 +3,14 @@ //! File layout: //! 0x00 7 x BE32 end offsets. Section 0 runs from 0x1c to the first offset //! and holds the selector thumbnails; sections 1..6 are tracks. +//! Section 0 starts with the selector thumbnails: a 640x64 16-colour +//! picture, four bitplanes one after another (80 bytes per row), with +//! the six tracks' 123x32 thumbnails in a 3x2 grid at its top left. +//! Its pixels are Amiga hi-res, so each shows at 123x64 on screen. +//! The last 142 bytes of section 0 are: BE16 file number, the 16 +//! colour palette shared by all six tracks as 48 BE16 values (R, G, B +//! per colour, 0-15 each), 12 bytes of unknown settings, then the +//! 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 @@ -46,9 +54,16 @@ pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool { sx >= margin && sy >= margin && sx < W as f32 - margin && sy < H as f32 - margin } +pub const THUMB_W: usize = 123; +pub const THUMB_H: usize = 32; + pub struct TrackFile { pub name: String, pub number: u32, + pub palette: [Rgb; 16], + pub set_name: String, + /// The six selector thumbnails, THUMB_W x THUMB_H palette indices each. + pub thumbnails: Vec>, /// Packed track sections (index 0 = first playable track). pub sections: Vec>, } @@ -67,6 +82,10 @@ impl Gate { pub struct Track { pub name: String, + /// The set of six tracks this one belongs to. + pub set_name: String, + /// The original selector thumbnail, THUMB_W x THUMB_H palette indices. + pub thumbnail: Option>, /// Palette indices, W x H. pub pixels: Vec, pub palette: [Rgb; 16], @@ -104,9 +123,16 @@ impl TrackFile { .trim_start_matches(|c: char| !c.is_ascii_digit()) .parse() .unwrap_or(u32::MAX); + let header = &d[SECTIONS * 4..ends[0]]; + let thumbnails = thumbnails(header); + let (palette, set_name) = + file_header(header).unwrap_or((palette::TRACK_DEFAULT, String::new())); Ok(TrackFile { name: name.to_string(), number, + palette, + set_name, + thumbnails, sections, }) } @@ -117,10 +143,54 @@ impl TrackFile { pub fn decode(&self, section: usize) -> Result { 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(); + t.thumbnail = self.thumbnails.get(section).cloned(); + Ok(t) } } +/// The six thumbnails from the start of section 0. +fn thumbnails(s: &[u8]) -> Vec> { + const ROW: usize = 80; + const ROWS: usize = 64; + let plane = ROW * ROWS; + if s.len() < plane * 4 { + return Vec::new(); + } + let px = |x: usize, y: usize| -> u8 { + (0..4) + .map(|p| ((s[p * plane + y * ROW + x / 8] >> (7 - x % 8)) & 1) << p) + .sum() + }; + (0..6) + .map(|i| { + let (ox, oy) = ((i % 3) * THUMB_W, (i / 3) * THUMB_H); + let mut t = Vec::with_capacity(THUMB_W * THUMB_H); + for y in 0..THUMB_H { + for x in 0..THUMB_W { + t.push(px(ox + x, oy + y)); + } + } + t + }) + .collect() +} + +/// Palette and set name from the tail of section 0. +fn file_header(s: &[u8]) -> Option<([Rgb; 16], String)> { + let tail = s.get(s.len().checked_sub(142)?..)?; + 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 { if d.len() < SECTION_SIZE { @@ -168,6 +238,8 @@ impl Track { name, pixels, palette: palette::TRACK_DEFAULT, + set_name: String::new(), + thumbnail: None, height, gates, start, @@ -236,6 +308,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 +360,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 +378,8 @@ impl Track { name: name.to_string(), pixels: img.pixels, palette: pal, + set_name, + thumbnail: None, height, gates, start, @@ -311,6 +387,24 @@ impl Track { }) } + /// The thumbnail as an indexed PNG in the track's palette. + pub fn thumb_png(&self) -> Option, String>> { + self.thumbnail + .as_ref() + .map(|t| image::write_indexed(THUMB_W, THUMB_H, &self.palette, false, t)) + } + + pub fn set_thumb_png(&mut self, png: &[u8]) -> Result<(), String> { + let img = image::read_indexed(png, &self.palette)?; + if (img.w, img.h) != (THUMB_W, THUMB_H) || img.pixels.iter().any(|&p| p > 15) { + return Err(format!( + "thumb.png must be {THUMB_W}x{THUMB_H} using colours 0-15" + )); + } + self.thumbnail = Some(img.pixels); + Ok(()) + } + pub fn to_png(&self) -> Result, String> { image::write_indexed(W, H, &self.palette, false, &self.pixels) } @@ -331,10 +425,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 = row.iter().map(|v| v.to_string()).collect(); diff --git a/src/data/vehicle.rs b/src/data/vehicle.rs index 8ecd532..4054161 100644 --- a/src/data/vehicle.rs +++ b/src/data/vehicle.rs @@ -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, + /// 64x32 menu picture (indices 0-3), from the `.set` file. + pub menu_picture: Option>, pub spec: Spec, /// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H. pub frames: Vec, @@ -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, 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 { 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::>() + .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()); } diff --git a/src/formats/iff.rs b/src/formats/iff.rs new file mode 100644 index 0000000..46e93c9 --- /dev/null +++ b/src/formats/iff.rs @@ -0,0 +1,381 @@ +//! 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, + /// Loop start in samples, if the sound repeats. + pub loop_start: Option, + pub volume: f32, +} + +#[derive(Clone, Debug)] +pub struct Picture { + pub w: usize, + pub h: usize, + /// Palette indices, w * h. + pub pixels: Vec, + /// 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 { + 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 { + 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, + }) +} + +/// Reads a bank of hardware sprites saved by the game (`gfx/ball.spr`) as +/// one picture sheet, 16 frames across. +/// +/// Each frame is a 16-byte header (BE32 saved pointer, BE16 lines, BE16 +/// strip count, BE16 flags, BE16 bytes per strip, 4 unused) followed by the +/// strips: 16-pixel sprites with two bitplane words per line. With flag bit +/// 15 set, strips pair up as attached sprites (16 colours, 16 pixels per +/// pair); otherwise each strip is a 4-colour, 16-pixel column. The bank +/// carries no colours, so the sheet gets a grey ramp. +pub fn read_sprite_bank(d: &[u8]) -> Result { + if d.len() < 16 { + return Err("too short for a sprite bank".into()); + } + let lines = be16(d, 4) as usize; + let strips = be16(d, 6) as usize; + let attached = be16(d, 8) & 0x8000 != 0 && strips.is_multiple_of(2); + let per_strip = be16(d, 10) as usize; + if lines == 0 || strips == 0 || per_strip != lines * 4 { + return Err("not a sprite bank".into()); + } + let record = 16 + strips * per_strip; + let frames = d.len() / record; + let fw = if attached { + strips / 2 * 16 + } else { + strips * 16 + }; + let (cols, rows) = (frames.min(16), frames.div_ceil(16)); + let (w, h) = (cols * fw, rows * lines); + let mut pixels = vec![0u8; w * h]; + for f in 0..frames { + let r = &d[f * record..(f + 1) * record]; + if be16(r, 4) as usize != lines || be16(r, 6) as usize != strips { + return Err(format!("frame {f} has a different size")); + } + let strip_px = |s: usize, x: usize, y: usize| -> u8 { + let o = 16 + s * per_strip + y * 4; + let bit = 0x8000 >> x; + ((be16(r, o) & bit != 0) as u8) | (((be16(r, o + 2) & bit != 0) as u8) << 1) + }; + let (ox, oy) = ((f % 16) * fw, (f / 16) * lines); + for y in 0..lines { + for x in 0..fw { + let v = if attached { + let pair = x / 16; + strip_px(pair * 2, x % 16, y) | (strip_px(pair * 2 + 1, x % 16, y) << 2) + } else { + strip_px(x / 16, x % 16, y) + }; + pixels[(oy + y) * w + ox + x] = v; + } + } + } + let n = if attached { 16 } else { 4 }; + let palette = (0..n) + .map(|i| { + let v = (i * 255 / (n - 1)) as u8; + (v, v, v) + }) + .collect(); + Ok(Picture { + w, + h, + pixels, + palette, + }) +} + +/// Writes 8-bit unsigned mono WAV, with the loop start in a `smpl` chunk so +/// sample editors keep it. +pub fn sound_to_wav(s: &Sound) -> Vec { + let data: Vec = s.data.iter().map(|&v| (v as i16 + 128) as u8).collect(); + let mut chunks: Vec<(&[u8; 4], Vec)> = 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 { + 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 = 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 attached_sprite_bank() { + // One frame: 1 line, 2 attached strips; pixel 0 uses all four planes. + let mut d = vec![0u8; 16 + 2 * 4]; + d[5] = 1; + d[7] = 2; + d[8] = 0x80; + d[11] = 4; + for o in [16, 18, 20, 22] { + d[o] = 0x80; + } + let p = read_sprite_bank(&d).unwrap(); + assert_eq!((p.w, p.h), (16, 1)); + assert_eq!(p.pixels[0], 15); + assert_eq!(p.pixels[1], 0); + } + + #[test] + fn byterun1_picture() { + // 16x1, 1 plane: run of 2 x 0xff. + 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]); + } +} diff --git a/src/formats/lha.rs b/src/formats/lha.rs index 44b2ed2..016ace7 100644 --- a/src/formats/lha.rs +++ b/src/formats/lha.rs @@ -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>, + /// File contents, or why they could not be read. + pub data: Result, String>, } pub fn parse(d: &[u8]) -> Result, LhaError> { @@ -45,7 +47,9 @@ pub fn parse(d: &[u8]) -> Result, 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, 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, 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, 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!"[..])); } } diff --git a/src/formats/lzh.rs b/src/formats/lzh.rs new file mode 100644 index 0000000..32e3b3c --- /dev/null +++ b/src/formats/lzh.rs @@ -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, + count: [u16; 17], + symbols: Vec, +} + +impl Table { + fn single(sym: u16) -> Table { + Table { + single: Some(sym), + count: [0; 17], + symbols: Vec::new(), + } + } + + fn from_lengths(lengths: &[u8]) -> Result { + 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 { + 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, +) -> Result { + 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 { + 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, 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)); + } +} diff --git a/src/formats/med.rs b/src/formats/med.rs new file mode 100644 index 0000000..653c165 --- /dev/null +++ b/src/formats/med.rs @@ -0,0 +1,174 @@ +//! OctaMED module reader (MMD0 and MMD1, 8-bit sample instruments). +//! +//! Layout notes (all offsets big-endian, pointers relative to file start): +//! 0 "MMD0"/"MMD1", 8 song struct, 16 block table, 24 sample table. +//! Song struct: 63 x 8-byte sample entries (repeat, repeat length in words, +//! MIDI channel, preset, volume, transpose), then block count, sequence +//! length, 256-byte play sequence, default tempo, transpose, flags, flags2, +//! secondary tempo (ticks per line), 16 track volumes, master volume, +//! sample count. MMD0 blocks have a 2-byte header and 3-byte notes; MMD1 +//! blocks have an 8-byte header and 4-byte notes. + +use super::{be16, be32}; + +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct Note { + /// 0 = none, 1 = C-1 ... + pub note: u8, + /// 0 = none, else instrument number (1-based). + pub instr: u8, + pub cmd: u8, + pub param: u8, +} + +#[derive(Clone, Debug, Default)] +pub struct Instrument { + pub data: Vec, + pub loop_start: usize, + pub loop_len: usize, + pub volume: u8, + pub transpose: i8, +} + +#[derive(Clone, Debug)] +pub struct Block { + pub tracks: usize, + pub lines: usize, + pub notes: Vec, +} + +#[derive(Clone, Debug)] +pub struct Song { + pub instruments: Vec, + pub blocks: Vec, + pub sequence: Vec, + /// Lines per minute is derived from these by the player. + pub tempo: u16, + pub speed: u8, + pub bpm_mode: bool, + pub lines_per_beat: u8, + pub volume_hex: bool, + pub transpose: i8, + pub track_volume: [u8; 16], + pub master_volume: u8, +} + +pub fn is_med(d: &[u8]) -> bool { + d.len() > 52 && (&d[0..4] == b"MMD0" || &d[0..4] == b"MMD1") +} + +pub fn parse(d: &[u8]) -> Result { + if !is_med(d) { + return Err("not an OctaMED MMD0/MMD1 module".into()); + } + let mmd1 = d[3] == b'1'; + let get = |o: usize, n: usize| { + d.get(o..o + n) + .ok_or_else(|| format!("offset {o} is outside the module")) + }; + let song = be32(d, 8) as usize; + let blockarr = be32(d, 16) as usize; + let smplarr = be32(d, 24) as usize; + let s = get(song, 788)?; + let nblocks = be16(s, 504) as usize; + let songlen = (be16(s, 506) as usize).min(256); + let sequence: Vec = s[508..508 + songlen].iter().map(|&b| b as usize).collect(); + let tempo = be16(s, 764); + let transpose = s[766] as i8; + let flags = s[767]; + let flags2 = s[768]; + let speed = s[769].max(1); + let mut track_volume = [64u8; 16]; + track_volume.copy_from_slice(&s[770..786]); + let master_volume = s[786]; + let nsamples = (s[787] as usize).min(63); + + let mut instruments = Vec::new(); + for i in 0..nsamples { + let e = &s[i * 8..i * 8 + 8]; + let (rep, replen) = (be16(e, 0) as usize * 2, be16(e, 2) as usize * 2); + let mut inst = Instrument { + volume: e[6].min(64), + transpose: e[7] as i8, + ..Default::default() + }; + let p = if smplarr != 0 { + be32(get(smplarr + i * 4, 4)?, 0) as usize + } else { + 0 + }; + if p != 0 { + let h = get(p, 6)?; + let len = be32(h, 0) as usize; + let kind = be16(h, 4) as i16; + if kind != 0 { + return Err(format!("instrument {} is a synth or multi-octave sample (type {kind}), which is not supported", i + 1)); + } + inst.data = get(p + 6, len)?.iter().map(|&b| b as i8).collect(); + if replen > 2 && rep < len { + inst.loop_start = rep; + inst.loop_len = replen.min(len - rep); + } + } + instruments.push(inst); + } + + let mut blocks = Vec::new(); + for b in 0..nblocks { + let bp = be32(get(blockarr + b * 4, 4)?, 0) as usize; + let (tracks, lines, header, width) = if mmd1 { + let h = get(bp, 8)?; + (be16(h, 0) as usize, be16(h, 2) as usize + 1, 8, 4) + } else { + let h = get(bp, 2)?; + (h[0] as usize, h[1] as usize + 1, 2, 3) + }; + if tracks == 0 || tracks > 64 { + return Err(format!("block {b} has {tracks} tracks")); + } + let body = get(bp + header, tracks * lines * width)?; + let notes = body + .chunks_exact(width) + .map(|c| { + if mmd1 { + Note { + note: c[0] & 0x7f, + instr: c[1] & 0x3f, + cmd: c[2], + param: c[3], + } + } else { + // MMD0: nnnnnnn i | iiii cccc | pppppppp + let instr = ((c[0] & 0x80) >> 3) | (c[1] >> 4); + Note { + note: c[0] & 0x3f, + instr, + cmd: c[1] & 0x0f, + param: c[2], + } + } + }) + .collect(); + blocks.push(Block { + tracks, + lines, + notes, + }); + } + if sequence.iter().any(|&b| b >= blocks.len()) || sequence.is_empty() { + return Err("play sequence refers to a missing block".into()); + } + Ok(Song { + instruments, + blocks, + sequence, + tempo, + speed, + bpm_mode: flags2 & 0x20 != 0, + lines_per_beat: (flags2 & 0x1f) + 1, + volume_hex: flags & 0x10 != 0, + transpose, + track_volume, + master_volume, + }) +} diff --git a/src/formats/mod.rs b/src/formats/mod.rs index 71351a0..0fd2705 100644 --- a/src/formats/mod.rs +++ b/src/formats/mod.rs @@ -6,7 +6,10 @@ pub mod adf; pub mod crm; +pub mod iff; pub mod lha; +pub mod lzh; +pub mod med; pub(crate) fn be16(d: &[u8], off: usize) -> u16 { u16::from_be_bytes([d[off], d[off + 1]]) diff --git a/src/game/ai.rs b/src/game/ai.rs index b76fcbe..b1449cd 100644 --- a/src/game/ai.rs +++ b/src/game/ai.rs @@ -17,8 +17,6 @@ pub struct Ai { pub line: f32, /// Fraction of the car's top speed this driver uses. pub pace: f32, - /// Index of the path segment the car is on (gate i-1 to gate i). - seg: Option, stuck_for: f32, blocked_for: f32, reversing: f32, @@ -29,7 +27,6 @@ impl Ai { Ai { line, pace, - seg: None, stuck_for: 0.0, blocked_for: 0.0, reversing: 0.0, @@ -46,16 +43,12 @@ impl Ai { /// Point `dist` along the driving line from the car's projection onto it. fn pursue(&mut self, car: &Car, gates: &[Gate], next_gate: usize, dist: f32) -> (f32, f32) { let n = gates.len(); - // Search a few segments ahead of the last known one; never jump back, - // so figure-of-eight crossings don't confuse it. Resync to the lap - // counter if we drift too far from it. - let mut seg = self.seg.unwrap_or(next_gate); - let ahead = (next_gate + n - seg) % n; - if ahead > 3 && ahead < n - 1 { - seg = next_gate; - } + // Only the stretch into the gate the lap counter needs next, or the + // one after it (crossing counts with one gate skipped), so a car + // knocked backwards turns round for the gate it missed. + let seg = next_gate; let mut best = (f32::MAX, seg, 0.0f32); - for k in 0..4 { + for k in 0..2 { let i = (seg + k) % n; let a = self.point(&gates[(i + n - 1) % n]); let b = self.point(&gates[i]); @@ -68,7 +61,6 @@ impl Ai { } } let (_, mut i, t) = best; - self.seg = Some(i); let mut a = self.point(&gates[(i + n - 1) % n]); let mut b = self.point(&gates[i]); diff --git a/src/game/mod.rs b/src/game/mod.rs index 1710b76..8675629 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -32,6 +32,59 @@ pub struct Entrant { pub best_lap: Option, pub last_lap: Option, pub finished: Option, + pub trailer: Option, + /// Race time when this entrant last crossed a gate. + pub progress_at: f32, +} + +/// A caravan on a tow bar behind a car. +#[derive(Clone, Copy, Debug)] +pub struct Trailer { + pub vehicle: usize, + pub x: f32, + pub y: f32, + pub heading: f32, +} + +/// Hitch distance behind the car's centre, and tow bar length to the +/// trailer's centre, in world units. +const HITCH: f32 = 12.0; +const TOW_BAR: f32 = 20.0; +const TRAILER_RADIUS: f32 = 10.0; +/// Seconds without crossing a gate before a computer car is rescued. +const RESCUE_AFTER: f32 = 8.0; + +fn rescue(e: &mut Entrant, gates: &[Gate]) { + let n = gates.len(); + let from = gates[(e.next_gate + n - 1) % n].mid(); + let to = gates[e.next_gate].mid(); + let heading = (to.1 - from.1).atan2(to.0 - from.0); + let c = &mut e.car; + c.x = from.0; + c.y = from.1; + c.vx = 0.0; + c.vy = 0.0; + c.heading = heading.rem_euclid(std::f32::consts::TAU); + if let Some(t) = &mut e.trailer { + let back = HITCH + TOW_BAR; + t.x = from.0 - heading.cos() * back; + t.y = from.1 - heading.sin() * back; + t.heading = heading; + } +} + +impl Trailer { + /// Drags the trailer after its car: it keeps its distance from the + /// hitch and swings to point at it. + fn follow(&mut self, car: &Car) { + let (fx, fy) = car.forward(); + let (hx, hy) = (car.x - fx * HITCH, car.y - fy * HITCH); + let (dx, dy) = (self.x - hx, self.y - hy); + let d = dx.hypot(dy).max(1e-3); + self.x = hx + dx / d * TOW_BAR; + self.y = hy + dy / d * TOW_BAR; + self.heading = (hy - self.y).atan2(hx - self.x); + } } impl Entrant { @@ -75,6 +128,8 @@ pub struct EntrantSetup { pub driver: Driver, pub team: usize, pub name: String, + /// Vehicle index of a caravan to tow, if any. + pub trailer: Option, } impl Race { @@ -97,7 +152,13 @@ impl Race { // from the line. let row = (i / 2) as f32; let side = if i % 2 == 0 { -1.0 } else { 1.0 }; - let ((px, py), heading) = behind(&path, 18.0 + row * 30.0); + // Leave room for a caravan between rows when towing. + let spacing = if s.trailer.is_some() { + 30.0 + HITCH + TOW_BAR + TRAILER_RADIUS + } else { + 30.0 + }; + let ((px, py), heading) = behind(&path, 18.0 + row * spacing); let (rx, ry) = (-heading.sin(), heading.cos()); let (x, y) = (px + rx * side * 12.0, py + ry * side * 12.0); let car = Car::new(s.vehicle, vehicles[s.vehicle].spec, s.team, x, y, heading); @@ -114,7 +175,23 @@ impl Race { best_lap: None, last_lap: None, finished: None, + progress_at: 0.0, + trailer: s.trailer.map(|t| { + let (fx, fy) = (heading.cos(), heading.sin()); + let back = HITCH + TOW_BAR; + Trailer { + vehicle: t, + x: x - fx * back, + y: y - fy * back, + heading, + } + }), }); + if let Some(e) = entrants.last_mut().filter(|e| e.trailer.is_some()) { + // Towing costs some speed and pick-up. + e.car.spec.top *= 0.9; + e.car.spec.accel *= 0.8; + } } let ground = track.rgb(); Race { @@ -153,6 +230,9 @@ impl Race { let racing = self.phase != Phase::Countdown; let rt = self.race_time(); let n = self.track.gates.len(); + // Where every car started the frame: gate crossings are judged on + // the whole frame's movement, including knocks from collisions. + let starts: Vec<(f32, f32)> = self.entrants.iter().map(|e| (e.car.x, e.car.y)).collect(); for e in self.entrants.iter_mut() { let controls = match (e.driver, e.finished) { @@ -164,15 +244,37 @@ impl Race { e.car.controls = controls; // Finished cars cruise round on autopilot at reduced pace. let pace = if e.finished.is_some() { 0.6 } else { 1.0 }; - let (px, py) = (e.car.x, e.car.y); e.car.step(&self.track, DT, pace); + } + for i in 0..self.entrants.len() { + for j in i + 1..self.entrants.len() { + let (a, b) = self.entrants.split_at_mut(j); + car::collide(&mut a[i].car, &mut b[0].car); + } + } + // Caravans follow their cars and shove other cars out of the way. + for i in 0..self.entrants.len() { + let Some(mut t) = self.entrants[i].trailer else { + continue; + }; + t.follow(&self.entrants[i].car); + for (j, other) in self.entrants.iter_mut().enumerate() { + if j != i { + push_off(&mut other.car, t.x, t.y); + } + } + self.entrants[i].trailer = Some(t); + } + + for (e, &(px, py)) in self.entrants.iter_mut().zip(&starts) { // Gate crossing, allowing one gate to be skipped. for skip in 0..2 { let gi = (e.next_gate + skip) % n; if crosses(&self.track.gates[gi], (px, py), (e.car.x, e.car.y)) { let before = e.passed; e.passed += skip + 1; + e.progress_at = rt; e.next_gate = (gi + 1) % n; if before == 0 { // First time over the line: the clock starts. @@ -188,10 +290,17 @@ impl Race { } } - for i in 0..self.entrants.len() { - for j in i + 1..self.entrants.len() { - let (a, b) = self.entrants.split_at_mut(j); - car::collide(&mut a[i].car, &mut b[0].car); + // A computer car that has made no progress for a while is put back + // on the racing line at the last gate it crossed. + if self.phase == Phase::Racing { + for e in self.entrants.iter_mut() { + if e.driver == Driver::Cpu + && e.finished.is_none() + && rt - e.progress_at > RESCUE_AFTER + { + rescue(e, &self.track.gates); + e.progress_at = rt; + } } } @@ -277,6 +386,26 @@ impl Car { } } +/// Moves a car out of a caravan it has run into, killing the speed it had +/// into it. +fn push_off(c: &mut Car, tx: f32, ty: f32) { + let (dx, dy) = (c.x - tx, c.y - ty); + let d = dx.hypot(dy); + let min = car::RADIUS + TRAILER_RADIUS; + if d >= min || d < 1e-3 { + return; + } + let (nx, ny) = (dx / d, dy / d); + c.x += nx * (min - d); + c.y += ny * (min - d); + let vn = c.vx * nx + c.vy * ny; + if vn < 0.0 { + c.vx -= vn * nx * 1.3; + c.vy -= vn * ny * 1.3; + c.bump = c.bump.max((-vn / 150.0).min(1.0)); + } +} + fn burn(ground: &mut [u32], sx: f32, sy: f32, strength: f32) { use crate::data::track::{H, W}; let (x, y) = (sx as isize, sy as isize); diff --git a/src/main.rs b/src/main.rs index 937af48..60e234b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,23 @@ //! The engine reads the original Amiga data (ADF images, the LHA archives on //! them, or extracted files) at run time; no game assets are bundled. +/// Like `println!`, but a closed pipe (e.g. `skids info | head`) ends the +/// program quietly instead of panicking. +macro_rules! out { + ($($arg:tt)*) => {{ + use std::io::Write; + if writeln!(std::io::stdout(), $($arg)*).is_err() { + std::process::exit(0); + } + }}; +} + mod app; mod audio; mod data; mod formats; mod game; +mod music; mod render; use app::{App, Input}; @@ -24,15 +36,19 @@ USAGE: skids [--data DIR] [--scale 1|2|4] [--mute] play skids info [--data DIR] list what was found in the data skids extract [--data DIR] [--out DIR] convert the data to editable assets (default ./assets) - skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] + skids unpack FILE.adf|FILE.lha --out DIR extract every file from a disk image or archive + skids music [--data DIR] --out FILE.wav [--seconds S] + render the tune to a WAV file + skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] [--caravans] simulate a race (or the menu) and save a frame - skids bench [--data DIR] [--seconds S] race computer cars on every track + skids bench [--data DIR] [--seconds S] [--caravans] + race computer cars on every track skids trace [--data DIR] --out FILE.png [--track N] [--seconds S] draw the computer cars' paths on a track The game data is built into the executable, so no disks are needed. --data adds to or overrides it with ADF images, LHA archives, or extracted track/, -vehicles/ and palettes/ folders (e.g. disk 1, or real palettes). Without +vehicles/ and palettes/ folders, or an assets folder of edits. Without --data, a folder like that in the current directory or next to the executable is picked up automatically. @@ -51,6 +67,7 @@ struct Args { seconds: f32, players: usize, menu: bool, + caravans: bool, } fn parse_args() -> Result { @@ -64,6 +81,7 @@ fn parse_args() -> Result { seconds: 12.0, players: 1, menu: false, + caravans: false, }; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { @@ -78,7 +96,12 @@ fn parse_args() -> Result { "--players" => a.players = val("--players")?.parse().map_err(|_| "bad --players")?, "--mute" => a.mute = true, "--menu" => a.menu = true, - "info" | "extract" | "render" | "bench" | "trace" | "play" => a.cmd = arg, + "--caravans" => a.caravans = true, + "info" | "extract" | "render" | "bench" | "trace" | "music" | "play" => a.cmd = arg, + "unpack" => { + a.cmd = arg; + a.data = Some(val("unpack")?.into()); + } other => return Err(format!("unknown argument {other}")), } } @@ -86,7 +109,7 @@ fn parse_args() -> Result { } /// An explicit --data directory, or a nearby folder that looks like it holds -/// game data (e.g. disk 1 dropped next to the executable). The built-in data +/// game data (e.g. edited assets next to the executable). The built-in data /// is always loaded first, so finding nothing is fine. fn find_data(explicit: Option) -> Option { if explicit.is_some() { @@ -111,6 +134,21 @@ fn find_data(explicit: Option) -> Option { candidates.into_iter().find(|d| looks_right(d)) } +/// Sound output using the game's samples and tune. +fn make_audio(data: &GameData, enabled: bool) -> audio::Audio { + let song = data + .music + .as_ref() + .and_then(|(name, d)| match formats::med::parse(d) { + Ok(s) => Some(std::sync::Arc::new(s)), + Err(e) => { + eprintln!("warning: music {name}: {e}"); + None + } + }); + audio::Audio::new(enabled, audio::Bank::from_sounds(&data.sounds), song) +} + fn main() -> ExitCode { let args = match parse_args() { Ok(a) => a, @@ -126,8 +164,22 @@ fn main() -> ExitCode { }; } }; + if args.cmd == "unpack" { + return match unpack(args.data.as_deref().unwrap(), args.out.as_deref()) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + }; + } let root = find_data(args.data.clone()); - let data = match GameData::load(root.as_deref()) { + // Converting disks must read only the disks, not the built-in assets. + let data = match if args.cmd == "extract" { + GameData::load_from(root.as_deref(), false) + } else { + GameData::load(root.as_deref()) + } { Ok(d) => d, Err(e) => { eprintln!("{e}"); @@ -146,6 +198,7 @@ fn main() -> ExitCode { "render" => render_shot(data, &args), "bench" => bench(data, &args), "trace" => trace(data, &args), + "music" => render_music(&data, &args), _ => play(data, &args), }; match result { @@ -160,27 +213,75 @@ fn main() -> ExitCode { fn info(data: &GameData, dir: Option<&Path>) { let built_in = data::embedded::files().len(); match (built_in, dir) { - (0, Some(d)) => println!("Data: {}", d.display()), - (n, Some(d)) => println!("Data: built in ({n} files), plus {}", d.display()), - (n, None) => println!("Data: built in ({n} files)"), + (0, Some(d)) => out!("Data: {}", d.display()), + (n, Some(d)) => out!("Data: built in ({n} files), plus {}", d.display()), + (n, None) => out!("Data: built in ({n} files)"), } - println!("Tracks:"); + out!("Tracks:"); for (i, entry) in data.tracks.iter().enumerate() { let from = match entry.source { data::TrackSource::Asset { .. } => "asset", data::TrackSource::Packed { .. } => "original data", }; match data.load_track(i) { - Ok(t) => println!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()), - Err(e) => println!(" {:<10} ERROR {e}", entry.name), + Ok(t) => out!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()), + Err(e) => out!(" {:<10} ERROR {e}", entry.name), } } - println!("Vehicles:"); + out!("Vehicles:"); for v in &data.vehicles { - println!(" {:<10} ({})", v.name, v.id); + out!(" {:<10} ({})", v.name, v.id); } } +/// Extracts every file from an ADF image or LHA archive, unpacking LHA +/// archives found on disks. Files keep their stored (packed) form. +fn unpack(input: &Path, out: Option<&Path>) -> Result<(), String> { + use formats::{adf::Adf, lha}; + let out = out.ok_or("unpack needs --out DIR")?; + let data = std::fs::read(input).map_err(|e| format!("{}: {e}", input.display()))?; + let mut files: Vec<(String, Vec)> = Vec::new(); + if input + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("adf")) + { + let adf = Adf::open(data).map_err(|e| e.to_string())?; + for f in adf.files().map_err(|e| e.to_string())? { + files.push((f.path, f.data)); + } + } else { + files.push((input.file_name().unwrap().to_string_lossy().into(), data)); + } + let mut failed = 0; + while let Some((path, data)) = files.pop() { + if path.to_lowercase().ends_with(".lha") { + for e in lha::parse(&data).map_err(|e| format!("{path}: {e}"))? { + match e.data { + Ok(d) => files.push((e.path, d)), + Err(why) => { + eprintln!("FAILED {} ({}): {why}", e.path, e.method); + failed += 1; + } + } + } + continue; + } + // Keep paths inside `out`. + let safe: PathBuf = path + .split(['/', '\\']) + .filter(|c| !c.is_empty() && *c != "..") + .collect(); + let dest = out.join(safe); + std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?; + std::fs::write(&dest, &data).map_err(|e| format!("{}: {e}", dest.display()))?; + out!("{:>8} {}", data.len(), path); + } + if failed > 0 { + return Err(format!("{failed} files could not be extracted")); + } + Ok(()) +} + /// Writes every track and vehicle as editable assets, then reads each one /// back to check the conversion is lossless. fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> { @@ -209,7 +310,10 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> { } write(dir.join("track.png"), &png)?; write(dir.join("track.txt"), txt.as_bytes())?; - println!("wrote {}", dir.display()); + if let Some(thumb) = t.thumb_png() { + write(dir.join("thumb.png"), &thumb?)?; + } + out!("wrote {}", dir.display()); } for v in &data.vehicles { let (png, txt) = (v.sheet_png()?, v.settings_text()); @@ -220,7 +324,40 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> { let dir = out.join("vehicles"); write(dir.join(format!("{}.png", v.id)), &png)?; write(dir.join(format!("{}.txt", v.id)), txt.as_bytes())?; - println!("wrote {}", dir.join(&v.id).display()); + if let Some(menu) = v.menu_png() { + write(dir.join(format!("{}-menu.png", v.id)), &menu?)?; + } + out!("wrote {}", dir.join(&v.id).display()); + } + for (name, s) in &data.sounds { + let wav = formats::iff::sound_to_wav(s); + let back = formats::iff::wav_to_sound(&wav)?; + if back.data != s.data || back.rate != s.rate || back.loop_start != s.loop_start { + return Err(format!("sound {name}: WAV does not round-trip")); + } + let path = out.join("sounds").join(format!("{name}.wav")); + write(path.clone(), &wav)?; + out!("wrote {}", path.display()); + } + if let Some((name, med)) = &data.music { + formats::med::parse(med).map_err(|e| format!("music {name}: {e}"))?; + let path = out.join("music").join(format!("{name}.med")); + write(path.clone(), med)?; + out!("wrote {}", path.display()); + } + for (name, p) in &data.pictures { + let pal: Vec = p + .palette + .iter() + .map(|c| data::palette::Rgb(c.0, c.1, c.2)) + .collect(); + let png = data::image::write_indexed(p.w, p.h, &pal, false, &p.pixels)?; + if data::image::read_indexed(&png, &pal)?.pixels != p.pixels { + return Err(format!("picture {name}: PNG does not round-trip")); + } + let path = out.join("gfx").join(format!("{name}.png")); + write(path.clone(), &png)?; + out!("wrote {}", path.display()); } Ok(()) } @@ -228,7 +365,11 @@ fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> { /// Runs a race with computer drivers in the human seats and saves the screen. fn render_shot(data: GameData, args: &Args) -> Result<(), String> { let out = args.out.clone().ok_or("render needs --out FILE.png")?; - let mut app = App::new(data, audio::Audio::new(false)); + let mut app = { + let audio = make_audio(&data, false); + App::new(data, audio) + }; + app.settings.caravans = args.caravans; if args.menu { for _ in 0..(args.seconds / game::DT) as usize { app.update(&Input::default()); @@ -267,7 +408,7 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> { let n = race.track.gates.len(); for &i in &race.standings() { let e = &race.entrants[i]; - println!( + out!( "{:<10} laps {} gates {} best {:?}", e.name, e.laps_done(n), @@ -275,18 +416,26 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> { e.best_lap ); } - println!("wrote {}", out.display()); + out!("wrote {}", out.display()); Ok(()) } /// Races four computer cars on every track and reports how they got on: /// a quick health check for the track decoding and the AI. fn bench(data: GameData, args: &Args) -> Result<(), String> { - let app = App::new(data, audio::Audio::new(false)); + let mut app = { + let audio = make_audio(&data, false); + App::new(data, audio) + }; + app.settings.caravans = args.caravans; let frames = (args.seconds / game::DT) as usize; - println!( + out!( "{:<10} {:>5} {:>8} {:>9} {:>8}", - "TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD" + "TRACK", + "GATES", + "LAPS", + "BEST LAP", + "ON ROAD" ); for ti in 0..app.data.tracks.len() { let mut race = app.build_race(ti, 0, [0, 0], 4, 99, ti as u32 + 1)?; @@ -314,7 +463,7 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> { } else { app::fmt_time(best) }; - println!( + out!( "{:<10} {:>5} {:>8} {:>9} {:>7.0}%", race.track.name, n, @@ -326,11 +475,77 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> { Ok(()) } +fn render_music(data: &GameData, args: &Args) -> Result<(), String> { + let out = args.out.clone().ok_or("music needs --out FILE.wav")?; + let (name, d) = data.music.as_ref().ok_or("no music in the data")?; + let song = std::sync::Arc::new(formats::med::parse(d).map_err(|e| format!("{name}: {e}"))?); + let rate = 44100u32; + let mut player = music::Player::new(song, rate as f64); + let frames = (args.seconds * rate as f32) as usize; + let mut pcm = Vec::with_capacity(frames * 4); + let mut peak = 0.0f32; + let mut wrapped_at = None; + let mut last_order = 0; + for i in 0..frames { + let (l, r) = player.next(); + let order = player.order(); + if order < last_order && wrapped_at.is_none() { + wrapped_at = Some(i as f32 / rate as f32); + } + last_order = order; + peak = peak.max(l.abs()).max(r.abs()); + for v in [l, r] { + pcm.extend_from_slice(&((v.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); + } + } + let mut wav = b"RIFF\0\0\0\0WAVEfmt ".to_vec(); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&rate.to_le_bytes()); + wav.extend_from_slice(&(rate * 4).to_le_bytes()); + wav.extend_from_slice(&4u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&(pcm.len() as u32).to_le_bytes()); + wav.extend(pcm); + let n = (wav.len() - 8) as u32; + wav[4..8].copy_from_slice(&n.to_le_bytes()); + std::fs::write(&out, wav).map_err(|e| e.to_string())?; + out!( + "wrote {} ({:.0} s, peak {:.2}, position {})", + out.display(), + args.seconds, + peak, + player.position() + ); + if let Some(t) = wrapped_at { + out!( + "the song loops back to the start after {}:{:04.1}", + (t / 60.0) as u32, + t % 60.0 + ); + } + Ok(()) +} + fn trace(data: GameData, args: &Args) -> Result<(), String> { use data::track; let out = args.out.clone().ok_or("trace needs --out FILE.png")?; - let app = App::new(data, audio::Audio::new(false)); - let mut race = app.build_race(args.track.saturating_sub(1), 0, [0, 0], 4, 99, 1)?; + let mut app = { + let audio = make_audio(&data, false); + App::new(data, audio) + }; + app.settings.caravans = args.caravans; + // Same seed as `bench` uses for this track, so problems seen there can be traced. + let mut race = app.build_race( + args.track.saturating_sub(1), + 0, + [0, 0], + 4, + 99, + args.track.max(1) as u32, + )?; let mut img = race.track.rgb(); let plot = |img: &mut Vec, wx: f32, wy: f32, c: u32| { let (x, y) = track::to_screen(wx, wy); @@ -359,7 +574,7 @@ fn trace(data: GameData, args: &Args) -> Result<(), String> { } let n = race.track.gates.len(); for e in &race.entrants { - println!( + out!( "{:<8} laps {} next gate {}", e.name, e.laps_done(n), @@ -385,7 +600,10 @@ fn play(data: GameData, args: &Args) -> Result<(), String> { .map_err(|e| e.to_string())?; // PAL Amigas ran at 50 frames per second; the simulation is tied to it. window.set_target_fps(50); - let mut app = App::new(data, audio::Audio::new(!args.mute)); + let mut app = { + let audio = make_audio(&data, !args.mute); + App::new(data, audio) + }; while window.is_open() && !app.quit { let input = Input { held: window.get_keys(), diff --git a/src/music.rs b/src/music.rs new file mode 100644 index 0000000..c1337aa --- /dev/null +++ b/src/music.rs @@ -0,0 +1,283 @@ +//! OctaMED song playback, mixed in software. +//! +//! Implements what the game's tune uses: sample instruments with loops, +//! slides (1, 2), portamento (3), speed (9), position jump (B), volume (C), +//! volume slide (D) and the F group (break, tempo, retrigger, note delay, +//! set pitch, stop, note off). Other commands are ignored. + +use crate::formats::med::{Note, Song}; +use std::sync::Arc; + +const PAULA_CLOCK: f64 = 3_546_895.0; +/// Period of note 1 (C-1), as on ProTracker. +const C1_PERIOD: f64 = 856.0; + +#[derive(Clone, Default)] +struct Channel { + instr: Option, + pos: f64, + period: f64, + target: f64, + porta_speed: f64, + volume: f64, + playing: bool, + /// Note to start later this line (F2 delay) and retrigger interval (F1/F3). + delayed: Option<(Note, u32)>, + retrigger: Option, +} + +pub struct Player { + song: Arc, + rate: f64, + order: usize, + line: usize, + tick: u32, + speed: u32, + tempo: u16, + tick_left: f64, + jump: Option<(usize, usize)>, + channels: Vec, +} + +fn period_for(note: i32) -> f64 { + C1_PERIOD * 2f64.powf(-(note - 1) as f64 / 12.0) +} + +impl Player { + pub fn new(song: Arc, rate: f64) -> Player { + let tracks = song.blocks.iter().map(|b| b.tracks).max().unwrap_or(4); + let speed = song.speed as u32; + let tempo = song.tempo; + Player { + song, + rate, + order: 0, + line: 0, + tick: 0, + speed, + tempo, + tick_left: 0.0, + jump: None, + channels: vec![Channel::default(); tracks], + } + } + + fn ticks_per_second(&self) -> f64 { + let s = &self.song; + let bpm = if s.bpm_mode { + self.tempo as f64 * s.lines_per_beat as f64 / 4.0 + } else if self.tempo <= 10 { + // SoundTracker-compatible tempo: a plain 50 Hz tick. + 125.0 + } else { + self.tempo as f64 * 125.0 / 33.0 + }; + (bpm * 2.0 / 5.0).max(1.0) + } + + fn trigger(&mut self, ch: usize, n: Note) { + let song = self.song.clone(); + let c = &mut self.channels[ch]; + if n.instr > 0 { + let i = n.instr as usize - 1; + if i < song.instruments.len() { + c.instr = Some(i); + c.volume = song.instruments[i].volume as f64; + } + } + if n.note > 0 { + let transpose = + c.instr.map_or(0, |i| song.instruments[i].transpose as i32) + song.transpose as i32; + let p = period_for(n.note as i32 + transpose); + if n.cmd == 3 && c.playing { + c.target = p; + if n.param > 0 { + c.porta_speed = n.param as f64; + } + } else { + c.period = p; + c.target = p; + c.pos = 0.0; + c.playing = c.instr.is_some(); + } + } + } + + fn start_line(&mut self) { + let song = self.song.clone(); + let block = &song.blocks[song.sequence[self.order]]; + for ch in 0..block.tracks.min(self.channels.len()) { + let n = block.notes[self.line * block.tracks + ch]; + let c = &mut self.channels[ch]; + c.retrigger = None; + c.delayed = None; + match (n.cmd, n.param) { + (0x0f, 0xf1) => c.retrigger = Some((self.speed / 2).max(1)), + (0x0f, 0xf3) => c.retrigger = Some((self.speed / 3).max(1)), + (0x0f, 0xf2) => { + c.delayed = Some((n, (self.speed / 2).max(1))); + continue; + } + _ => {} + } + if n.cmd == 0x0f && n.param == 0xfd && n.note > 0 { + // Set pitch without restarting the sample. + let transpose = c.instr.map_or(0, |i| song.instruments[i].transpose as i32) + + song.transpose as i32; + c.period = period_for(n.note as i32 + transpose); + c.target = c.period; + } else { + self.trigger(ch, n); + } + let c = &mut self.channels[ch]; + match n.cmd { + 0x09 if n.param > 0 => self.speed = (n.param as u32).min(32), + 0x0b => self.jump = Some((n.param as usize, 0)), + 0x0c => { + let v = if self.song.volume_hex { + n.param as u32 + } else { + (n.param >> 4) as u32 * 10 + (n.param & 15) as u32 + }; + c.volume = v.min(64) as f64; + } + 0x0f => match n.param { + 0x00 => self.jump = Some((self.order + 1, 0)), + 0x01..=0xf0 => self.tempo = n.param as u16, + 0xfe => self.jump = Some((0, 0)), + 0xff => c.playing = false, + _ => {} + }, + _ => {} + } + } + } + + fn effects_tick(&mut self) { + let song = self.song.clone(); + let block = &song.blocks[song.sequence[self.order]]; + for ch in 0..block.tracks.min(self.channels.len()) { + let n = block.notes[self.line * block.tracks + ch]; + if let Some((note, at)) = self.channels[ch].delayed { + if self.tick == at { + self.trigger(ch, note); + self.channels[ch].delayed = None; + } + } + let c = &mut self.channels[ch]; + if let Some(every) = c.retrigger { + if self.tick.is_multiple_of(every) { + c.pos = 0.0; + } + } + match n.cmd { + 0x01 => c.period = (c.period - n.param as f64).max(57.0), + 0x02 => c.period = (c.period + n.param as f64).min(3424.0), + 0x03 => { + let s = c.porta_speed; + c.period = if c.period > c.target { + (c.period - s).max(c.target) + } else { + (c.period + s).min(c.target) + }; + } + 0x0d => { + let (up, down) = ((n.param >> 4) as f64, (n.param & 15) as f64); + c.volume = if up > 0.0 { + (c.volume + up).min(64.0) + } else { + (c.volume - down).max(0.0) + }; + } + _ => {} + } + } + } + + fn advance_line(&mut self) { + let len = self.song.sequence.len(); + if let Some((order, line)) = self.jump.take() { + self.order = if order < len { order } else { 0 }; + self.line = line; + return; + } + self.line += 1; + if self.line >= self.song.blocks[self.song.sequence[self.order]].lines { + self.line = 0; + self.order = (self.order + 1) % len; + } + } + + fn tick(&mut self) { + if self.tick == 0 { + self.start_line(); + } else { + self.effects_tick(); + } + self.tick += 1; + if self.tick >= self.speed { + self.tick = 0; + self.advance_line(); + } + } + + pub fn order(&self) -> usize { + self.order + } + + /// Current (sequence position, line), for diagnostics. + pub fn position(&self) -> String { + format!("{}:{}", self.order, self.line) + } + + /// Mixes one stereo frame. + pub fn next(&mut self) -> (f32, f32) { + if self.tick_left <= 0.0 { + self.tick(); + self.tick_left += self.rate / self.ticks_per_second(); + } + self.tick_left -= 1.0; + + let song = &self.song; + let master = song.master_volume.min(64) as f64 / 64.0; + let (mut l, mut r) = (0.0f64, 0.0f64); + for (ch, c) in self.channels.iter_mut().enumerate() { + if !c.playing { + continue; + } + let Some(inst) = c.instr.map(|i| &song.instruments[i]) else { + continue; + }; + let len = inst.data.len(); + if len == 0 { + c.playing = false; + continue; + } + let i = c.pos as usize; + if i >= len { + c.playing = false; + continue; + } + let v = inst.data[i] as f64 / 128.0 + * (c.volume / 64.0) + * (song.track_volume[ch % 16].min(64) as f64 / 64.0); + // Amiga hardware panning: channels 0 and 3 left, 1 and 2 right. + if matches!(ch % 4, 0 | 3) { + l += v; + } else { + r += v; + } + c.pos += PAULA_CLOCK / c.period / self.rate; + if inst.loop_len > 2 { + let end = (inst.loop_start + inst.loop_len) as f64; + while c.pos >= end { + c.pos -= inst.loop_len as f64; + } + } + } + // Blend a little of each side into the other, as hard Amiga stereo + // is tiring on headphones. + let (l, r) = (l * 0.75 + r * 0.25, r * 0.75 + l * 0.25); + ((l * master * 0.5) as f32, (r * master * 0.5) as f32) + } +}