From e439ee9afae840efe9fe45d2317af22b06384a78 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Sat, 24 Oct 2026 15:01:24 +0200 Subject: [PATCH 1/2] feat: mods give movies in WebM or Matroska, of VP9 video with Opus or Vorbis sound Mod makers work in modern formats, and a sharper movie made with an upscaler is easiest to ship as WebM. For a movie name.bik, a mod's name.webm comes first, then name.mkv, then the Bink movie, as music takes the first of its formats. OpenReliant reads the Matroska container itself and decodes VP9 with FFmpeg's own decoder, so nothing new is linked. A modern movie is drawn at the size of the Bink movie it replaces, read from that movie's header, and keeps the rates the rooms and the transitions force, so it fits wherever the game's movie did. Its colours follow what the video says, BT.709 or full range, and BT.601 otherwise. The mods log names the movie it replaces. Closes #1179 --- README.md | 2 +- deps/ffmpeg/build.zig | 55 +- docs/README.md | 1 + docs/engine/movies.md | 23 +- docs/formats/matroska.md | 98 ++++ docs/guide/modding.md | 50 +- docs/guide/openreliant.d.luau | 2 +- docs/guide/reference.md | 4 +- docs/guide/scripting.md | 3 +- docs/guide/what-mods-can-do.md | 3 +- docs/port/platform.md | 6 +- src/engine/bink.zig | 250 +++++++-- src/engine/bink/picture.zig | 117 ++++- src/engine/files.zig | 11 + src/engine/game/bigfile/mods.zig | 60 ++- src/engine/game/interface/disc.zig | 17 + src/engine/game/interface/rooms.zig | 6 +- src/engine/game/xtrabits/movie.zig | 135 ++++- src/formats/bink.zig | 12 +- src/formats/hog.zig | 15 +- src/formats/matroska.zig | 765 ++++++++++++++++++++++++++++ src/platform/video.zig | 93 +++- src/scripting/front_end.zig | 2 +- 23 files changed, 1609 insertions(+), 121 deletions(-) create mode 100644 docs/formats/matroska.md create mode 100644 src/formats/matroska.zig diff --git a/README.md b/README.md index 2d41c76d..0a548396 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ OpenReliant is in beta. The whole campaign is playable from the first mission to - **Controls**: Mouse and keyboard, flight sticks, HOTAS and gamepads, with force feedback played as rumble, and the bindings set in the game's controls screen. - **Graphics**: Per-pixel shading with gamma-corrected lighting, and real-time shadows. Rendering runs at native resolution in 32-bit colour, with bloom, anti-aliasing, smooth motion at high frame rates, and more detailed explosions, shields and planets. `--original` restores the original graphics and sound. - **Audio**: 3D positional sound with reverb and headphone HRTF. -- **Mods**: Archives and folders in the game's `mods` folder can replace or add to any of the game's files: models, sounds, music, speech, movies and missions. PNG pictures of any size can replace textures, with material maps for physically based shading (normal maps, highlights, glow and sky reflections), and the interface's shapes and backgrounds, including widescreen ones. Mods can add ship types, guns, missiles and pilots with their own faces and voices, and models built from OBJ or glTF. Scripts in Luau can change the game's records, hook its functions and the missions' commands, run on ships, missiles and turrets, draw on the display and the menus, play their own sounds in 3D and control the music, offer a page of options, add game modes and campaigns, change the game's own campaign, its missions and the KILLBOARD, change the AI's combat maneuvers, and add shaders. A mods screen turns mods on and off, sets their load order, and shows which mods replace each other's files and which mods a mod needs. The scripting API is stable from 0.8 on ([What mods can do](docs/guide/what-mods-can-do.md), [Modding](docs/guide/modding.md), [Scripting](docs/guide/scripting.md)). +- **Mods**: Archives and folders in the game's `mods` folder can replace or add to any of the game's files: models, sounds, music, speech, movies and missions. PNG pictures of any size can replace textures, with material maps for physically based shading (normal maps, highlights, glow and sky reflections), and the interface's shapes and backgrounds, including widescreen ones. WebM or Matroska files can replace the movies. Mods can add ship types, guns, missiles and pilots with their own faces and voices, and models built from OBJ or glTF. Scripts in Luau can change the game's records, hook its functions and the missions' commands, run on ships, missiles and turrets, draw on the display and the menus, play their own sounds in 3D and control the music, offer a page of options, add game modes and campaigns, change the game's own campaign, its missions and the KILLBOARD, change the AI's combat maneuvers, and add shaders. A mods screen turns mods on and off, sets their load order, and shows which mods replace each other's files and which mods a mod needs. The scripting API is stable from 0.8 on ([What mods can do](docs/guide/what-mods-can-do.md), [Modding](docs/guide/modding.md), [Scripting](docs/guide/scripting.md)). - **Front End**: The main menu, the settings screen with its audio, controls, video and graphics, the pilot roster, the saved games and the Reliant's and the Yamato's rooms, with a new pilot's induction, the news reports, the in-game options, the simulator pod, the locker, the CD player, the briefings, the loadout, the ITAC and the story's end. - **In Development**: 1.0 is next: the beta and the mission editor polished with players' feedback, and the whole campaign played through. The mission editor, [OpenReliant Editor](https://github.com/OpenReliant/openreliant-editor), works in an early preview with its script view, map and debugger, and grows in releases of its own. After 1.0 come multiplayer and new kinds of ships for mods, and later a standalone engine that other space games can use. See the [milestones](../../milestones) for the roadmap. diff --git a/deps/ffmpeg/build.zig b/deps/ffmpeg/build.zig index 78ef3e81..017288da 100644 --- a/deps/ffmpeg/build.zig +++ b/deps/ffmpeg/build.zig @@ -1,18 +1,22 @@ //! Builds the part of FFmpeg that OpenReliant uses as a static library: the Bink video and audio -//! decoders for the game's movies (`libavcodec/bink.c`, `libavcodec/binkaudio.c`), the MP3 decoder -//! for the crew's lines and mods' recordings (`libavcodec/mpegaudiodec_float.c`), the FLAC, Vorbis -//! and Opus decoders for mods' recordings (`libavcodec/flacdec.c`, `libavcodec/vorbisdec.c`, -//! `libavcodec/opus/dec.c`), and the parts of `libavcodec`, `libavutil` and `libswresample` they -//! need. The Opus decoder resamples with `libswresample`. It is plain C, without FFmpeg's assembly -//! or threads. OpenReliant reads the Bink container, the MP3 frames and the Ogg pages itself -//! (`src/formats/bink.zig`, `src/formats/mp3.zig`, `src/formats/ogg.zig`) and hands the FLAC -//! decoder whole files, so none of `libavformat` is built. FFmpeg is LGPL-2.1 or later (the -//! upstream's `LICENSE.md`), and none of its GPL parts are built. +//! decoders for the game's movies (`libavcodec/bink.c`, `libavcodec/binkaudio.c`), the VP9 decoder +//! for mods' movies (`libavcodec/vp9.c`), the MP3 decoder for the crew's lines and mods' recordings +//! (`libavcodec/mpegaudiodec_float.c`), the FLAC, Vorbis and Opus decoders for mods' recordings +//! and movies' sound (`libavcodec/flacdec.c`, `libavcodec/vorbisdec.c`, `libavcodec/opus/dec.c`), +//! and the parts of `libavcodec`, `libavutil` and `libswresample` they need. The VP9 decoder reads +//! its frames' headers through FFmpeg's coded bitstream reader (`libavcodec/cbs_vp9.c`) and splits +//! superframes with a bitstream filter (`libavcodec/bsf/vp9_superframe_split.c`), and the Opus +//! decoder resamples with `libswresample`. It is plain C, without FFmpeg's assembly or threads. +//! OpenReliant reads the Bink and Matroska containers, the MP3 frames and the Ogg pages itself +//! (`src/formats/bink.zig`, `src/formats/matroska.zig`, `src/formats/mp3.zig`, +//! `src/formats/ogg.zig`) and hands the FLAC decoder whole files, so none of `libavformat` is +//! built. FFmpeg is LGPL-2.1 or later (the upstream's `LICENSE.md`), and none of its GPL parts are +//! built. //! //! The files and the configuration are the ones FFmpeg's `configure` gives for //! `--disable-everything --disable-asm --disable-pthreads --disable-autodetect -//! --enable-decoder=bink,binkaudio_rdft,binkaudio_dct,mp3float,flac,vorbis,opus`, minus what the -//! decoding calls never reach: the objects that a program using those calls links, and the +//! --enable-decoder=bink,binkaudio_rdft,binkaudio_dct,mp3float,flac,vorbis,opus,vp9`, minus what +//! the decoding calls never reach: the objects that a program using those calls links, and the //! settings those files read. The one addition is `libavutil/sha.c`, which the random seed falls //! back on when no system source of random numbers is set up, as here. The version is the upstream //! release that the manifest names. @@ -35,6 +39,8 @@ pub fn build(b: *std.Build) void { // The settings the files read: plain C for any processor, and the aligned memory and the C99 // maths every target has. const config = b.addConfigHeader(.{ .style = .blank, .include_path = "config.h" }, .{ + .ARCH_AARCH64 = false, + .ARCH_ARM = false, .ARCH_X86 = false, .HAVE_BIGENDIAN = false, .HAVE_FAST_64BIT = false, @@ -47,6 +53,7 @@ pub fn build(b: *std.Build) void { .CONFIG_LIBLCEVC_DEC = false, .CONFIG_MEMORY_POISONING = false, .CONFIG_SAFE_BITSTREAM_READER = true, + .CONFIG_CBS_VP9 = true, }); for (maths) |name| config.addValue(b.fmt("HAVE_{s}", .{name}), bool, true); const components = b.addConfigHeader(.{ .style = .blank, .include_path = "config_components.h" }, .{ @@ -57,7 +64,11 @@ pub fn build(b: *std.Build) void { .CONFIG_FLAC_DECODER = true, .CONFIG_VORBIS_DECODER = true, .CONFIG_OPUS_DECODER = true, + .CONFIG_VP9_DECODER = true, + .CONFIG_VP9_SUPERFRAME_SPLIT_BSF = true, }); + // The VP9 decoder counts the hardware decoders built, of which there are none. + for (vp9_hardware) |name| components.addValue(b.fmt("CONFIG_VP9_{s}_HWACCEL", .{name}), bool, false); const avconfig = b.addConfigHeader(.{ .style = .blank, .include_path = "libavutil/avconfig.h" }, .{ .AV_HAVE_BIGENDIAN = false, .AV_HAVE_FAST_UNALIGNED = false, @@ -78,11 +89,13 @@ pub fn build(b: *std.Build) void { \\ &ff_flac_decoder, \\ &ff_opus_decoder, \\ &ff_vorbis_decoder, + \\ &ff_vp9_decoder, \\ NULL }; \\ ); _ = lists.add("libavcodec/bsf_list.c", \\static const FFBitStreamFilter * const bitstream_filters[] = { + \\ &ff_vp9_superframe_split_bsf, \\ NULL }; \\ ); @@ -107,6 +120,9 @@ pub fn build(b: *std.Build) void { b.installArtifact(lib); } +/// The hardware decoders of VP9 that FFmpeg can build. +const vp9_hardware = [_][]const u8{ "D3D11VA", "D3D11VA2", "D3D12VA", "DXVA2", "NVDEC", "VAAPI", "VDPAU", "VIDEOTOOLBOX", "VULKAN" }; + /// The C99 maths `libavutil/libm.h` stands in for where a system lacks them. const maths = [_][]const u8{ "ATAN2F", @@ -149,6 +165,9 @@ const sources = [_][]const u8{ "libavcodec/bitstream_filters.c", "libavcodec/blockdsp.c", "libavcodec/bsf.c", + "libavcodec/bsf/vp9_superframe_split.c", + "libavcodec/cbs.c", + "libavcodec/cbs_vp9.c", "libavcodec/codec_desc.c", "libavcodec/codec_par.c", "libavcodec/dct32_fixed.c", @@ -189,11 +208,24 @@ const sources = [_][]const u8{ "libavcodec/threadprogress.c", "libavcodec/tiff_common.c", "libavcodec/utils.c", + "libavcodec/videodsp.c", "libavcodec/vlc.c", "libavcodec/vorbis.c", "libavcodec/vorbis_data.c", "libavcodec/vorbisdec.c", "libavcodec/vorbisdsp.c", + "libavcodec/vp9.c", + "libavcodec/vp9block.c", + "libavcodec/vp9data.c", + "libavcodec/vp9dsp.c", + "libavcodec/vp9dsp_10bpp.c", + "libavcodec/vp9dsp_12bpp.c", + "libavcodec/vp9dsp_8bpp.c", + "libavcodec/vp9lpf.c", + "libavcodec/vp9mvs.c", + "libavcodec/vp9prob.c", + "libavcodec/vp9recon.c", + "libavcodec/vpx_rac.c", "libavcodec/wma_freqs.c", "libavcodec/xiph.c", @@ -238,6 +270,7 @@ const sources = [_][]const u8{ "libavutil/tx_float.c", "libavutil/tx_int32.c", "libavutil/utils.c", + "libavutil/video_enc_params.c", "libswresample/audioconvert.c", "libswresample/dither.c", diff --git a/docs/README.md b/docs/README.md index 508d1312..1da2ff63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ The [user guide](guide/README.md) covers installing, configuring and playing Ope | [`formats/fm8.md`](formats/fm8.md) | Face films: the pilots' faces the radio's window plays, their chunks and their codec. | | [`formats/fnt.md`](formats/fnt.md) | `.fnt` bitmap fonts. | | [`formats/bink.md`](formats/bink.md) | `.bik` movies: the Bink container. | +| [`formats/matroska.md`](formats/matroska.md) | Matroska and WebM files: mods' movies, and the elements and blocks OpenReliant reads of them. | | [`formats/frc.md`](formats/frc.md) | `.frc` force-feedback effects. | | [`formats/dte.md`](formats/dte.md) | `.DTE` missions: directory, ships, triggers, and the script VM. | | [`formats/mission-source.md`](formats/mission-source.md) | Mission source: a mission as Luau text, which `sltool dte decompile` writes and `sltool dte build` compiles, its definitions for editors and its reference. | diff --git a/docs/engine/movies.md b/docs/engine/movies.md index 3aba6b13..2a86f650 100644 --- a/docs/engine/movies.md +++ b/docs/engine/movies.md @@ -252,11 +252,12 @@ the movie opens the disc of the mission's carrier. [`engine/bink.zig`](../../src/engine/bink.zig) stands in for Bink, each function for the call it names; [`engine/bink/picture.zig`](../../src/engine/bink/picture.zig) turns a decoded picture into -colours, by BT.601 in its limited range. It reads the container itself and hands the packets to a -`Codec`: FFmpeg's decoders, in the platform ([Platform](../port/platform.md#movies)). It decodes a -movie's sound whole as the movie opens and plays it as a Miles stream, where Bink fills a Miles -sample as it plays; the sound starts with the first frame, and OpenAL Soft resamples it as it -resamples every sound ([Sound](../port/sound.md#openal-soft)). +colours, by BT.601 in its limited range, or by what a mod's movie says: BT.709, or the full range. +It reads the container itself and hands the packets to a `Codec`: FFmpeg's decoders, in the +platform ([Platform](../port/platform.md#movies)). It decodes a movie's sound whole as the movie +opens and plays it as a Miles stream, where Bink fills a Miles sample as it plays; the sound starts +with the first frame, and OpenAL Soft resamples it as it resamples every sound +([Sound](../port/sound.md#openal-soft)). [`game/xtrabits/movie.zig`](../../src/engine/game/xtrabits/movie.zig) holds the game's players, one `Kind` each and one for the landing's loop, `bink_frame`, and the hangar's movies; @@ -303,5 +304,17 @@ a bank. Super Resolution 1.0), which keeps its edges sharp without steps ([Renderer](../port/renderer.md#improvements)). +**Improvement:** a mod can give a movie in WebM or Matroska, of VP9 video with Opus or Vorbis sound, +in place of a Bink movie ([Movies](../guide/modding.md#movies), [Matroska and WebM +files](../formats/matroska.md)). For the movie `name.bik`, a mod's `name.webm` comes first, then +its `name.mkv`, then the Bink movie, a mod's or the game's. Its frames show at their own times, +unless the movie's kind sets a rate in place of its own, as the transitions' and the rooms' 15 +frames a second do. It is drawn in the place and at the size of the Bink movie it replaces, which +OpenReliant reads from that movie's header, or at its own size where there is none. Like other +mods' files, it is used under `--original` too. The deblocking above passes it by, as VP9 filters +the edges of its own blocks. One that can't be played, such as one of AV1 video +([#1334](https://github.com/OpenReliant/openreliant/issues/1334)), is left out with a message in +the log, and the Bink movie plays. + Not ported: the transitions of the multiplayer screens ([#404](https://github.com/OpenReliant/openreliant/issues/404)). diff --git a/docs/formats/matroska.md b/docs/formats/matroska.md new file mode 100644 index 00000000..ac249e10 --- /dev/null +++ b/docs/formats/matroska.md @@ -0,0 +1,98 @@ +# Matroska and WebM files + +Matroska is a container for video, audio and subtitles in many codecs. WebM is Matroska that holds +only VP8, VP9 or AV1 video and Vorbis or Opus audio. The original reads no Matroska files. +OpenReliant reads them from mods, in place of a Bink movie ([Movies](../guide/modding.md#movies)), +and plays VP9 video with Opus or Vorbis sound. A WebM file usually has the extension `.webm`, and +any other Matroska file `.mkv`. + +## Elements + +A file is a tree of EBML elements (RFC 8794). Each element is an ID, the size of its data, and the +data, which holds a value or more elements. The ID and the size are numbers of variable length: +the zero bits at the start of the first byte, plus one, give the number's length in bytes, and the +set bit after them marks where the length ends. An ID keeps that bit as part of its value, as the +specifications write the IDs, and has at most 4 bytes. A size drops it, and has at most 8 bytes; a +size whose value has every bit set is unknown, as a file written as a stream leaves its segment's +and its clusters' sizes. Integers are big-endian, in up to 8 bytes, floats are 4 or 8 bytes, +big-endian, and strings may end in zero bytes. + +A file is an EBML header, `1A45DFA3`, whose `DocType` (`4282`) is `matroska` or `webm`, then a +segment, `18538067`, which holds the rest (RFC 9559): + +| Element | ID | Holds | +|---|---|---| +| `Info` | `1549A966` | Its `TimestampScale` (`2AD7B1`): how many nanoseconds a tick of the timestamps lasts, 1,000,000 unless given | +| `Tracks` | `1654AE6B` | A `TrackEntry` (`AE`) for each track | +| `Cluster` | `1F43B675` | Its `Timestamp` (`E7`), in ticks, then its blocks | + +A segment can also hold an index of where its elements are (`SeekHead`), cues for seeking +(`Cues`), chapters, tags and attachments. + +A track entry holds: + +| Element | ID | Holds | +|---|---|---| +| `TrackNumber` | `D7` | The number its blocks give | +| `TrackType` | `83` | 1 for video, 2 for audio | +| `FlagEnabled` | `B9` | 0 for a track that is off, 1 unless given | +| `CodecID` | `86` | `V_VP9`, `A_OPUS` or `A_VORBIS`, say | +| `CodecPrivate` | `63A2` | The codec's headers, where it has any | +| `ContentEncodings` | `6D80` | How the track's data is compressed or encrypted, where it is | +| `Video` | `E0` | A video track's `PixelWidth` (`B0`) and `PixelHeight` (`BA`) | +| `Audio` | `E1` | An audio track's `SamplingFrequency` (`B5`, a float, 8000 unless given) and `Channels` (`9F`, 1 unless given) | + +## Blocks + +A cluster holds the tracks' frames in blocks: each in a `SimpleBlock` (`A3`), or in a `Block` +(`A1`) inside a `BlockGroup` (`A0`). A block starts with a header: + +| Offset | Size | Field | +|---|---|---| +| 0 | 1 to 8 | The track's number, as a size | +| then | 2 | The time, in ticks after the cluster's timestamp, signed | +| then | 1 | Flags: `0x80` a key frame (in a simple block), `0x08` a frame not shown, `0x06` the lacing, `0x01` a frame that may be dropped | + +The frame follows. A block in a group leaves the key frame flag clear: it is a key frame unless the +group also holds a `ReferenceBlock` (`FB`), which names a frame it depends on. + +Lacing puts several frames in one block, as audio packets often are. The first byte after the +header is the number of frames less one, then come the frames' sizes, all but the last's, which is +what remains: + +- **Xiph lacing** (`0x02`): each size as bytes added up, where a byte of 255 carries on into the + next byte and any other ends the size. +- **EBML lacing** (`0x06`): the first size as a size number, then each next size as a signed + number of variable length, the difference from the size before. A signed number is the value of + a size less half its range, `2^(7n-1) - 1` for a number of `n` bytes. +- **Fixed-size lacing** (`0x04`): no sizes, as the frames all have the same size. + +## Codecs + +- **VP9** (`V_VP9`): each block holds a frame, or a superframe: several frames, one shown and the + others only decoded, with an index of their sizes at its end. A frame's first byte holds the + frame's marker, `10` in its two top bits, then the profile's low bit and its high bit. Profile 0 + is 8 bits with the colour at half size across and down (4:2:0). A key frame gives its colour + space, such as BT.601 or BT.709, or leaves it unknown, and says whether its range is limited or + full. +- **Opus** (`A_OPUS`): `CodecPrivate` is the identification header, `OpusHead`, as in an Ogg + stream ([Ogg files](ogg.md#streams)). +- **Vorbis** (`A_VORBIS`): `CodecPrivate` is the three headers laced together in Xiph lacing, as + the decoder takes them. + +## In OpenReliant + +[`formats/matroska.zig`](../../src/formats/matroska.zig) reads the EBML header, then the segment's +information and tracks, and the blocks of every cluster that belong to the first video track and +the first audio track that are on. It keeps each video frame with its time and whether it is a key +frame, and each audio packet, and skips the other elements. A cluster whose size is unknown ends +where one of the segment's own elements begins, such as the next cluster or the cues. It refuses a +file that isn't Matroska or WebM, one without video, one whose first video frame isn't a key frame, +and one whose tracks are compressed or encrypted. + +`engine/bink.zig` plays the movie in a Bink movie's place +([Movies](../engine/movies.md#in-openreliant)). It takes only VP9 of profile 0, which it checks +from the first frame. FFmpeg's VP9 decoder decodes the frames, and its Opus or Vorbis decoder the +sound, all of it as the movie opens ([Platform](../port/platform.md#movies)). The colours are +turned into RGB by BT.709 where the frames say BT.709, or SMPTE 240M, which is close to it, and by +BT.601 otherwise, in the range they say. diff --git a/docs/guide/modding.md b/docs/guide/modding.md index 81506efe..7c5360df 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -19,8 +19,8 @@ make and change it. The other sections can be read in any order, as you need the - [A first mod](#a-first-mod) - [Where mods go](#where-mods-go) and [the manifest](#the-manifest) - [Load order](#load-order) and [the mods screen](#the-mods-screen) -- [How files are replaced](#how-files-are-replaced), their [formats](#formats), and - [music](#music) +- [How files are replaced](#how-files-are-replaced), their [formats](#formats), [music](#music), + [sounds](#sounds) and [movies](#movies) - [Textures](#textures) and [material maps](#material-maps) - [The interface](#the-interface): [shapes](#shapes), [pictures](#pictures) and [fonts](#fonts) - [Faces and voices](#faces-and-voices) @@ -372,9 +372,7 @@ and a few modern ones beside them: | Missions | `.dte` | A mission editor ([Missions](#missions)) | | Stats tables | `.bin` | Better changed from a load script ([The records](scripting.md#the-records)) | | Whole sound banks | `.fat` | No tool builds one: replace a bank's sounds one at a time instead ([Sounds](#sounds)) | -| Movies | Bink (`.bik`) | RAD Game Tools' Bink tools | - -Movies in modern formats are planned ([#1179](https://github.com/OpenReliant/openreliant/issues/1179)). +| Movies | WebM or Matroska, of VP9 video with Opus or Vorbis sound, or Bink (`.bik`) | FFmpeg ([Movies](#movies)), or RAD Game Tools' Bink tools | ### Music @@ -420,6 +418,40 @@ narrows to a point as the ship moves away, while a shot, a missile or an explosi still be placed. Scripts can also play a sound file from their mod by name, in any of these formats, in 2D or 3D ([Sound](scripting.md#sound)). +### Movies + +A movie can be a WebM or Matroska file of VP9 video, named after the game's movie: `warty_.webm` or +`warty_.mkv` replaces the game's `warty_.bik`, the Warthog logo. If a mod has more than one, +OpenReliant uses the first in this order: WebM, Matroska, then Bink. A movie that can't be played +is left out, the log says why, and the Bink movie plays instead. + +- **Video.** VP9 of profile 0: 8 bits, with the colour at half size across and down (4:2:0), which + encoders make unless told otherwise. OpenReliant takes the colours as BT.601 in the limited range, + as the game's movies have them, unless the video is tagged as BT.709 or full range. AV1 is + planned ([#1334](https://github.com/OpenReliant/openreliant/issues/1334)). +- **Size.** A movie is drawn in the place and at the size of the game's movie it replaces, so a + larger movie is sharper in the same place. Keep the game movie's shape, and use a whole multiple + of its size, such as 1280x960 or 1920x1440 for a movie of 640x480. VP9 is decoded on the + processor, on one thread, so a movie much larger than the window only costs time. A movie that + replaces none of the game's, such as a new one that a script plays, is drawn at its own size. +- **Rate.** A movie's frames show at their own times, at any rate. The front end's transitions, + the landing, the ITAC's movies, and the movies of the rooms, a new pilot's induction, the news + reports and the locker play at 15 frames a second whatever the movie's rate, as the game plays + them. Make those at 15 frames a second, with as many frames as the game's: the rooms show the + crew on given frames. +- **Sound.** Opus or Vorbis, at any rate, mono or stereo, or none. It is decoded whole as the movie + starts. Sound in another format, such as AAC, is left out, and the log says so. +- **File size.** A movie is read whole into memory as it starts. A file in a mod's folder can be at + most 256 MiB. + +With [FFmpeg](https://ffmpeg.org), this makes `warty_.webm` from a sharper copy of the movie, +`warty_hd.mov`, made with an upscaler say. `-crf` sets the quality: a lower number is better and +makes a larger file. Add `-colorspace bt709` where the copy's colours are in BT.709. + +```bash +ffmpeg -i warty_hd.mov -c:v libvpx-vp9 -crf 24 -b:v 0 -pix_fmt yuv420p -c:a libopus warty_.webm +``` + ## Textures Model and effect textures come from the texture cache, `tcachehw.dat` ([Texture @@ -1433,8 +1465,9 @@ report one. As it starts, it lists each mod it loads, in order, by its manifest' one, and then what each of the mod's files does: which game file, texture, shape, picture, font, line or sound it replaces, which earlier mod's file it replaces, or which file it adds. A recording in a different format from the game file it replaces, such as `New_Pensive.flac`, is listed as -replacing `New_Pensive.wav`. A mod that the mods screen has turned off is listed as off, and none -of it is used. When a font is loaded, the log says which outline font draws it. +replacing `New_Pensive.wav`, and a movie such as `warty_.webm` as replacing the movie +`warty_.bik`. A mod that the mods screen has turned off is listed as off, and none of it is used. +When a font is loaded, the log says which outline font draws it. ```text info(mods): music.hog matches music.hog.sha256 @@ -1476,3 +1509,6 @@ A script's messages and errors follow its mod's name, as above; | `the radio's film is left out: ...` | The film's name ([Faces](#faces)) | | `the dead channel's film pilots\static.fm8 is left out too: ...` | The game's `pilots.hog` is missing or damaged | | `the mod's music may loop back to the wrong moment, since the game's piece can't be read: ...` | Keep the game's piece in the game folder ([Music](#music)) | +| `the movie is left out: its video isn't VP9 of profile 0, 8 bits with 4:2:0 colour` | Encode it again as VP9, with `-pix_fmt yuv420p` in FFmpeg ([Movies](#movies)) | +| `the movie is left out: ...` | The file is damaged, or isn't a WebM or Matroska file of the kind OpenReliant plays ([Movies](#movies)) | +| `the movie plays without its sound: it is , and OpenReliant plays Opus and Vorbis` | Encode its sound as Opus or Vorbis, with `-c:a libopus` in FFmpeg ([Movies](#movies)) | diff --git a/docs/guide/openreliant.d.luau b/docs/guide/openreliant.d.luau index fa9891fb..27727d97 100644 --- a/docs/guide/openreliant.d.luau +++ b/docs/guide/openreliant.d.luau @@ -924,7 +924,7 @@ export type Ui = { start_game_mode: (name: string) -> boolean, --- From a game mode's briefing screen, asks the front end to fly the mode's next mission (`core.game_mode_mission`). Only menu scripts can use it. launch_mission: () -> (), - --- Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. + --- Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, or a mod's WebM or Matroska file with the same name, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. play_movie: (name: string) -> (), --- Asks the front end to quit the game. Only menu scripts can use it. quit: () -> (), diff --git a/docs/guide/reference.md b/docs/guide/reference.md index 6dc31850..e40392e4 100644 --- a/docs/guide/reference.md +++ b/docs/guide/reference.md @@ -297,7 +297,7 @@ Drawing over the menus, the front end's screens and the pause menu, while they'r | `go_to(screen: FrontEndScreen)` | nothing | Asks the front end to go to its screen `screen`, or to the mod's screen that stands in for it. Only menu scripts can use it. | | `start_game_mode(name: string)` | boolean | Asks the front end to start the game mode `name`: the calling mod's by its own name, or any mod's by the qualified one. Only menu scripts can use it. Returns whether the mode is registered. | | `launch_mission()` | nothing | From a game mode's briefing screen, asks the front end to fly the mode's next mission (`core.game_mode_mission`). Only menu scripts can use it. | -| `play_movie(name: string)` | nothing | Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. | +| `play_movie(name: string)` | nothing | Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, or a mod's WebM or Matroska file with the same name, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. | | `quit()` | nothing | Asks the front end to quit the game. Only menu scripts can use it. | | `from_game_screen(at: vector)` | vector | Converts the point `at` on the game's 640 by 480 screen to window pixels, as the front end and the rooms centre that screen in the window. Use it to draw over a spot on one of their pictures. | | `to_game_screen(at: vector)` | vector | Converts the window pixel `at`, such as the pointer's position, to a point on the game's 640 by 480 screen. The inverse of `from_game_screen`. | @@ -706,7 +706,7 @@ turret, and a turret's own scripts on their turret. | `go_to(screen: FrontEndScreen)` | nothing | Asks the front end to go to its screen `screen`, or to the mod's screen that stands in for it. Only menu scripts can use it. | | `start_game_mode(name: string)` | boolean | Asks the front end to start the game mode `name`: the calling mod's by its own name, or any mod's by the qualified one. Only menu scripts can use it. Returns whether the mode is registered. | | `launch_mission()` | nothing | From a game mode's briefing screen, asks the front end to fly the mode's next mission (`core.game_mode_mission`). Only menu scripts can use it. | -| `play_movie(name: string)` | nothing | Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. | +| `play_movie(name: string)` | nothing | Plays the movie `name`, a Bink file of the game folder's or a mod's such as `"intro.bik"`, or a mod's WebM or Matroska file with the same name, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it. | | `quit()` | nothing | Asks the front end to quit the game. Only menu scripts can use it. | | `from_game_screen(at: vector)` | vector | Converts the point `at` on the game's 640 by 480 screen to window pixels, as the front end and the rooms centre that screen in the window. Use it to draw over a spot on one of their pictures. | | `to_game_screen(at: vector)` | vector | Converts the window pixel `at`, such as the pointer's position, to a point on the game's 640 by 480 screen. The inverse of `from_game_screen`. | diff --git a/docs/guide/scripting.md b/docs/guide/scripting.md index e90d8a1d..cb7d78c8 100644 --- a/docs/guide/scripting.md +++ b/docs/guide/scripting.md @@ -2236,7 +2236,8 @@ ui.replace_screen("main_menu", screen) mode, and `ui.quit()` quits the game. If the front end can't show the screen asked for, nothing happens and the log says so. - `ui.play_movie(name)` plays a Bink movie from the game folder or a mod, such as - `"thread01.bik"`, on a cleared screen. Escape or the pointer's right button ends it. + `"thread01.bik"`, or a mod's WebM or Matroska movie with the same name + ([Movies](modding.md#movies)), on a cleared screen. Escape or the pointer's right button ends it. - `ui.replace_screen(screen, nil)` gives the screen back to the front end. If the mod's script stops, or its screen's callback fails, the front end shows its own screen again. - These functions are for menu scripts only. diff --git a/docs/guide/what-mods-can-do.md b/docs/guide/what-mods-can-do.md index 0445cb16..3a136bd6 100644 --- a/docs/guide/what-mods-can-do.md +++ b/docs/guide/what-mods-can-do.md @@ -19,6 +19,7 @@ the mods screen ([The mods screen](modding.md#the-mods-screen)). | Replace the game's sounds one at a time, in WAV, FLAC, MP3, Ogg Vorbis or Opus, such as the guns, the explosions, the engines, the menus' sounds and Betty's warnings | [Sounds](modding.md#sounds) | | | Replace the pilots' faces, with sharper films than the game's if they like, and the lines they say | [Faces and voices](modding.md#faces-and-voices) | [`trent`](../../examples/mods/trent) | | Give lines of speech, briefings and Enriquez's scenes as WAV, FLAC, MP3, Ogg Vorbis or Opus recordings, which play as recorded | [Lines](modding.md#lines) | | +| Replace the movies with WebM or Matroska files, sharper than the game's if they like | [Movies](modding.md#movies) | | ## Change the look @@ -118,7 +119,7 @@ the mods screen ([The mods screen](modding.md#the-mods-screen)). These are planned, each in an issue: -- Movies in modern formats ([#1179](https://github.com/OpenReliant/openreliant/issues/1179)) +- Movies in AV1 ([#1334](https://github.com/OpenReliant/openreliant/issues/1334)) - Campaigns of more than 28 missions ([#987](https://github.com/OpenReliant/openreliant/issues/987)) - Post effects that read the scene's depth ([#633](https://github.com/OpenReliant/openreliant/issues/633)) - KTX2 files with Basis Universal data ([#637](https://github.com/OpenReliant/openreliant/issues/637)) diff --git a/docs/port/platform.md b/docs/port/platform.md index 623ee236..4f7684f2 100644 --- a/docs/port/platform.md +++ b/docs/port/platform.md @@ -10,7 +10,7 @@ The `openreliant` executable runs the game on SDL3, which stands in for everythi | [`platform/joystick.zig`](../../src/platform/joystick.zig) | DirectInput's joystick: SDL's joysticks and gamepads as the device the game reads into `DIJOYSTATE` | | [`platform/audio.zig`](../../src/platform/audio.zig) | The wave-out device Miles played through; OpenAL Soft or OpenReliant's own mixer plays the game's sound into it ([Sound](sound.md)) | | [`platform/openal.zig`](../../src/platform/openal.zig) | Miles's 3D providers: the game's sound calls played by OpenAL Soft ([Sound](sound.md#openal-soft)) | -| [`platform/video.zig`](../../src/platform/video.zig) | FFmpeg's decoders of Bink, MP3, FLAC, Vorbis and Opus: the movies' packets, the crew's lines, and mods' music, lines and sounds ([Movies](#movies)) | +| [`platform/video.zig`](../../src/platform/video.zig) | FFmpeg's decoders of Bink, VP9, MP3, FLAC, Vorbis and Opus: the movies' packets, the crew's lines, and mods' music, lines, sounds and movies ([Movies](#movies)) | | [`platform/fonts.zig`](../../src/platform/fonts.zig) | Nothing: the outline fonts' glyphs, drawn by FreeType ([Fonts](#fonts)) | | [`platform/macos.zig`](../../src/platform/macos.zig) | Nothing: what macOS needs around SDL ([macOS](#macos)) | | [`openreliant/main.zig`](../../src/openreliant/main.zig) | `WinMain`: opening the game's files and running the frame loop | @@ -20,9 +20,9 @@ SDL comes from the [castholm/SDL](https://github.com/castholm/SDL) package, whic ## Movies -The game's movies ([Movies](../engine/movies.md)) are decoded by [FFmpeg](https://ffmpeg.org)'s Bink decoders, in place of RAD's library, and the crew's lines in the rooms by its MP3 decoder, in place of Miles's ([The crew](../engine/rooms.md#the-crew)). Its FLAC, Vorbis and Opus decoders handle mods' music, lines and sounds in those formats ([FLAC files](../formats/flac.md), [Ogg files](../formats/ogg.md)). [`deps/ffmpeg`](../../deps/ffmpeg/build.zig) builds FFmpeg 9.0.2, pinned in its manifest by the release's tag and commit, as a static library of the Bink video and audio decoders, the MP3, FLAC, Vorbis and Opus decoders, and the parts of `libavcodec`, `libavutil` and `libswresample` they need (the Opus decoder resamples with `libswresample`). It compiles the files that FFmpeg's `configure --disable-everything --enable-decoder=bink,binkaudio_rdft,binkaudio_dct,mp3float,flac,vorbis,opus --disable-asm --disable-pthreads --disable-autodetect` would build for a program that uses the decoding calls, with the settings those files read. It is plain C for any processor, without assembly or threads, and like OpenAL Soft it is always built optimized, whatever the game is built as. Its assertion messages hold their source files' paths, which Clang writes with the target's separators (`-ffile-reproducible`), so that Windows and the other systems build the same `openreliant.exe`. None of FFmpeg's GPL parts are built. +The game's movies ([Movies](../engine/movies.md)) are decoded by [FFmpeg](https://ffmpeg.org)'s Bink decoders, in place of RAD's library, and the crew's lines in the rooms by its MP3 decoder, in place of Miles's ([The crew](../engine/rooms.md#the-crew)). Its FLAC, Vorbis and Opus decoders handle mods' music, lines and sounds in those formats ([FLAC files](../formats/flac.md), [Ogg files](../formats/ogg.md)), and its VP9 decoder, with the Vorbis and Opus ones, mods' movies ([Matroska and WebM files](../formats/matroska.md)). [`deps/ffmpeg`](../../deps/ffmpeg/build.zig) builds FFmpeg 9.0.2, pinned in its manifest by the release's tag and commit, as a static library of the Bink video and audio decoders, the VP9, MP3, FLAC, Vorbis and Opus decoders, and the parts of `libavcodec`, `libavutil` and `libswresample` they need (the Opus decoder resamples with `libswresample`, and the VP9 decoder splits superframes with a bitstream filter). It compiles the files that FFmpeg's `configure --disable-everything --enable-decoder=bink,binkaudio_rdft,binkaudio_dct,mp3float,flac,vorbis,opus,vp9 --disable-asm --disable-pthreads --disable-autodetect` would build for a program that uses the decoding calls, with the settings those files read. It is plain C for any processor, without assembly or threads, and like OpenAL Soft it is always built optimized, whatever the game is built as. Its assertion messages hold their source files' paths, which Clang writes with the target's separators (`-ffile-reproducible`), so that Windows and the other systems build the same `openreliant.exe`. None of FFmpeg's GPL parts are built. -[`platform/video.zig`](../../src/platform/video.zig) sets each decoder up as FFmpeg's demuxer would, and gives the engine the pictures, 4:2:0 in BT.601's limited range, and the sound, as 16-bit samples rounded as FFmpeg's conversion rounds them. The pictures and the sound match what the `ffmpeg` tool decodes from the same files with its plain C code (`-cpuflags 0`), to within the last bit of a sample. Since it is built without threads, FFmpeg is only called from the game's thread. +[`platform/video.zig`](../../src/platform/video.zig) sets each decoder up as FFmpeg's demuxer would, and gives the engine the pictures, 4:2:0 in BT.601's limited range or as a VP9 frame says, and the sound, as 16-bit samples rounded as FFmpeg's conversion rounds them. The pictures and the sound match what the `ffmpeg` tool decodes from the same files with its plain C code (`-cpuflags 0`), to within the last bit of a sample. Since it is built without threads, FFmpeg is only called from the game's thread. ## Fonts diff --git a/src/engine/bink.zig b/src/engine/bink.zig index b4b6c00c..72a0e755 100644 --- a/src/engine/bink.zig +++ b/src/engine/bink.zig @@ -8,11 +8,16 @@ //! packets: FFmpeg's decoders, in the platform (`platform/video.zig`). The stand-in decodes a //! movie's sound whole as the movie opens, and plays it as a Miles stream, where Bink fills a Miles //! sample as it plays. +//! +//! **Improvement:** the stand-in also plays a mod's movie in Matroska or WebM, of VP9 video with +//! Opus or Vorbis sound (`formats/matroska.zig`), in a Bink movie's place +//! ([#1179](https://github.com/OpenReliant/openreliant/issues/1179)). const std = @import("std"); const Allocator = std.mem.Allocator; const container = @import("../formats/bink.zig"); +const matroska = @import("../formats/matroska.zig"); const flac = @import("../formats/flac.zig"); const mp3 = @import("../formats/mp3.zig"); const ogg = @import("../formats/ogg.zig"); @@ -27,7 +32,12 @@ test { std.testing.refAllDecls(@This()); } -pub const Error = container.Error || Allocator.Error || error{Decoding}; +pub const Error = container.Error || matroska.Error || error{ + Decoding, + /// A mod's movie's video isn't VP9 of profile 0, 8 bits with colour at half size across and + /// down, which alone OpenReliant decodes. + UnsupportedVideo, +}; /// A stream a `Codec` decodes: a movie's video, or one of its audio tracks. pub const Stream = *anyopaque; @@ -98,9 +108,10 @@ pub const Encoding = union(enum) { mp3, /// A FLAC file (`formats/flac.zig`), sent whole as one packet. Used for mods' recordings. flac, - /// The packets of an Ogg stream of Vorbis or Opus (`formats/ogg.zig`), sent one at a time. The - /// payload is the stream's headers in the form the decoder takes them (`ogg.Stream.setup`). - /// Used for mods' recordings. + /// The packets of an Ogg stream of Vorbis or Opus (`formats/ogg.zig`), or of a mod's movie's + /// sound (`formats/matroska.zig`), sent one at a time. The payload is the stream's headers in + /// the form the decoder takes them (`ogg.Stream.setup`, `matroska.Audio.setup`). Used for mods' + /// recordings and movies. vorbis: []const u8, opus: []const u8, }; @@ -275,14 +286,28 @@ test AudioDecoding { try std.testing.expectEqual(0, decoders.streams); } -/// A movie's video, as its decoder is set up: the header's revision, size and flags. -pub const Video = struct { - revision: u8, - width: u32, - height: u32, - flags: container.VideoFlags, +/// A movie's video, as its decoder is set up: a Bink movie's, with its header's revision, size and +/// flags, or a mod's movie's VP9, at its size. +pub const Video = union(enum) { + bink: struct { revision: u8, width: u32, height: u32, flags: container.VideoFlags }, + vp9: struct { width: u32, height: u32 }, + + /// Its pictures' size, across and down. + pub fn size(video: Video) [2]u32 { + return switch (video) { + inline else => |coded| .{ coded.width, coded.height }, + }; + } }; +/// The profile of the VP9 frame `packet`, where it starts as a frame's header does: the frame's +/// marker, `10`, then the profile's low bit and its high bit. Profile 0 is 8 bits with colour at +/// half size across and down. +fn vp9Profile(packet: []const u8) ?u2 { + if (packet.len == 0 or packet[0] >> 6 != 0b10) return null; + return @intCast((packet[0] >> 4 & 1) << 1 | (packet[0] >> 5 & 1)); +} + /// One of a movie's audio tracks, as its decoder is set up. pub const Audio = struct { revision: u8, @@ -318,13 +343,32 @@ pub const Options = struct { /// `play_bink_movie` sets (`0x004AB90F`). pub const full_volume = 0x8000; +/// The file a movie plays from: one of the game's Bink movies, or a mod's movie in Matroska or +/// WebM. +const Source = union(enum) { + bink: container.Movie, + matroska: matroska.Movie, + + /// Its video's frame `index`, from 0: the packet the decoder takes, and whether the frame is a + /// key frame, which decodes without the frames before it. + fn frame(source: Source, index: usize) Error!struct { packet: []const u8, keyframe: bool } { + return switch (source) { + .bink => |movie| { + const found = try movie.frame(index); + return .{ .packet = found.video, .keyframe = found.keyframe }; + }, + .matroska => |movie| .{ .packet = movie.frames[index].data, .keyframe = movie.frames[index].keyframe }, + }; + } +}; + /// A movie open (`HBINK`). pub const Bink = struct { gpa: Allocator, codec: Codec, /// The movie's file, which it keeps. file: []const u8, - movie: container.Movie, + source: Source, video: Stream, /// Its size (`Width`, `Height`), its frames (`Frames`), and the frame `doFrame` decodes next, /// counting from 1 (`FrameNum`). @@ -332,7 +376,9 @@ pub const Bink = struct { height: u32, frames: u32, frame_number: u32 = 1, - rate: Rate, + /// The rate it plays at: a Bink movie's header's, or the one set in place of its own; null for + /// a mod's movie in Matroska or WebM, whose frames each show at its own time. + rate: ?Rate, look: Look, /// The last frame decoded. picture: ?Picture = null, @@ -340,6 +386,9 @@ pub const Bink = struct { /// each a row straight after another. deblocking: ?[]u8 = null, sound: ?Sound = null, + /// Not Bink's: the codec of a mod's movie's sound that it plays without, as the file names it, + /// for its player to log. + silent: ?[]const u8 = null, /// When the first frame was decoded, from which the others are due, in nanoseconds; and when /// it was paused. start: ?u64 = null, @@ -353,18 +402,20 @@ pub const Bink = struct { }; /// `BinkOpen`: the movie of `file`, which it takes, to be freed as it closes; its first frame - /// next. Its first audio track's sound is decoded whole, and waits for the first frame. + /// next. Its first audio track's sound is decoded whole, and waits for the first frame. A + /// mod's movie in Matroska or WebM opens in a Bink movie's place (`openMatroska`). pub fn open(gpa: Allocator, codec: Codec, file: []const u8, options: Options) Error!Bink { errdefer gpa.free(file); + if (matroska.isEbml(file)) return openMatroska(gpa, codec, file, options); const movie: container.Movie = try .parse(file); const header = movie.header; - const video = try codec.openVideo(.{ .revision = header.revision, .width = header.width, .height = header.height, .flags = header.flags }); + const video = try codec.openVideo(.{ .bink = .{ .revision = header.revision, .width = header.width, .height = header.height, .flags = header.flags } }); errdefer codec.close(video); var bink: Bink = .{ .gpa = gpa, .codec = codec, .file = file, - .movie = movie, + .source = .{ .bink = movie }, .video = video, .width = header.width, .height = header.height, @@ -384,6 +435,57 @@ pub const Bink = struct { return bink; } + /// **Improvement:** `open` for a mod's movie in Matroska or WebM: its first video track, + /// which must be VP9 of profile 0, and its first audio track's sound where it is Opus or + /// Vorbis. Its frames show at their own times, unless `options` sets a rate in place of its + /// own, as a Bink movie's would. Bink's blocks aren't VP9's, so the look's deblocking passes + /// it by. AV1 isn't played yet + /// ([#1334](https://github.com/OpenReliant/openreliant/issues/1334)). + fn openMatroska(gpa: Allocator, codec: Codec, file: []const u8, options: Options) Error!Bink { + var movie: matroska.Movie = try .parse(gpa, file); + errdefer movie.deinit(gpa); + if (movie.video.codec != .vp9 or vp9Profile(movie.frames[0].data) != 0) return error.UnsupportedVideo; + const width = movie.video.width; + const height = movie.video.height; + const video = try codec.openVideo(.{ .vp9 = .{ .width = width, .height = height } }); + errdefer codec.close(video); + var look = options.look; + look.deblock = false; + var bink: Bink = .{ + .gpa = gpa, + .codec = codec, + .file = file, + .source = .{ .matroska = movie }, + .video = video, + .width = width, + .height = height, + .frames = std.math.cast(u32, movie.frames.len) orelse return error.UnsupportedVideo, + .rate = options.rate, + .look = look, + }; + const audio = movie.audio orelse return bink; + const encoding: Encoding = switch (audio.codec) { + .opus => .{ .opus = audio.setup }, + .vorbis => .{ .vorbis = audio.setup }, + .other => |id| { + bink.silent = id; + return bink; + }, + }; + const driver = options.sound orelse return bink; + const stream = try codec.openFile(encoding); + defer codec.close(stream); + var pcm: std.ArrayList(i16) = .empty; + defer pcm.deinit(gpa); + for (movie.packets) |packet| try codec.samples(stream, packet, gpa, &pcm); + const rate: u32 = switch (audio.codec) { + .opus => ogg.opus_rate, + else => std.math.lossyCast(u32, @round(audio.rate)), + }; + bink.sound = try soundOf(gpa, driver, rate, audio.channels, pcm.items); + return bink; + } + /// The first audio track's sound, decoded whole, as a stream of `driver`; none where it has /// none, or the driver has no stream left. fn openSound(gpa: Allocator, codec: Codec, movie: container.Movie, driver: mss.Driver) Error!?Sound { @@ -399,8 +501,14 @@ pub const Bink = struct { // A packet of fewer than 4 bytes holds no samples. if (packet.len >= 4) try codec.samples(audio, packet, gpa, &pcm); } - if (pcm.items.len == 0) return null; - const file = try wave.pcm16(gpa, track.rate, channels, pcm.items); + return soundOf(gpa, driver, track.rate, channels, pcm.items); + } + + /// The samples `pcm`, at `rate` and with `channels`, as a stream of `driver`; none where there + /// are none, or the driver has no stream left. + fn soundOf(gpa: Allocator, driver: mss.Driver, rate: u32, channels: u8, pcm: []const i16) Allocator.Error!?Sound { + if (pcm.len == 0) return null; + const file = try wave.pcm16(gpa, rate, channels, pcm); const stream = driver.openStream(file) orelse { gpa.free(file); return null; @@ -417,6 +525,10 @@ pub const Bink = struct { bink.gpa.free(sound.file); } if (bink.deblocking) |buffer| bink.gpa.free(buffer); + switch (bink.source) { + .bink => {}, + .matroska => |*movie| movie.deinit(bink.gpa), + } bink.codec.close(bink.video); bink.gpa.free(bink.file); bink.* = undefined; @@ -429,8 +541,8 @@ pub const Bink = struct { bink.start = now; if (bink.sound) |sound| sound.driver.startStream(sound.stream); } - const frame = try bink.movie.frame(bink.frame_number - 1); - bink.picture = try bink.codec.picture(bink.video, frame.video); + const frame = try bink.source.frame(bink.frame_number - 1); + bink.picture = try bink.codec.picture(bink.video, frame.packet); } /// `BinkCopyToBuffer`, in 32-bit RGBA: the last frame decoded into `rgba`, `pitch` bytes to a @@ -474,14 +586,14 @@ pub const Bink = struct { if (next > 1) { const before = next - 2; var from = before; - while (from > 0 and !(try bink.movie.frame(from)).keyframe) from -= 1; + while (from > 0 and !(try bink.source.frame(from)).keyframe) from -= 1; for (from..before + 1) |index| { - const frame = try bink.movie.frame(index); - bink.picture = try bink.codec.picture(bink.video, frame.video); + const frame = try bink.source.frame(index); + bink.picture = try bink.codec.picture(bink.video, frame.packet); } } bink.frame_number = next; - bink.start = (now + bink.rate.due(1)) -| bink.rate.due(next - 1); + bink.start = (now + bink.due(1)) -| bink.due(next - 1); } /// `BinkWait`: whether the frame `frame_number` names is not yet due at `now`, which it never @@ -489,7 +601,15 @@ pub const Bink = struct { pub fn wait(bink: Bink, now: u64) bool { if (bink.paused_at != null) return true; const start = bink.start orelse return false; - return now < start + bink.rate.due(bink.frame_number - 1); + return now < start + bink.due(bink.frame_number - 1); + } + + /// When frame `number`, from 0, is due after the first, in nanoseconds: by its rate, or a + /// mod's movie's frame at its own time. + fn due(bink: Bink, number: u32) u64 { + if (bink.rate) |rate| return rate.due(number); + const frames = bink.source.matroska.frames; + return std.math.cast(u64, frames[number].time - frames[0].time) orelse 0; } /// `BinkPause`: stops the movie at `now`, its sound too, or plays it on from where it stopped. @@ -531,10 +651,11 @@ pub const Bink = struct { /// What the tests of Bink and of what plays it share. pub const testing = struct { /// A codec for the tests: each picture the movie's size, at most 8 by 6, and grey, its level - /// of Y 16 more than 16 times the video packet's first byte (in `container.testing.movie`, its - /// frame's number); each audio packet's bytes past the size, an MP3 frame's past its header, a - /// FLAC file's past its marker, or an Ogg packet's bytes, as samples. It counts the pictures it - /// makes, and the streams open. + /// of Y 16 more than 16 times the low four bits of the video packet's first byte (in + /// `container.testing.movie`, its frame's number); each audio packet's bytes past the size, an + /// MP3 frame's past its header, a FLAC file's past its marker, or an Ogg packet's or a mod's + /// movie's audio packet's bytes, as samples. It counts the pictures it makes, and the streams + /// open. pub const Decoders = struct { width: u32 = 0, height: u32 = 0, @@ -564,7 +685,7 @@ pub const testing = struct { } fn openVideo(context: *anyopaque, video: Video) Error!Stream { const test_codec = of(context); - test_codec.width, test_codec.height = .{ video.width, video.height }; + test_codec.width, test_codec.height = video.size(); test_codec.streams += 1; return context; } @@ -586,7 +707,7 @@ pub const testing = struct { fn pictureOf(context: *anyopaque, _: Stream, packet: []const u8) Error!Picture { const test_codec = of(context); test_codec.pictures += 1; - test_codec.y = @splat(@intCast(16 + 16 * @as(u32, packet[0]))); + test_codec.y = @splat(@intCast(16 + 16 * @as(u32, packet[0] & 0x0F))); const half = @divCeil(test_codec.width, 2); return .{ .width = test_codec.width, .height = test_codec.height, .y = &test_codec.y, .u = &test_codec.chroma, .v = &test_codec.chroma, .strides = .{ test_codec.width, half, half, 0 } }; } @@ -656,6 +777,75 @@ test Bink { try std.testing.expect(!bink.wait(later + std.time.ns_per_s / 15)); } +test "a mod's movie in Matroska or WebM" { + const gpa = std.testing.allocator; + var decoders: testing.Decoders = .{}; + var mixer: mss.Mixer = .init(22050); + // Three frames of VP9 of profile 0, 8 by 6, a tick of 40 ms apart, each a level 16 above the + // last, and its sound in Opus, whose packets' bytes are its samples. + const opus_head = "OpusHead\x01\x02\x38\x01\x80\xBB\x00\x00\x00\x00\x00"; + const bytes = try matroska.testing.movie(gpa, .{ + .width = 8, + .height = 6, + .frames = &.{ &.{0x80}, &.{0x81}, &.{0x82} }, + .audio = .{ .codec = "A_OPUS", .setup = opus_head, .packets = &.{ &.{ 3, 5 }, &.{ 7, 9 } } }, + }); + var bink: Bink = try .open(gpa, decoders.codec(), bytes, .{ .sound = mixer.driver() }); + defer bink.close(); + try std.testing.expectEqual(3, bink.frames); + try std.testing.expectEqual(8, bink.width); + try std.testing.expect(!bink.look.deblock); + // Its sound at Opus's rate, its four samples two to a channel. + const sound = try wave.Wave.parse(bink.sound.?.file); + try std.testing.expectEqual(ogg.opus_rate, sound.rate); + try std.testing.expectEqual(2, sound.frameCount()); + + // Each frame is due at its own time, 40 ms after the one before. + const start = 1_000; + try bink.doFrame(start); + try std.testing.expectEqual(16, bink.picture.?.y[0]); + bink.nextFrame(); + try std.testing.expect(bink.wait(start + 40 * std.time.ns_per_ms - 1)); + try std.testing.expect(!bink.wait(start + 40 * std.time.ns_per_ms)); + // On to the last frame, decoded from the key frame, the first, up to the one before it. + try bink.goto(3, start + std.time.ns_per_s); + try std.testing.expectEqual(32, bink.picture.?.y[0]); + try bink.doFrame(start + std.time.ns_per_s); + try std.testing.expectEqual(48, bink.picture.?.y[0]); +} + +test "a mod's movie at a rate set in place of its own, and what it can't play" { + const gpa = std.testing.allocator; + var decoders: testing.Decoders = .{}; + const frames: []const []const u8 = &.{ &.{0x80}, &.{0x81} }; + // At 15 frames a second, as the rooms play their movies, whatever its frames' times. + var paced: Bink = try .open(gpa, decoders.codec(), try matroska.testing.movie(gpa, .{ .frames = frames }), .{ .rate = .{ .frames = 15, .seconds = 1 } }); + defer paced.close(); + try paced.doFrame(0); + paced.nextFrame(); + try std.testing.expect(paced.wait(std.time.ns_per_s / 15 - 1)); + try std.testing.expect(!paced.wait(std.time.ns_per_s / 15)); + // Sound it can't decode is left out, and named. + var silent: Bink = try .open(gpa, decoders.codec(), try matroska.testing.movie(gpa, .{ + .frames = frames, + .audio = .{ .codec = "A_AAC", .setup = "", .packets = &.{"aac"} }, + }), .{}); + defer silent.close(); + try std.testing.expectEqualStrings("A_AAC", silent.silent.?); + // Video other than VP9, or of another profile, isn't played. + try std.testing.expectError(error.UnsupportedVideo, Bink.open(gpa, decoders.codec(), try matroska.testing.movie(gpa, .{ .video_codec = "V_AV1", .frames = frames }), .{})); + try std.testing.expectError(error.UnsupportedVideo, Bink.open(gpa, decoders.codec(), try matroska.testing.movie(gpa, .{ .frames = &.{&.{0x90}} }), .{})); + try std.testing.expectEqual(2, decoders.streams); +} + +test vp9Profile { + try std.testing.expectEqual(0, vp9Profile(&.{0x82}).?); + try std.testing.expectEqual(1, vp9Profile(&.{0xA2}).?); + try std.testing.expectEqual(2, vp9Profile(&.{0x92}).?); + try std.testing.expectEqual(null, vp9Profile(&.{0x42})); + try std.testing.expectEqual(null, vp9Profile(&.{})); +} + test "a movie's sound plays as a stream" { const gpa = std.testing.allocator; var decoders: testing.Decoders = .{}; diff --git a/src/engine/bink/picture.zig b/src/engine/bink/picture.zig index 479395b3..01683dc9 100644 --- a/src/engine/bink/picture.zig +++ b/src/engine/bink/picture.zig @@ -1,13 +1,13 @@ //! A movie's decoded pictures, and how `BinkCopyToBuffer` turns one into colours //! (`bink.Bink.copyToBuffer`): each level of Y with the U and V of where it stands, by BT.601 in -//! its limited range. OpenReliant's improvements on the way (`Look`) are each left out under -//! `--original`. +//! its limited range, or as a mod's movie says (`Colour`). OpenReliant's improvements on the way +//! (`Look`) are each left out under `--original`. const std = @import("std"); /// A decoded picture: planes of levels, `y` at the picture's size, `u` and `v` at half its size /// across and down, and `alpha` at its size where the movie has one, each `strides` bytes to a -/// row. The levels are BT.601's, in its limited range. +/// row. Its levels make colours as `colour` says. pub const Picture = struct { width: u32, height: u32, @@ -16,8 +16,76 @@ pub const Picture = struct { v: []const u8, alpha: ?[]const u8 = null, strides: [4]usize, + colour: Colour = .{}, }; +/// How a picture's levels of Y, U and V make colours: the matrix of BT.601 or BT.709, and the +/// limited range of levels, 16 to 235 for Y and 16 to 240 for U and V, or the full range. A Bink +/// movie's are BT.601's in the limited range; a mod's movie says its own (`bink.Bink`). +pub const Colour = struct { + matrix: Matrix = .bt601, + range: Range = .limited, + + pub const Matrix = enum { bt601, bt709 }; + pub const Range = enum { limited, full }; +}; + +/// The factors that turn levels into a colour, in fixed point, 256ths, as the usual converters +/// have them: the level of black, Y's scale, V's share of red, U's and V's of green, and U's of +/// blue. +const Factors = struct { + black: i32, + y: i32, + red_v: i32, + green_u: i32, + green_v: i32, + blue_u: i32, + + /// The factors of `colour`, from its matrix's weights of red and blue in Y and its range's + /// scales of the levels. + fn of(colour: Colour) Factors { + const red: f64, const blue: f64 = switch (colour.matrix) { + .bt601 => .{ 0.299, 0.114 }, + .bt709 => .{ 0.2126, 0.0722 }, + }; + const green = 1 - red - blue; + const black: i32, const luma: f64, const chroma: f64 = switch (colour.range) { + .limited => .{ 16, 255.0 / 219.0, 255.0 / 224.0 }, + .full => .{ 0, 1, 1 }, + }; + return .{ + .black = black, + .y = fixed(luma), + .red_v = fixed(2 * (1 - red) * chroma), + .green_u = fixed(2 * (1 - blue) * blue / green * chroma), + .green_v = fixed(2 * (1 - red) * red / green * chroma), + .blue_u = fixed(2 * (1 - blue) * chroma), + }; + } + + /// `factor` in 256ths, rounded. + fn fixed(factor: f64) i32 { + return @intFromFloat(@round(factor * 256)); + } + + /// The colour of levels `y`, `u` and `v`. + fn rgb(factors: Factors, y: u8, u: u8, v: u8) [3]u8 { + const c = (@as(i32, y) - factors.black) * factors.y + 128; + const d = @as(i32, u) - 128; + const e = @as(i32, v) - 128; + return .{ + clampLevel((c + factors.red_v * e) >> 8), + clampLevel((c - factors.green_u * d - factors.green_v * e) >> 8), + clampLevel((c + factors.blue_u * d) >> 8), + }; + } +}; + +comptime { + // BT.601's in the limited range are the factors the usual converters, and Bink's, use. + std.debug.assert(std.meta.eql(Factors.of(.{}), Factors{ .black = 16, .y = 298, .red_v = 409, .green_u = 100, .green_v = 208, .blue_u = 516 })); +} + /// The size of each of a picture `width` by `height` pixels' planes, Y, U and V: Y the picture's, /// U and V half of it across and down, rounded up. pub fn planeSizes(width: u32, height: u32) [3][2]usize { @@ -44,6 +112,7 @@ pub const Look = struct { /// `smooth_colour`, blended from the nearest four (`smoothChroma`). Alpha, where the movie has /// none, is full. pub fn convert(picture: Picture, rgba: []u8, pitch: usize, at: [2]usize, smooth_colour: bool) void { + const factors: Factors = .of(picture.colour); const chroma_width, const chroma_height = planeSizes(picture.width, picture.height)[1]; for (0..picture.height) |row| { const out = rgba[(at[1] + row) * pitch + at[0] * 4 ..][0 .. picture.width * 4]; @@ -59,7 +128,7 @@ pub fn convert(picture: Picture, rgba: []u8, pitch: usize, at: [2]usize, smooth_ picture.u[row / 2 * picture.strides[1] + column / 2], picture.v[row / 2 * picture.strides[2] + column / 2], }; - const colour = rgbOf(y[column], u, v); + const colour = factors.rgb(y[column], u, v); out[column * 4 ..][0..4].* = .{ colour[0], colour[1], colour[2], if (alpha) |levels| levels[column] else 0xFF }; } } @@ -87,19 +156,6 @@ fn smoothChroma(plane: []const u8, stride: usize, rows: Pair, columns: Pair) u8 return @intCast((sum + 8) / 16); } -/// The colour of levels `y`, `u` and `v`, by BT.601 in its limited range, in fixed point as the -/// usual converters have it. -pub fn rgbOf(y: u8, u: u8, v: u8) [3]u8 { - const c = (@as(i32, y) - 16) * 298 + 128; - const d = @as(i32, u) - 128; - const e = @as(i32, v) - 128; - return .{ - clampLevel((c + 409 * e) >> 8), - clampLevel((c - 100 * d - 208 * e) >> 8), - clampLevel((c + 516 * d) >> 8), - }; -} - fn clampLevel(level: i32) u8 { return @intCast(std.math.clamp(level, 0, 0xFF)); } @@ -151,13 +207,21 @@ fn smooth(plane: []u8, at: usize, apart: usize) void { } } -test rgbOf { +test Factors { + const bt601: Factors = .of(.{}); // Limited range: 16 is black, 235 white, and the middle of U and V grey. - try std.testing.expectEqual([3]u8{ 0, 0, 0 }, rgbOf(16, 128, 128)); - try std.testing.expectEqual([3]u8{ 255, 255, 255 }, rgbOf(235, 128, 128)); - try std.testing.expectEqual([3]u8{ 128, 128, 128 }, rgbOf(126, 128, 128)); - // Red, from its levels. - try std.testing.expectEqual([3]u8{ 255, 0, 0 }, rgbOf(81, 90, 240)); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, bt601.rgb(16, 128, 128)); + try std.testing.expectEqual([3]u8{ 255, 255, 255 }, bt601.rgb(235, 128, 128)); + try std.testing.expectEqual([3]u8{ 128, 128, 128 }, bt601.rgb(126, 128, 128)); + // Red, from BT.601's levels, and within a level of it from BT.709's, which BT.601 takes for a + // darker red. + try std.testing.expectEqual([3]u8{ 255, 0, 0 }, bt601.rgb(81, 90, 240)); + try std.testing.expectEqual([3]u8{ 255, 1, 0 }, Factors.of(.{ .matrix = .bt709 }).rgb(63, 102, 240)); + try std.testing.expectEqual([3]u8{ 234, 0, 2 }, bt601.rgb(63, 102, 240)); + // Full range: 0 is black and 255 white. + const full: Factors = .of(.{ .range = .full }); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, full.rgb(0, 128, 128)); + try std.testing.expectEqual([3]u8{ 255, 255, 255 }, full.rgb(255, 128, 128)); } test convert { @@ -177,9 +241,10 @@ test convert { convert(picture, &rgba, 16, .{ 0, 0 }, true); const blue = [4]u8{ rgba[2], rgba[4 + 2], rgba[8 + 2], rgba[12 + 2] }; try std.testing.expect(blue[0] < blue[1] and blue[1] < blue[2] and blue[2] < blue[3]); - try std.testing.expectEqual(rgbOf(126, 128, 128)[2], blue[0]); - try std.testing.expectEqual(rgbOf(126, 136, 128)[2], blue[1]); - try std.testing.expectEqual(rgbOf(126, 152, 128)[2], blue[2]); + const bt601: Factors = .of(.{}); + try std.testing.expectEqual(bt601.rgb(126, 128, 128)[2], blue[0]); + try std.testing.expectEqual(bt601.rgb(126, 136, 128)[2], blue[1]); + try std.testing.expectEqual(bt601.rgb(126, 152, 128)[2], blue[2]); } test deblock { diff --git a/src/engine/files.zig b/src/engine/files.zig index 7a355fa8..4dc284bf 100644 --- a/src/engine/files.zig +++ b/src/engine/files.zig @@ -79,6 +79,17 @@ pub fn readFile(io: Io, gpa: Allocator, dir: Io.Dir, path: []const u8, limit: Io return try dir.readFileAlloc(io, spelled, gpa, limit); } +/// Reads the start of the game's file `path` under `dir`, found as `readFile` finds it, into +/// `buffer`: as much as fits, or the whole file where it is shorter. Null where there's no such +/// file. +pub fn readHead(io: Io, dir: Io.Dir, path: []const u8, buffer: []u8) (Io.File.OpenError || Io.File.ReadPositionalError)!?[]u8 { + var name_buffer: [max_path]u8 = undefined; + const spelled = find(io, dir, path, &name_buffer) orelse return null; + const file = try dir.openFile(io, spelled, .{}); + defer file.close(io); + return buffer[0..try file.readPositionalAll(io, buffer, 0)]; +} + /// Writes `data` to the game's file `path` under `dir`, as Windows does: over the file `find` finds, /// whatever the case of its name, or else as a new file named by `path`'s last name in the folder /// `find` finds. error.FileNotFound when that folder isn't there. The file is written safely diff --git a/src/engine/game/bigfile/mods.zig b/src/engine/game/bigfile/mods.zig index 9ae86159..1d82a93f 100644 --- a/src/engine/game/bigfile/mods.zig +++ b/src/engine/game/bigfile/mods.zig @@ -24,6 +24,7 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const Io = std.Io; +const bink = @import("../../../formats/bink.zig"); const checksums = @import("../../../formats/checksums.zig"); const fat = @import("../../../formats/fat.zig"); const fnt = @import("../../../formats/fnt.zig"); @@ -39,6 +40,7 @@ const srtexture = @import("../../surrender/surrenderlib/srtexture.zig"); const bigfile = @import("../bigfile.zig"); const cbox = @import("../cbox.zig"); const recordings = @import("../recordings.zig"); +const movie = @import("../xtrabits/movie.zig"); const order_module = @import("order.zig"); /// The order the mods load in and which are on (`Mods.openOrdered`). @@ -302,6 +304,15 @@ pub const Mod = struct { return null; } + /// Reads the start of file number `file` as stored into `buffer`: as much as fits, or the + /// whole file where it is shorter. + fn readHead(mod: Mod, file: usize, buffer: []u8) bigfile.ReadError![]u8 { + return switch (mod.source) { + .archive => |archive| try archive.readHead(archive.entries[file], buffer), + .folder => |folder| try folder.readHead(folder.names[file], buffer), + }; + } + /// Reads file number `file` as `reading` says. fn read(mod: Mod, gpa: Allocator, file: usize, reading: Reading) bigfile.ReadError![]u8 { switch (mod.source) { @@ -442,6 +453,14 @@ pub const Folder = struct { folder.dir.close(folder.io); } + /// Reads the start of its file `name` into `buffer`: as much as fits, or the whole file where + /// it is shorter. + fn readHead(folder: Folder, name: []const u8, buffer: []u8) bigfile.ReadError![]u8 { + const file = try folder.dir.openFile(folder.io, name, .{}); + defer file.close(folder.io); + return buffer[0..try file.readPositionalAll(folder.io, buffer, 0)]; + } + /// Reads its file `name` as `reading` says. Decompressing reads it the same way as the member /// `hog.packMember` would make of it: RefPack data that the game would decompress, such as a /// member extracted with `--raw`, is decompressed, and any other file is read as it is. @@ -607,6 +626,21 @@ pub const Mods = struct { return try mods.list[found.mod].read(gpa, found.file, .stored); } + /// Reads the start of the mod file `name` as `readStored` reads it, into `buffer`: as much as + /// fits, or the whole file where it is shorter. Null if no mod has it. + pub fn readHead(mods: *const Mods, name: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { + const found = mods.place(name) orelse return null; + return try mods.list[found.mod].readHead(found.file, buffer); + } + + /// Reads the start of the game's loose file `path`, as `readLoose` finds it, into `buffer`: as + /// much as fits, or the whole file where it is shorter. Null if neither a mod nor the game + /// folder has it. + pub fn readLooseHead(mods: *const Mods, io: Io, dir: Io.Dir, path: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { + if (try mods.readHead(files.leaf(path), buffer)) |head| return head; + return files.readHead(io, dir, path, buffer); + } + /// Reads the mod file that replaces the game's loose file `path`: the file with the same name /// (the part of the path after the last `\` or `/`) from the last mod that has it, as /// `readFile` reads it. Null if no mod has it. @@ -688,6 +722,7 @@ pub const Mods = struct { .line => |line| log.info("{s} replaces the line {s}", .{ mod.name, line }), .sound => |sound| log.info("{s} replaces sound {d} of the sound bank {s}", .{ mod.name, sound.index, sound.file }), .recording => |replaced| log.info("{s} replaces {s}{s}", .{ mod.name, replaced.stem, replaced.extension }), + .movie => |replaced| log.info("{s} replaces the movie {s}" ++ bink.extension, .{ mod.name, replaced }), .added => log.info("{s} adds {s}", .{ mod.name, name }), }; } @@ -770,6 +805,9 @@ const Effect = union(enum) { /// It's a recording (`recordings.Source.find`) that replaces a game file of another format: a /// piece of music or one of Enriquez's scenes. recording: GameFiles.Replaced, + /// It's a movie in WebM or Matroska (`movie.modern_extensions`) that replaces the game's Bink + /// movie with its name, given without the extension. + movie: []const u8, /// It adds a new file. added, }; @@ -789,6 +827,7 @@ fn effectOf(list: []const Mod, at: usize, own: GameFiles, name: []const u8) Effe if (own.lineOf(name)) |line| return .{ .line = line }; if (own.soundOf(name)) |sound| return .{ .sound = sound }; if (own.recordingOf(name)) |replaced| return .{ .recording = replaced }; + if (own.movieOf(name)) |replaced| return .{ .movie = replaced }; return .added; } @@ -982,6 +1021,19 @@ const GameFiles = struct { return null; } + /// The name, without the extension, of the game's Bink movie that the mod's movie `name`, + /// `.webm` or `.mkv` (`movie.modern_extensions`), replaces; null if it doesn't + /// replace one. + fn movieOf(gathered: GameFiles, name: []const u8) ?[]const u8 { + for (movie.modern_extensions) |extension| { + const stem = withoutSuffix(name, extension) orelse continue; + var buffer: [files.max_path]u8 = undefined; + const replaced = std.mem.print(&buffer, "{s}" ++ bink.extension, .{stem}) catch return null; + if (gathered.kindOf(replaced) == .file) return stem; + } + return null; + } + /// The file `name` without the extension of a mod's recording (`recordings.Kind`); null if it /// has another. fn recordingStem(name: []const u8) ?[]const u8 { @@ -1594,7 +1646,7 @@ test effectOf { // Two mods: `a`, and `b`, which loads after it. try tmp.dir.createDirPath(io, "mods/a"); try tmp.dir.createDirPath(io, "mods/b"); - for ([_][]const u8{ "mods/a/hull.tga", "mods/b/hull.tga", "mods/b/ship.shp", "mods/b/yank_2.png", "mods/b/yank_2_normal.png", "mods/b/hudhard_021.png", "mods/b/back.png", "mods/b/logo.tga", "mods/b/OPTFNT.ttf", "mods/b/abrt_001.ut", "mods/b/abrt_009.ut", "mods/b/abrt_001.flac", "mods/b/theme.flac", "mods/b/theme.wav", "mods/b/0015.mp3", "mods/b/new.flac", "mods/b/stdsmp_012.wav", "mods/b/STDSMP_007.flac", "mods/b/stdsmp_12.wav" }) |path| { + for ([_][]const u8{ "mods/a/hull.tga", "mods/b/hull.tga", "mods/b/ship.shp", "mods/b/yank_2.png", "mods/b/yank_2_normal.png", "mods/b/hudhard_021.png", "mods/b/back.png", "mods/b/logo.tga", "mods/b/OPTFNT.ttf", "mods/b/abrt_001.ut", "mods/b/abrt_009.ut", "mods/b/abrt_001.flac", "mods/b/theme.flac", "mods/b/theme.wav", "mods/b/0015.mp3", "mods/b/new.flac", "mods/b/stdsmp_012.wav", "mods/b/STDSMP_007.flac", "mods/b/stdsmp_12.wav", "mods/b/warty_.webm", "mods/b/R_VR.MKV", "mods/b/new.webm" }) |path| { try tmp.dir.writeFile(io, .{ .sub_path = path, .data = path }); } var mods: Mods = try .open(gpa, io, tmp.dir, null); @@ -1610,6 +1662,8 @@ test effectOf { try own.add(gpa, "theme.wav", .file); try own.add(gpa, "0015.box", .file); try own.add(gpa, "stdsmp.fat", .file); + try own.add(gpa, "warty_.bik", .file); + try own.add(gpa, "r_vr.bik", .file); // An earlier mod's file, a game file, a texture and one of its maps, a sprite set shape, a // picture, an outline font, a line with the extension, and new files. @@ -1639,6 +1693,10 @@ test effectOf { try std.testing.expectEqual(12, sound.index); try std.testing.expectEqual(7, effectOf(mods.list, 1, own, "STDSMP_007.flac").sound.index); try std.testing.expectEqual(.added, effectOf(mods.list, 1, own, "stdsmp_12.wav")); + // Movies in WebM and Matroska in place of the game's Bink movies, and a new one. + try std.testing.expectEqualStrings("warty_", effectOf(mods.list, 1, own, "warty_.webm").movie); + try std.testing.expectEqualStrings("R_VR", effectOf(mods.list, 1, own, "R_VR.MKV").movie); + try std.testing.expectEqual(.added, effectOf(mods.list, 1, own, "new.webm")); // The first mod has no earlier mod's files to replace. try std.testing.expectEqual(.added, effectOf(mods.list, 0, own, "hull.tga")); } diff --git a/src/engine/game/interface/disc.zig b/src/engine/game/interface/disc.zig index e18c4248..c7d9b21d 100644 --- a/src/engine/game/interface/disc.zig +++ b/src/engine/game/interface/disc.zig @@ -83,6 +83,16 @@ pub const Disc = struct { return hog.readStored(gpa, name); } + /// Reads the start of the member `name`, as `readStored` finds it, into `buffer`: as much as + /// fits, or the whole member where it is shorter. Null if neither a mod nor the open archive + /// has it. + pub fn readStoredHead(disc: Disc, name: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { + if (try disc.mods.readHead(name, buffer)) |head| return head; + const hog = disc.hog orelse return null; + const entry = hog.archive.find(name) orelse return null; + return try hog.archive.readHead(entry, buffer); + } + /// Reads the file that `name` names in the open archive, decompressing RefPack data /// (`hog_read_file` on `cd_hog`), with a mod's file taking priority; null if neither has it. pub fn readFile(disc: Disc, gpa: Allocator, name: []const u8) bigfile.ReadError!?[]u8 { @@ -161,4 +171,11 @@ test "a mod's movies replace the CD archives' movies" { const other = (try disc.readStored(gpa, "r_h_tb.bik")).?; defer gpa.free(other); try std.testing.expectEqualStrings("the disc's other", other); + // The start of each alone, as much as fits, or a whole file that's shorter. + var head: [5]u8 = undefined; + try std.testing.expectEqualStrings("a mod", (try disc.readStoredHead("r_h_ta.bik", &head)).?); + try std.testing.expectEqualStrings("the d", (try disc.readStoredHead("R_H_TB.BIK", &head)).?); + var long: [64]u8 = undefined; + try std.testing.expectEqualStrings("a mod's", (try disc.readStoredHead("r_h_ta.bik", &long)).?); + try std.testing.expectEqual(null, try disc.readStoredHead("y_h_ta.bik", &head)); } diff --git a/src/engine/game/interface/rooms.zig b/src/engine/game/interface/rooms.zig index 7ba51a81..5b130aeb 100644 --- a/src/engine/game/interface/rooms.zig +++ b/src/engine/game/interface/rooms.zig @@ -565,9 +565,11 @@ pub const Film = struct { if (film.player) |*player| player.draw(target.target, target.window, .screen); } - /// Its frame, at its size with its top left corner at `at` on the front end's screen. + /// Its frame, at its size with its top left corner at `at` on the front end's screen: a mod's + /// movie at the size of the movie it replaces (`movie.Player.size`). pub fn drawAt(film: *Film, target: canvas.Canvas, at: [2]i32) void { - if (film.player) |*player| target.image(&player.picture, at); + const player = &(film.player orelse return); + target.imageOver(&player.picture, at, .{ @intCast(player.size[0]), @intCast(player.size[1]) }); } }; diff --git a/src/engine/game/xtrabits/movie.zig b/src/engine/game/xtrabits/movie.zig index e61ca8e7..08cab21d 100644 --- a/src/engine/game/xtrabits/movie.zig +++ b/src/engine/game/xtrabits/movie.zig @@ -16,6 +16,10 @@ //! //! **Improvement:** OpenReliant draws a movie as large as fits in the window, as it draws the front //! end, so that a 16:9 movie fills a wide window (`Size`). +//! +//! **Improvement:** a mod can give a movie in WebM or Matroska, of VP9 video with Opus or Vorbis +//! sound, in place of a Bink movie (`Player.load`, +//! [#1179](https://github.com/OpenReliant/openreliant/issues/1179)). const std = @import("std"); const Allocator = std.mem.Allocator; @@ -25,12 +29,14 @@ const files = @import("../../files.zig"); const input = @import("../../input.zig"); const mss = @import("../../mss.zig"); const hud = @import("../hud.zig"); +const bigfile = @import("../bigfile.zig"); const canvas = @import("../interface/canvas.zig"); const disc = @import("../interface/disc.zig"); const rooms = @import("../interface/rooms.zig"); const srtexture = @import("../../surrender/surrenderlib/srtexture.zig"); const device = @import("../../surrender/srd3d/device.zig"); const container = @import("../../../formats/bink.zig"); +const matroska = @import("../../../formats/matroska.zig"); const log = std.log.scoped(.movies); @@ -49,6 +55,11 @@ pub const main_to_single = "interface\\main2sin.bik"; pub const single_to_main = "interface\\sin2main.bik"; pub const main_to_options = "interface\\main2opt.bik"; +/// The extensions of a mod's movie in a format other than Bink, in the order they are looked for +/// (`Player.load`): WebM, then Matroska. A mod's Bink file of the same name comes after them, then +/// the game's own. +pub const modern_extensions = [_][]const u8{ matroska.webm_extension, matroska.extension }; + /// A movie to play in a loop of its own: its name, and how it plays. pub const Named = struct { name: []const u8, kind: Kind }; @@ -225,6 +236,10 @@ pub const Player = struct { /// The pointer's right button, which ends the movie only once it has come up since the movie /// began (`pass`). right: input.FreshPress = .{}, + /// The size it is drawn at on the front end's screen of 640 by 480 (`draw`): its own, or for a + /// mod's movie in another format, the size of the game's movie it replaces (`load`), so that a + /// sharper movie is drawn in the same place. + size: [2]u32, /// Opens the movie of `file`, which it takes, to play as `kind` has it, its sound through /// `sound` where there is one, and its pictures with OpenReliant's `look`. @@ -238,15 +253,21 @@ pub const Player = struct { return err; }; picture.magnify = .edge_adaptive; - return .{ .gpa = gpa, .kind = kind, .bink = movie, .picture = picture, .pixels = rgba }; + return .{ .gpa = gpa, .kind = kind, .bink = movie, .picture = picture, .pixels = rgba, .size = .{ movie.width, movie.height } }; } /// Reads the movie `name` from where `kind` says, the game folder or the open CD archive /// (`archive`, in the game folder), with a mod file of the same name taking priority /// (`bigfile.Mods`), and opens it to play as `open` does; null if it can't be read or opened, /// which the log says. + /// + /// **Improvement:** a mod's movie in WebM or Matroska with the same name, `name.webm` or + /// `name.mkv`, takes priority over a Bink file (`modern_extensions`). It is drawn where the + /// movie it replaces would be, at that movie's size (`size`). One that can't be played is left + /// out, which the log says, and the Bink file plays. pub fn load(gpa: Allocator, codec: bink.Codec, archive: *const disc.Disc, name: []const u8, kind: Kind, sound: ?mss.Driver, look: bink.Look) ?Player { const source = kind.source(); + if (loadModern(gpa, codec, archive, name, kind, sound, look)) |player| return player; const found = switch (source) { .folder => archive.mods.readLoose(archive.io, gpa, archive.directory, name, .limited(files.max_file_size)), .disc => archive.readStored(gpa, name), @@ -267,6 +288,56 @@ pub const Player = struct { }; } + /// A mod's movie in a format other than Bink that replaces the movie `name`, the first that a + /// mod has (`modern_extensions`), opened to play as `open` does, at the size of the movie it + /// replaces where the game has it (`load`); null where no mod has one, or it can't be read or + /// played, which the log says. Unlike a Bink movie, which Bink reads as it is stored, it is + /// read as other mod files are, expanded where a mod's archive holds it compressed. + fn loadModern(gpa: Allocator, codec: bink.Codec, archive: *const disc.Disc, name: []const u8, kind: Kind, sound: ?mss.Driver, look: bink.Look) ?Player { + var buffer: [files.max_path]u8 = undefined; + for (modern_extensions) |extension| { + // A name longer than any path the game builds is in no mod (`bigfile.Mods`). + const modern = std.mem.print(&buffer, "{s}{s}", .{ files.stem(name), extension }) catch return null; + const file = archive.mods.readFile(gpa, modern) catch |err| { + log.warn("the movie {s} is left out: {s}", .{ modern, @errorName(err) }); + continue; + } orelse continue; + var player = open(gpa, codec, file, kind, sound, look) catch |err| { + log.warn("the movie {s} is left out: {s}", .{ modern, problem(err) }); + continue; + }; + if (player.bink.silent) |unplayed| log.warn("the movie {s} plays without its sound: it is {s}, and OpenReliant plays Opus and Vorbis", .{ modern, unplayed }); + if (replacedSize(archive, name, kind.source())) |size| player.size = size; + return player; + } + return null; + } + + /// The size of the movie `name` as `load` reads it from `source` without a mod's movie in + /// another format, from its header alone; null where it has none, or its header can't be read. + fn replacedSize(archive: *const disc.Disc, name: []const u8, source: Source) ?[2]u32 { + var buffer: [@sizeOf(container.Header)]u8 = undefined; + const head = (switch (source) { + .folder => archive.mods.readLooseHead(archive.io, archive.directory, name, &buffer), + .disc => archive.readStoredHead(name, &buffer), + } catch return null) orelse return null; + const header = container.readHeader(head) catch return null; + return .{ header.width, header.height }; + } + + /// What the log says of an error opening a mod's movie in another format. + fn problem(err: bink.Error) []const u8 { + return switch (err) { + error.UnsupportedVideo => "its video isn't VP9 of profile 0, 8 bits with 4:2:0 colour", + error.NotMatroska => "it isn't a Matroska or WebM file", + error.NoVideo => "it has no video", + error.NoFrames => "its video has no frames, or doesn't start with a key frame", + error.Encoded => "its tracks are compressed or encrypted", + error.BadElement => "it is damaged", + else => @errorName(err), + }; + } + pub fn close(player: *Player) void { player.picture.deinit(player.gpa); player.bink.close(); @@ -309,12 +380,12 @@ pub const Player = struct { /// Draws the frame showing on `target`, a window `window` pixels across and down, as large as /// `how` has it, in the middle. pub fn draw(player: *Player, target: device.Device, window: [2]u32, how: Size) void { - const size: [2]u32 = .{ player.bink.width, player.bink.height }; + const size = player.size; const scale = switch (how) { .fitted => hud.fit(window, size), .screen => canvas.scaleFor(window), }; - hud.drawImage(target, &player.picture, hud.centred(window, size, scale), .{ 1, 1, 1, 1 }, scale, .{}); + hud.drawImageAs(target, &player.picture, size, hud.centred(window, size, scale), .{ 1, 1, 1, 1 }, scale, .{}); } }; @@ -337,6 +408,62 @@ test "a movie plays its frames as each falls due, and ends" { try std.testing.expectEqual(End.finished, try player.pass(&keyboard, false, std.time.ns_per_s)); } +test "a mod's movie in WebM or Matroska plays in a Bink movie's place" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + // The game's movies, 8 by 6: two in its folder, and one in the second disc's archive. + var buffer: [256]u8 = undefined; + const game_movie = container.testing.movie(&buffer, 2, &.{}); + try tmp.dir.writeFile(io, .{ .sub_path = "intro.bik", .data = game_movie }); + try tmp.dir.writeFile(io, .{ .sub_path = "broken.bik", .data = game_movie }); + try bigfile.testing.write(gpa, io, tmp.dir, "CD2.HOG", &.{.{ .name = "r_vr.bik", .data = game_movie }}); + // A mod's: the folder's intro in WebM, the disc's movie in WebM and in Matroska, one in AV1, + // which OpenReliant doesn't play, and one that replaces no movie of the game's. + try tmp.dir.createDirPath(io, "mods/movies"); + const frames: []const []const u8 = &.{ &.{0x80}, &.{0x81} }; + const files_made = [_]struct { []const u8, matroska.testing.Spec }{ + .{ "intro.webm", .{ .width = 16, .height = 12, .frames = frames } }, + .{ "r_vr.webm", .{ .width = 16, .height = 12, .frames = frames } }, + .{ "r_vr.mkv", .{ .doc_type = "matroska", .width = 32, .height = 24, .frames = frames } }, + .{ "broken.webm", .{ .video_codec = "V_AV1", .frames = frames } }, + .{ "new.webm", .{ .width = 16, .height = 12, .frames = frames } }, + }; + for (files_made) |made| { + const name, const spec = made; + const bytes = try matroska.testing.movie(gpa, spec); + defer gpa.free(bytes); + var path: [64]u8 = undefined; + try tmp.dir.writeFile(io, .{ .sub_path = try std.mem.print(&path, "mods/movies/{s}", .{name}), .data = bytes }); + } + var mods: bigfile.Mods = try .open(gpa, io, tmp.dir, null); + defer mods.close(gpa); + var archive: disc.Disc = .{ .gpa = gpa, .io = io, .directory = tmp.dir, .mods = &mods }; + defer archive.close(); + archive.open(.two); + var codec: bink.testing.Decoders = .{}; + + const Case = struct { name: []const u8, kind: Kind, pictures: [2]u32, size: [2]u32 }; + const cases = [_]Case{ + // In WebM in place of the folder's movie, drawn at its size. + .{ .name = "intro.bik", .kind = .cleared, .pictures = .{ 16, 12 }, .size = .{ 8, 6 } }, + // The WebM file before the Matroska one, in place of the disc's movie. + .{ .name = "r_vr.bik", .kind = .screen, .pictures = .{ 16, 12 }, .size = .{ 8, 6 } }, + // A movie it can't play is left out, and the game's plays. + .{ .name = "broken.bik", .kind = .cleared, .pictures = .{ 8, 6 }, .size = .{ 8, 6 } }, + // One that replaces none of the game's is drawn at its own size. + .{ .name = "new.bik", .kind = .briefing, .pictures = .{ 16, 12 }, .size = .{ 16, 12 } }, + }; + for (cases) |case| { + var player = Player.load(gpa, codec.codec(), &archive, case.name, case.kind, null, .{}).?; + defer player.close(); + try std.testing.expectEqual(case.pictures, [2]u32{ player.bink.width, player.bink.height }); + try std.testing.expectEqual(case.size, player.size); + } + try std.testing.expectEqual(0, codec.streams); +} + test "Escape and the right button end a movie" { const gpa = std.testing.allocator; var buffer: [256]u8 = undefined; @@ -365,7 +492,7 @@ test "the landing plays on with the right button down" { try std.testing.expectEqual(null, try player.pass(&keyboard, true, 0)); try std.testing.expectEqual(1, codec.pictures); // At 15 frames a second, whatever the movie's rate. - try std.testing.expectEqual(transition_rate, player.bink.rate); + try std.testing.expectEqual(transition_rate, player.bink.rate.?); keyboard.down[input.scan.escape] = true; try std.testing.expectEqual(End.skipped, try player.pass(&keyboard, true, 0)); } diff --git a/src/formats/bink.zig b/src/formats/bink.zig index d5692f67..243e362a 100644 --- a/src/formats/bink.zig +++ b/src/formats/bink.zig @@ -11,6 +11,8 @@ const assert = std.debug.assert; const layout = @import("layout.zig"); +pub const extension = ".bik"; + pub const Error = error{ NotABink, Truncated, BadRate, TooManyTracks, BadIndex, BadFrame }; /// The header's fixed part. @@ -38,6 +40,13 @@ pub const Header = extern struct { } }; +/// The header at the start of `bytes`, which must start as a Bink file does. +pub fn readHeader(bytes: []const u8) Error!*align(1) const Header { + const header = try layout.view(Header, bytes); + if (!std.mem.eql(u8, &header.signature, "BIK")) return error.NotABink; + return header; +} + /// The video's flags, which the video decoder takes as its extra data. pub const VideoFlags = packed struct(u32) { _unknown_0: u17 = 0, @@ -79,8 +88,7 @@ pub const Movie = struct { index: []align(1) const u32, pub fn parse(bytes: []const u8) Error!Movie { - const header = try layout.view(Header, bytes); - if (!std.mem.eql(u8, &header.signature, "BIK")) return error.NotABink; + const header = try readHeader(bytes); if (header.rate == 0 or header.rate_divisor == 0) return error.BadRate; if (header.audio_tracks > max_audio_tracks) return error.TooManyTracks; const tracks_at = @sizeOf(Header) + header.audio_tracks * @sizeOf(u32); diff --git a/src/formats/hog.zig b/src/formats/hog.zig index b464527c..3c3c8979 100644 --- a/src/formats/hog.zig +++ b/src/formats/hog.zig @@ -158,13 +158,20 @@ pub const Archive = struct { /// (`0x004C81F0`) reads it; null for a member the game reads as it is stored (one that doesn't /// start `10 FB`), or one too short for its header. pub fn expandedSize(archive: Archive, entry: Entry) !?u32 { - var head: [refpack.max_header_len]u8 = undefined; - const n = try archive.file.readPositionalAll(archive.io, head[0..@min(entry.size, head.len)], entry.offset); - if (!refpack.gameExpands(head[0..n])) return null; - const header = refpack.readHeader(head[0..n]) catch return null; + var buffer: [refpack.max_header_len]u8 = undefined; + const head = try archive.readHead(entry, &buffer); + if (!refpack.gameExpands(head)) return null; + const header = refpack.readHeader(head) catch return null; return header.decompressed_size; } + /// Reads the start of a member as stored into `buffer`: as much as fits, or the whole member + /// where it is shorter. + pub fn readHead(archive: Archive, entry: Entry, buffer: []u8) ![]u8 { + const head = buffer[0..@min(buffer.len, entry.size)]; + return head[0..try archive.file.readPositionalAll(archive.io, head, entry.offset)]; + } + pub fn find(archive: Archive, name: []const u8) ?Entry { for (archive.entries) |entry| { if (std.ascii.eqlIgnoreCase(entry.name, name)) return entry; diff --git a/src/formats/matroska.zig b/src/formats/matroska.zig new file mode 100644 index 00000000..519b8892 --- /dev/null +++ b/src/formats/matroska.zig @@ -0,0 +1,765 @@ +//! Matroska and WebM files, in which mods give movies in place of the game's Bink files +//! ([#1179](https://github.com/OpenReliant/openreliant/issues/1179)). WebM files are Matroska files +//! that hold only a few codecs. A file is a tree of EBML elements (RFC 8794): each has an ID, the +//! size of its data, and its data, which holds a value or more elements. The file's segment holds +//! its tracks and its clusters, and each cluster holds blocks of the tracks' frames, each block +//! with its time (RFC 9559). OpenReliant reads the tracks and the blocks here, and hands the frames +//! of the first video track and the first audio track to FFmpeg's decoders. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const ogg = @import("ogg.zig"); + +/// The extensions of Matroska files, and of WebM files. +pub const extension = ".mkv"; +pub const webm_extension = ".webm"; + +pub const Error = error{ + /// It isn't an EBML file of the Matroska or WebM type. + NotMatroska, + /// An element runs past the element that holds it, or a number in it is malformed. + BadElement, + /// It has no video track. + NoVideo, + /// Its first video track has no frames, or its first frame isn't a key frame. + NoFrames, + /// Its first video or audio track's data is compressed or encrypted (`ContentEncodings`). + Encoded, +} || Allocator.Error; + +/// The IDs of the elements OpenReliant reads, with the bits that mark their length, as the +/// specifications write them. +pub const Id = enum(u32) { + ebml = 0x1A45DFA3, + doc_type = 0x4282, + segment = 0x18538067, + seek_head = 0x114D9B74, + info = 0x1549A966, + timestamp_scale = 0x2AD7B1, + tracks = 0x1654AE6B, + track_entry = 0xAE, + track_number = 0xD7, + track_type = 0x83, + flag_enabled = 0xB9, + codec_id = 0x86, + codec_private = 0x63A2, + content_encodings = 0x6D80, + video = 0xE0, + pixel_width = 0xB0, + pixel_height = 0xBA, + audio = 0xE1, + sampling_frequency = 0xB5, + channels = 0x9F, + cluster = 0x1F43B675, + timestamp = 0xE7, + simple_block = 0xA3, + block_group = 0xA0, + block = 0xA1, + reference_block = 0xFB, + cues = 0x1C53BB6B, + chapters = 0x1043A770, + tags = 0x1254C367, + attachments = 0x1941A469, + _, + + /// Whether it is one of the segment's own elements, which end a cluster of unknown size. + fn inSegment(id: Id) bool { + return switch (id) { + .seek_head, .info, .tracks, .cluster, .cues, .chapters, .tags, .attachments => true, + else => false, + }; + } +}; + +/// The document types of Matroska files and of WebM files. +const doc_types = [_][]const u8{ "matroska", "webm" }; + +/// The time that a cluster's timestamp and a block's are counted in, unless the segment's +/// information gives another: a millisecond, in nanoseconds. +const default_timestamp_scale = 1_000_000; + +/// A movie's file read: its first video track and that track's frames, and its first audio track +/// and that track's packets, each pointing into the file's bytes. +pub const Movie = struct { + video: Video, + frames: []Frame, + audio: ?Audio, + packets: []const []const u8, + + /// Reads the file `bytes`, which must outlive the movie. + pub fn parse(gpa: Allocator, bytes: []const u8) Error!Movie { + if (!isEbml(bytes)) return error.NotMatroska; + var top: Elements = .{ .bytes = bytes }; + const header = try top.next() orelse return error.NotMatroska; + if (header.id != .ebml or !isMatroska(header.data)) return error.NotMatroska; + const segment = while (try top.next()) |element| { + if (element.id == .segment) break element; + } else return error.NotMatroska; + var read: Tracks = .{}; + var scale: u64 = default_timestamp_scale; + var children: Elements = .{ .bytes = segment.data }; + while (try children.next()) |element| switch (element.id) { + .info => scale = try timestampScale(element.data), + .tracks => try read.add(element.data), + else => {}, + }; + const video = read.video orelse return error.NoVideo; + if (video.encoded or (read.audio != null and read.audio.?.encoded)) return error.Encoded; + + var frames: std.ArrayList(Frame) = .empty; + defer frames.deinit(gpa); + var packets: std.ArrayList([]const u8) = .empty; + defer packets.deinit(gpa); + children = .{ .bytes = segment.data }; + while (try children.next()) |element| { + if (element.id != .cluster) continue; + try readCluster(gpa, element.data, scale, .{ + .video = video.number, + .audio = if (read.audio) |audio| audio.number else null, + .frames = &frames, + .packets = &packets, + }); + } + if (frames.items.len == 0 or !frames.items[0].keyframe) return error.NoFrames; + return .{ + .video = .{ .codec = .of(video.codec_id), .width = video.width, .height = video.height }, + .frames = try frames.toOwnedSlice(gpa), + .audio = if (read.audio) |audio| .{ + .codec = .of(audio.codec_id), + .setup = audio.private, + .rate = audio.rate, + .channels = audio.channels, + } else null, + .packets = try packets.toOwnedSlice(gpa), + }; + } + + pub fn deinit(movie: *Movie, gpa: Allocator) void { + gpa.free(movie.frames); + gpa.free(movie.packets); + movie.* = undefined; + } +}; + +/// Whether `bytes` start as an EBML file does. +pub fn isEbml(bytes: []const u8) bool { + return std.mem.startsWith(u8, bytes, &std.mem.toBytes(std.mem.nativeToBig(u32, @backingInt(Id.ebml)))); +} + +/// A video track: its codec and its size in pixels. +pub const Video = struct { + codec: VideoCodec, + width: u32, + height: u32, +}; + +/// A video track's codec, where OpenReliant decodes it, or else the ID the file gives it. +pub const VideoCodec = union(enum) { + vp9, + other: []const u8, + + fn of(id: []const u8) VideoCodec { + return if (std.mem.eql(u8, id, "V_VP9")) .vp9 else .{ .other = id }; + } +}; + +/// An audio track: its codec, its codec's headers (`CodecPrivate`), and its rate and channels. +/// Opus's headers are its identification header, and Vorbis's its three headers laced together, +/// as an Ogg stream's give them to the decoders (`ogg.Stream.setup`). +pub const Audio = struct { + codec: AudioCodec, + setup: []const u8, + /// Samples per second, as the track gives it. Opus always decodes at `ogg.opus_rate`. + rate: f64, + channels: u8, +}; + +/// An audio track's codec, where OpenReliant decodes it, or else the ID the file gives it. +pub const AudioCodec = union(enum) { + opus, + vorbis, + other: []const u8, + + fn of(id: []const u8) AudioCodec { + if (std.mem.eql(u8, id, "A_OPUS")) return .opus; + if (std.mem.eql(u8, id, "A_VORBIS")) return .vorbis; + return .{ .other = id }; + } +}; + +/// A frame of a video track: its coded data, when it shows, in nanoseconds from the time the +/// file's timestamps count from, and whether it is a key frame, which decodes without the frames +/// before it. +pub const Frame = struct { + data: []const u8, + time: i64, + keyframe: bool, +}; + +/// Whether the EBML header's data `header` names a Matroska or WebM document; a header without +/// a document type is Matroska's. +fn isMatroska(header: []const u8) bool { + var children: Elements = .{ .bytes = header }; + while (children.next() catch return false) |element| { + if (element.id != .doc_type) continue; + const doc_type = std.mem.trimEnd(u8, element.data, "\x00"); + for (doc_types) |known| if (std.mem.eql(u8, doc_type, known)) return true; + return false; + } + return true; +} + +/// The segment information's timestamp scale, the nanoseconds each tick of a timestamp lasts. +fn timestampScale(info: []const u8) Error!u64 { + var children: Elements = .{ .bytes = info }; + while (try children.next()) |element| { + if (element.id != .timestamp_scale) continue; + const scale = try unsigned(element.data); + return if (scale == 0) error.BadElement else scale; + } + return default_timestamp_scale; +} + +/// A track's entry, as far as OpenReliant reads it. +const TrackEntry = struct { + number: u64 = 0, + kind: u64 = 0, + enabled: bool = true, + codec_id: []const u8 = "", + private: []const u8 = "", + encoded: bool = false, + width: u32 = 0, + height: u32 = 0, + rate: f64 = default_rate, + channels: u8 = 1, + + /// The track types of video and of audio (`TrackType`). + const video_type = 1; + const audio_type = 2; + + /// The rate and channels an audio track has unless it gives its own. + const default_rate = 8000; + + /// Reads the track's entry `data`. + fn read(data: []const u8) Error!TrackEntry { + var entry: TrackEntry = .{}; + var children: Elements = .{ .bytes = data }; + while (try children.next()) |element| switch (element.id) { + .track_number => entry.number = try unsigned(element.data), + .track_type => entry.kind = try unsigned(element.data), + .flag_enabled => entry.enabled = try unsigned(element.data) != 0, + .codec_id => entry.codec_id = std.mem.trimEnd(u8, element.data, "\x00"), + .codec_private => entry.private = element.data, + .content_encodings => entry.encoded = true, + .video => try entry.readVideo(element.data), + .audio => try entry.readAudio(element.data), + else => {}, + }; + return entry; + } + + fn readVideo(entry: *TrackEntry, data: []const u8) Error!void { + var children: Elements = .{ .bytes = data }; + while (try children.next()) |element| switch (element.id) { + .pixel_width => entry.width = std.math.cast(u32, try unsigned(element.data)) orelse return error.BadElement, + .pixel_height => entry.height = std.math.cast(u32, try unsigned(element.data)) orelse return error.BadElement, + else => {}, + }; + } + + fn readAudio(entry: *TrackEntry, data: []const u8) Error!void { + var children: Elements = .{ .bytes = data }; + while (try children.next()) |element| switch (element.id) { + .sampling_frequency => entry.rate = try float(element.data), + .channels => entry.channels = std.math.cast(u8, try unsigned(element.data)) orelse return error.BadElement, + else => {}, + }; + } +}; + +/// The first enabled video track and audio track of a file's track entries. +const Tracks = struct { + video: ?TrackEntry = null, + audio: ?TrackEntry = null, + + /// Reads the track entries in `tracks`, the data of a `Tracks` element. + fn add(found: *Tracks, tracks: []const u8) Error!void { + var children: Elements = .{ .bytes = tracks }; + while (try children.next()) |element| { + if (element.id != .track_entry) continue; + const entry: TrackEntry = try .read(element.data); + if (!entry.enabled or entry.number == 0) continue; + switch (entry.kind) { + TrackEntry.video_type => if (found.video == null) { + if (entry.width == 0 or entry.height == 0) return error.BadElement; + found.video = entry; + }, + TrackEntry.audio_type => if (found.audio == null) { + if (entry.channels == 0 or !(entry.rate > 0)) return error.BadElement; + found.audio = entry; + }, + else => {}, + } + } + } +}; + +/// Where a cluster's blocks go: the tracks they belong to, and the lists of their frames. +const Wanted = struct { + video: u64, + audio: ?u64, + frames: *std.ArrayList(Frame), + packets: *std.ArrayList([]const u8), +}; + +/// Reads the blocks of the cluster `data`, with the timestamps counted in ticks of `scale` +/// nanoseconds, into the lists `wanted` gives. +fn readCluster(gpa: Allocator, data: []const u8, scale: u64, wanted: Wanted) Error!void { + var start: u64 = 0; + var children: Elements = .{ .bytes = data }; + while (try children.next()) |element| switch (element.id) { + .timestamp => start = try unsigned(element.data), + .simple_block => try readBlock(gpa, element.data, null, start, scale, wanted), + .block_group => { + var block: ?[]const u8 = null; + var referenced = false; + var parts: Elements = .{ .bytes = element.data }; + while (try parts.next()) |part| switch (part.id) { + .block => block = part.data, + .reference_block => referenced = true, + else => {}, + }; + // A block of a group is a key frame where it refers to no other. + if (block) |found| try readBlock(gpa, found, !referenced, start, scale, wanted); + }, + else => {}, + }; +} + +/// The flags of a block's header. A simple block's sets `keyframe`; a block of a group leaves it +/// clear, and says so with a `ReferenceBlock` beside it. +const BlockFlags = packed struct(u8) { + discardable: bool, + lacing: Lacing, + invisible: bool, + _reserved: u3, + keyframe: bool, +}; + +/// How a block holds more than one frame. +const Lacing = enum(u2) { + none = 0, + xiph = 1, + fixed = 2, + ebml = 3, +}; + +/// Reads the block `data`, which belongs to the cluster that starts at `start` ticks of `scale` +/// nanoseconds, into the lists `wanted` gives, where it belongs to one of their tracks. A simple +/// block says itself whether it is a key frame; a block of a group is one as `keyframe` says. +fn readBlock(gpa: Allocator, data: []const u8, keyframe: ?bool, start: u64, scale: u64, wanted: Wanted) Error!void { + const track = try readVint(data, 0, .size); + if (track.unknown) return error.BadElement; + const after = track.length; + if (data.len < after + 3) return error.BadElement; + const relative = std.mem.readInt(i16, data[after..][0..2], .big); + const flags: BlockFlags = @bitCast(data[after + 2]); + const payload = data[after + 3 ..]; + const ticks = @as(i128, start) + relative; + const time = std.math.cast(i64, ticks * scale) orelse return error.BadElement; + if (track.value == wanted.video) { + var laced: std.ArrayList([]const u8) = .empty; + defer laced.deinit(gpa); + try unlace(gpa, &laced, flags.lacing, payload); + for (laced.items) |frame| try wanted.frames.append(gpa, .{ + .data = frame, + .time = time, + .keyframe = keyframe orelse flags.keyframe, + }); + } else if (wanted.audio == track.value) { + try unlace(gpa, wanted.packets, flags.lacing, payload); + } +} + +/// Appends the frames of a block's `payload`, laced as `lacing` says, to `out`. +fn unlace(gpa: Allocator, out: *std.ArrayList([]const u8), lacing: Lacing, payload: []const u8) Error!void { + if (lacing == .none) return out.append(gpa, payload); + if (payload.len == 0) return error.BadElement; + const count = @as(usize, payload[0]) + 1; + var at: usize = 1; + const sizes = try gpa.alloc(usize, count); + defer gpa.free(sizes); + switch (lacing) { + .none => unreachable, + // Each size but the last as bytes added up, where a byte of 255 carries on into the next + // byte and any other ends the size. + .xiph => for (sizes[0 .. count - 1]) |*size| { + size.* = 0; + while (true) { + if (at >= payload.len) return error.BadElement; + const byte = payload[at]; + at += 1; + size.* += byte; + if (byte != 255) break; + } + }, + // The first size as a number, and each after it as how much it differs from the one + // before. + .ebml => if (count > 1) { + const first = try readVint(payload, at, .size); + if (first.unknown) return error.BadElement; + at += first.length; + sizes[0] = std.math.cast(usize, first.value) orelse return error.BadElement; + for (sizes[1 .. count - 1], sizes[0 .. count - 2]) |*size, before| { + const difference = try readVint(payload, at, .signed); + at += difference.length; + const signed = @as(i128, before) + difference.signedValue(); + size.* = std.math.cast(usize, signed) orelse return error.BadElement; + } + }, + // As many frames of one size, which the rest divides into evenly. + .fixed => { + const rest = payload.len - at; + if (rest % count != 0) return error.BadElement; + @memset(sizes, rest / count); + }, + } + var used: usize = 0; + for (sizes[0 .. count - 1]) |size| used = std.math.add(usize, used, size) catch return error.BadElement; + if (used > payload.len - at) return error.BadElement; + if (lacing != .fixed) sizes[count - 1] = payload.len - at - used; + for (sizes) |size| { + if (size > payload.len - at) return error.BadElement; + try out.append(gpa, payload[at..][0..size]); + at += size; + } +} + +/// An element: its ID and its data. +const Element = struct { + id: Id, + data: []const u8, +}; + +/// The elements of an element's data, or of a file, one after another. An element whose size is +/// unknown, as in a file written as a stream, runs to the end of the data that holds it; a cluster +/// whose size is unknown ends where one of the segment's own elements begins (`Id.inSegment`). +const Elements = struct { + bytes: []const u8, + at: usize = 0, + + fn next(elements: *Elements) Error!?Element { + const bytes = elements.bytes; + if (elements.at >= bytes.len) return null; + const id = try readVint(bytes, elements.at, .id); + const size = try readVint(bytes, elements.at + id.length, .size); + const start = elements.at + id.length + size.length; + const element_id: Id = @fromBackingInt(@as(u32, @intCast(id.value))); + const end = if (size.unknown) + if (element_id == .cluster) clusterEnd(bytes, start) else bytes.len + else if (size.value <= bytes.len - start) start + @as(usize, @intCast(size.value)) else return error.BadElement; + elements.at = end; + return .{ .id = element_id, .data = bytes[start..end] }; + } +}; + +/// Where a cluster of unknown size that starts its data at `start` in `bytes` ends: at the first of +/// its elements that is one of the segment's own instead, or at the end. +fn clusterEnd(bytes: []const u8, start: usize) usize { + var children: Elements = .{ .bytes = bytes, .at = start }; + while (true) { + const before = children.at; + const element = (children.next() catch return bytes.len) orelse return bytes.len; + if (element.id.inSegment()) return before; + } +} + +/// How an EBML number of variable length is read (RFC 8794, 4): an ID keeps the bits that mark its +/// length, a size drops them, and a signed number is a size less half its range. +const VintKind = enum { id, size, signed }; + +/// An EBML number of variable length: its first byte's leading zeros, plus one, give its length. +const Vint = struct { + value: u64, + length: usize, + /// For a size, whether every bit of its value is set, which leaves the size unknown. + unknown: bool = false, + + /// The value of a signed number: its value as a size less `2^(7n-1) - 1`, for a number of `n` + /// bytes. + fn signedValue(vint: Vint) i128 { + const half = (@as(i128, 1) << @intCast(7 * vint.length - 1)) - 1; + return @as(i128, vint.value) - half; + } +}; + +/// The EBML number at `at` in `bytes`, read as `kind` says. An ID has at most 4 bytes, a size at +/// most 8. +fn readVint(bytes: []const u8, at: usize, kind: VintKind) Error!Vint { + if (at >= bytes.len) return error.BadElement; + const first = bytes[at]; + if (first == 0) return error.BadElement; + const length: usize = @as(usize, @clz(first)) + 1; + if (kind == .id and length > 4) return error.BadElement; + if (bytes.len - at < length) return error.BadElement; + var value: u64 = 0; + for (bytes[at..][0..length]) |byte| value = value << 8 | byte; + if (kind == .id) return .{ .value = value, .length = length }; + const bits: u6 = @intCast(7 * length); + const mask = (@as(u64, 1) << bits) - 1; + value &= mask; + return .{ .value = value, .length = length, .unknown = kind == .size and value == mask }; +} + +/// The unsigned integer `data` holds, big-endian, in up to 8 bytes. +fn unsigned(data: []const u8) Error!u64 { + if (data.len > 8) return error.BadElement; + var value: u64 = 0; + for (data) |byte| value = value << 8 | byte; + return value; +} + +/// The float `data` holds, big-endian, in 4 or 8 bytes, or 0 in none. +fn float(data: []const u8) Error!f64 { + return switch (data.len) { + 0 => 0, + 4 => @floatCast(@as(f32, @bitCast(std.mem.readInt(u32, data[0..4], .big)))), + 8 => @bitCast(std.mem.readInt(u64, data[0..8], .big)), + else => error.BadElement, + }; +} + +/// Matroska files for the tests, built by hand. +pub const testing = struct { + /// Appends the element `id` holding `data` to `file`, its size in 8 bytes. + pub fn element(gpa: Allocator, file: *std.ArrayList(u8), id: Id, data: []const u8) Allocator.Error!void { + try appendId(gpa, file, id); + try file.append(gpa, 0x01); + try file.appendSlice(gpa, std.mem.asBytes(&std.mem.nativeToBig(u64, data.len))[1..]); + try file.appendSlice(gpa, data); + } + + /// Appends the element `id` holding the unsigned integer `value` to `file`. + pub fn unsignedElement(gpa: Allocator, file: *std.ArrayList(u8), id: Id, value: u64) Allocator.Error!void { + try element(gpa, file, id, &std.mem.toBytes(std.mem.nativeToBig(u64, value))); + } + + /// Appends `id`'s bytes, as many as it takes, to `file`. + fn appendId(gpa: Allocator, file: *std.ArrayList(u8), id: Id) Allocator.Error!void { + const value = @backingInt(id); + const length: usize = 4 - @as(usize, @clz(value)) / 8; + try file.appendSlice(gpa, std.mem.asBytes(&std.mem.nativeToBig(u32, value))[4 - length ..]); + } + + /// A simple block of track `track`, `relative` ticks into its cluster, holding `frame`. + pub fn simpleBlock(gpa: Allocator, file: *std.ArrayList(u8), track: u8, relative: i16, keyframe: bool, frame: []const u8) Allocator.Error!void { + var block: std.ArrayList(u8) = .empty; + defer block.deinit(gpa); + try block.append(gpa, 0x80 | track); + try block.appendSlice(gpa, &std.mem.toBytes(std.mem.nativeToBig(i16, relative))); + try block.append(gpa, if (keyframe) 0x80 else 0); + try block.appendSlice(gpa, frame); + try element(gpa, file, .simple_block, block.items); + } + + /// What a movie for the tests holds. + pub const Spec = struct { + doc_type: []const u8 = "webm", + video_codec: []const u8 = "V_VP9", + width: u8 = 16, + height: u8 = 16, + /// The video frames, one a simple block of track 1, each a tick of 40 ms after the one + /// before, the first a key frame. + frames: []const []const u8, + /// The audio track 2's codec and headers, and its packets, one a simple block, where it + /// has one. + audio: ?struct { codec: []const u8, setup: []const u8, packets: []const []const u8 } = null, + }; + + /// The movie `spec` describes, as a WebM file in `gpa`. + pub fn movie(gpa: Allocator, spec: Spec) Allocator.Error![]u8 { + var file: std.ArrayList(u8) = .empty; + defer file.deinit(gpa); + var header: std.ArrayList(u8) = .empty; + defer header.deinit(gpa); + try element(gpa, &header, .doc_type, spec.doc_type); + try element(gpa, &file, .ebml, header.items); + + var segment: std.ArrayList(u8) = .empty; + defer segment.deinit(gpa); + var info: std.ArrayList(u8) = .empty; + defer info.deinit(gpa); + try unsignedElement(gpa, &info, .timestamp_scale, 40 * std.time.ns_per_ms); + try element(gpa, &segment, .info, info.items); + + var tracks: std.ArrayList(u8) = .empty; + defer tracks.deinit(gpa); + var video: std.ArrayList(u8) = .empty; + defer video.deinit(gpa); + var picture: std.ArrayList(u8) = .empty; + defer picture.deinit(gpa); + try unsignedElement(gpa, &picture, .pixel_width, spec.width); + try unsignedElement(gpa, &picture, .pixel_height, spec.height); + try unsignedElement(gpa, &video, .track_number, 1); + try unsignedElement(gpa, &video, .track_type, TrackEntry.video_type); + try element(gpa, &video, .codec_id, spec.video_codec); + try element(gpa, &video, .video, picture.items); + try element(gpa, &tracks, .track_entry, video.items); + if (spec.audio) |given| { + var audio: std.ArrayList(u8) = .empty; + defer audio.deinit(gpa); + var sound: std.ArrayList(u8) = .empty; + defer sound.deinit(gpa); + try element(gpa, &sound, .sampling_frequency, &std.mem.toBytes(std.mem.nativeToBig(u64, @bitCast(@as(f64, ogg.opus_rate))))); + try unsignedElement(gpa, &sound, .channels, 2); + try unsignedElement(gpa, &audio, .track_number, 2); + try unsignedElement(gpa, &audio, .track_type, TrackEntry.audio_type); + try element(gpa, &audio, .codec_id, given.codec); + try element(gpa, &audio, .codec_private, given.setup); + try element(gpa, &audio, .audio, sound.items); + try element(gpa, &tracks, .track_entry, audio.items); + } + try element(gpa, &segment, .tracks, tracks.items); + + var cluster: std.ArrayList(u8) = .empty; + defer cluster.deinit(gpa); + try unsignedElement(gpa, &cluster, .timestamp, 0); + for (spec.frames, 0..) |frame, at| try simpleBlock(gpa, &cluster, 1, @intCast(at), at == 0, frame); + if (spec.audio) |given| for (given.packets, 0..) |packet, at| try simpleBlock(gpa, &cluster, 2, @intCast(at), true, packet); + try element(gpa, &segment, .cluster, cluster.items); + try element(gpa, &file, .segment, segment.items); + return file.toOwnedSlice(gpa); + } +}; + +test Movie { + const gpa = std.testing.allocator; + const opus_head = "OpusHead\x01\x02\x38\x01\x80\xBB\x00\x00\x00\x00\x00"; + const bytes = try testing.movie(gpa, .{ + .frames = &.{ "key", "next", "last" }, + .audio = .{ .codec = "A_OPUS", .setup = opus_head, .packets = &.{ "a", "b" } }, + }); + defer gpa.free(bytes); + try std.testing.expect(isEbml(bytes)); + var movie: Movie = try .parse(gpa, bytes); + defer movie.deinit(gpa); + try std.testing.expectEqual(VideoCodec.vp9, movie.video.codec); + try std.testing.expectEqual(16, movie.video.width); + // Each frame a tick of the segment's 40 ms after the one before, the first a key frame. + try std.testing.expectEqual(3, movie.frames.len); + try std.testing.expectEqualStrings("next", movie.frames[1].data); + try std.testing.expectEqual(80 * std.time.ns_per_ms, movie.frames[2].time); + try std.testing.expect(movie.frames[0].keyframe and !movie.frames[1].keyframe); + // The audio track's headers and packets. + const audio = movie.audio.?; + try std.testing.expectEqual(AudioCodec.opus, audio.codec); + try std.testing.expectEqualStrings(opus_head, audio.setup); + try std.testing.expectEqual(2, audio.channels); + try std.testing.expectEqual(2, movie.packets.len); + try std.testing.expectEqualStrings("b", movie.packets[1]); +} + +test "a file that isn't a movie OpenReliant reads" { + const gpa = std.testing.allocator; + try std.testing.expectError(error.NotMatroska, Movie.parse(gpa, "RIFF")); + const other = try testing.movie(gpa, .{ .doc_type = "other", .frames = &.{"key"} }); + defer gpa.free(other); + try std.testing.expectError(error.NotMatroska, Movie.parse(gpa, other)); + const empty = try testing.movie(gpa, .{ .frames = &.{} }); + defer gpa.free(empty); + try std.testing.expectError(error.NoFrames, Movie.parse(gpa, empty)); + // Cut short, an element runs past the end. + const whole = try testing.movie(gpa, .{ .frames = &.{"key"} }); + defer gpa.free(whole); + try std.testing.expectError(error.BadElement, Movie.parse(gpa, whole[0 .. whole.len - 1])); +} + +test "a block of a group, and a cluster of unknown size" { + const gpa = std.testing.allocator; + var file: std.ArrayList(u8) = .empty; + defer file.deinit(gpa); + const head = try testing.movie(gpa, .{ .frames = &.{"key"} }); + defer gpa.free(head); + // The test movie with a second cluster of unknown size after the first, then cues: a block + // of a group that refers to the frame before it, and one that refers to none. + var extra: std.ArrayList(u8) = .empty; + defer extra.deinit(gpa); + try testing.appendId(gpa, &extra, .cluster); + try extra.append(gpa, 0xFF); + try testing.unsignedElement(gpa, &extra, .timestamp, 10); + for ([_]bool{ true, false }) |refers| { + var group: std.ArrayList(u8) = .empty; + defer group.deinit(gpa); + var block: std.ArrayList(u8) = .empty; + defer block.deinit(gpa); + try block.appendSlice(gpa, &.{ 0x81, 0x00, 0x01, 0x00 }); + try block.appendSlice(gpa, if (refers) "delta" else "again"); + try testing.element(gpa, &group, .block, block.items); + if (refers) try testing.unsignedElement(gpa, &group, .reference_block, 1); + try testing.element(gpa, &extra, .block_group, group.items); + } + try testing.element(gpa, &extra, .cues, ""); + // The segment's size grows by what's added. + try file.appendSlice(gpa, head); + const segment_at = std.mem.findPos(u8, head, 4, &std.mem.toBytes(std.mem.nativeToBig(u32, @backingInt(Id.segment)))).?; + const size_at = segment_at + 4 + 1; + const size = std.mem.readInt(u56, head[size_at..][0..7], .big); + std.mem.writeInt(u56, file.items[size_at..][0..7], size + @as(u56, @intCast(extra.items.len)), .big); + try file.appendSlice(gpa, extra.items); + var movie: Movie = try .parse(gpa, file.items); + defer movie.deinit(gpa); + try std.testing.expectEqual(3, movie.frames.len); + try std.testing.expectEqualStrings("delta", movie.frames[1].data); + try std.testing.expect(!movie.frames[1].keyframe and movie.frames[2].keyframe); + try std.testing.expectEqual(11 * 40 * std.time.ns_per_ms, movie.frames[1].time); +} + +test unlace { + const gpa = std.testing.allocator; + var frames: std.ArrayList([]const u8) = .empty; + defer frames.deinit(gpa); + // Three frames of 2, 300 and 1 bytes, laced each way. + var payload: [303]u8 = undefined; + for (&payload, 0..) |*byte, at| byte.* = @intCast(at % 251); + const sizes = [3]usize{ 2, 300, 1 }; + var xiph: std.ArrayList(u8) = .empty; + defer xiph.deinit(gpa); + try xiph.appendSlice(gpa, &.{ 2, 2, 255, 45 }); + try xiph.appendSlice(gpa, &payload); + try unlace(gpa, &frames, .xiph, xiph.items); + var ebml: std.ArrayList(u8) = .empty; + defer ebml.deinit(gpa); + // 2, then 298 more as a signed number of two bytes: 298 plus 8191, with the length's marker. + try ebml.appendSlice(gpa, &.{ 2, 0x82, 0x60 | ((298 + 8191) >> 8 & 0x1F), (298 + 8191) & 0xFF }); + try ebml.appendSlice(gpa, &payload); + try unlace(gpa, &frames, .ebml, ebml.items); + try std.testing.expectEqual(6, frames.items.len); + for (frames.items, sizes ++ sizes) |frame, size| try std.testing.expectEqual(size, frame.len); + try std.testing.expectEqualSlices(u8, payload[2..302], frames.items[4]); + // Fixed lacing splits the rest evenly, and fails where it can't. + frames.clearRetainingCapacity(); + try unlace(gpa, &frames, .fixed, &.{ 1, 'a', 'b', 'c', 'd' }); + try std.testing.expectEqualStrings("cd", frames.items[1]); + try std.testing.expectError(error.BadElement, unlace(gpa, &frames, .fixed, &.{ 1, 'a', 'b', 'c' })); + try std.testing.expectError(error.BadElement, unlace(gpa, &frames, .xiph, &.{ 1, 9, 'a' })); +} + +test readVint { + // A one-byte ID keeps its marker, a size drops it, and all ones leave a size unknown. + try std.testing.expectEqual(0xA3, (try readVint(&.{0xA3}, 0, .id)).value); + try std.testing.expectEqual(0x23, (try readVint(&.{0xA3}, 0, .size)).value); + try std.testing.expect((try readVint(&.{0xFF}, 0, .size)).unknown); + try std.testing.expectEqual(0x0123, (try readVint(&.{ 0x41, 0x23 }, 0, .size)).value); + try std.testing.expectEqual(-1, (try readVint(&.{0xBE}, 0, .signed)).signedValue()); + try std.testing.expectError(error.BadElement, readVint(&.{0x00}, 0, .size)); + try std.testing.expectError(error.BadElement, readVint(&.{0x08}, 0, .id)); + try std.testing.expectError(error.BadElement, readVint(&.{0x40}, 0, .size)); +} + +comptime { + // A block's flags as the specification numbers its bits from the most significant: the key + // frame first, the lacing in bits 5 and 6. + std.debug.assert(@as(u8, @bitCast(BlockFlags{ .discardable = false, .lacing = .none, .invisible = false, ._reserved = 0, .keyframe = true })) == 0x80); + std.debug.assert(@as(u8, @bitCast(BlockFlags{ .discardable = false, .lacing = .ebml, .invisible = false, ._reserved = 0, .keyframe = false })) == 0x06); +} diff --git a/src/platform/video.zig b/src/platform/video.zig index 4da12871..8b2f8283 100644 --- a/src/platform/video.zig +++ b/src/platform/video.zig @@ -1,11 +1,11 @@ //! Decodes the movies' packets with FFmpeg's Bink decoders (`deps/ffmpeg`), for the engine's -//! replacement of Bink ([`engine/bink.zig`](../engine/bink.zig)'s `Codec`), and audio files with -//! FFmpeg's MP3, FLAC, Vorbis and Opus decoders. Each Bink decoder is set up the way FFmpeg's Bink -//! demuxer sets it up. The video decoder gets the file's signature as its codec tag and the -//! header's flags as extra data. An audio track's decoder gets the track's rate and channels, and -//! the file's signature as extra data. The MP3 and FLAC decoders read everything they need from -//! the data they are sent, and the Vorbis and Opus decoders get an Ogg stream's headers as extra -//! data. +//! replacement of Bink ([`engine/bink.zig`](../engine/bink.zig)'s `Codec`), mods' movies with its +//! VP9 decoder, and audio files with FFmpeg's MP3, FLAC, Vorbis and Opus decoders. Each Bink +//! decoder is set up the way FFmpeg's Bink demuxer sets it up. The video decoder gets the file's +//! signature as its codec tag and the header's flags as extra data. An audio track's decoder gets +//! the track's rate and channels, and the file's signature as extra data. The VP9, MP3 and FLAC +//! decoders read everything they need from the data they are sent, and the Vorbis and Opus +//! decoders get an Ogg stream's or a movie's audio track's headers as extra data. //! //! FFmpeg is built without threads, so it is only used from the game's thread. @@ -38,11 +38,14 @@ pub const Decoders = struct { } }; -/// A stream's decoder, and the packet and frame it decodes through. +/// A stream's decoder, the packet and frame it decodes through, and for a video, the frame shown +/// last, which holds where a packet shows none, as one of VP9 that holds a frame kept hidden for +/// the frames after it can. const Stream = struct { context: *c.AVCodecContext, packet: *c.AVPacket, frame: *c.AVFrame, + shown: *c.AVFrame, fn of(stream: bink.Stream) *Stream { return @ptrCast(@alignCast(stream)); @@ -73,7 +76,10 @@ const Setup = union(enum) { /// the stream's headers. fn open(setup: Setup) bink.Error!bink.Stream { const id: c.enum_AVCodecID = switch (setup) { - .video => c.AV_CODEC_ID_BINKVIDEO, + .video => |video| switch (video) { + .bink => c.AV_CODEC_ID_BINKVIDEO, + .vp9 => c.AV_CODEC_ID_VP9, + }, .audio => |audio| if (audio.dct) c.AV_CODEC_ID_BINKAUDIO_DCT else c.AV_CODEC_ID_BINKAUDIO_RDFT, .file => |encoding| switch (encoding) { .mp3 => c.AV_CODEC_ID_MP3, @@ -87,12 +93,15 @@ fn open(setup: Setup) bink.Error!bink.Stream { errdefer c.avcodec_free_context(&context); const set_up = context.?; switch (setup) { - .video => |video| { - const extradata = try extraData(set_up, 4); - set_up.width = @intCast(video.width); - set_up.height = @intCast(video.height); - set_up.codec_tag = signature(video.revision); - std.mem.writeInt(u32, extradata[0..4], @bitCast(video.flags), .little); + .video => |video| switch (video) { + .bink => |coded| { + const extradata = try extraData(set_up, 4); + set_up.width = @intCast(coded.width); + set_up.height = @intCast(coded.height); + set_up.codec_tag = signature(coded.revision); + std.mem.writeInt(u32, extradata[0..4], @bitCast(coded.flags), .little); + }, + .vp9 => {}, }, .audio => |audio| { const extradata = try extraData(set_up, 4); @@ -116,8 +125,10 @@ fn open(setup: Setup) bink.Error!bink.Stream { errdefer c.av_packet_free(&packet); var frame: ?*c.AVFrame = c.av_frame_alloc() orelse return error.OutOfMemory; errdefer c.av_frame_free(&frame); + var shown: ?*c.AVFrame = c.av_frame_alloc() orelse return error.OutOfMemory; + errdefer c.av_frame_free(&shown); const stream = try std.heap.c_allocator.create(Stream); - stream.* = .{ .context = set_up, .packet = packet.?, .frame = frame.? }; + stream.* = .{ .context = set_up, .packet = packet.?, .frame = frame.?, .shown = shown.? }; return stream; } @@ -147,11 +158,19 @@ fn send(stream: *Stream, packet: []const u8) bink.Error!void { if (c.avcodec_send_packet(stream.context, stream.packet) < 0) return error.Decoding; } +/// The picture of the video's next `packet`: the frame it shows, or the one shown last where it +/// shows none. Only pictures of 8 bits with their colour at half size across and down, with an +/// alpha plane or without, are taken. fn picture(_: *anyopaque, handle: bink.Stream, packet: []const u8) bink.Error!bink.Picture { const stream: *Stream = .of(handle); try send(stream, packet); - if (c.avcodec_receive_frame(stream.context, stream.frame) < 0) return error.Decoding; - const frame = stream.frame; + const received = c.avcodec_receive_frame(stream.context, stream.frame); + if (received == 0) { + c.av_frame_unref(stream.shown); + c.av_frame_move_ref(stream.shown, stream.frame); + } else if (received != -c.EAGAIN or stream.shown.data[0] == null) return error.Decoding; + const frame = stream.shown; + if (frame.format != c.AV_PIX_FMT_YUV420P and frame.format != c.AV_PIX_FMT_YUVA420P) return error.Decoding; const width: u32 = @intCast(frame.width); const height: u32 = @intCast(frame.height); const half = @divCeil(height, 2); @@ -165,6 +184,21 @@ fn picture(_: *anyopaque, handle: bink.Stream, packet: []const u8) bink.Error!bi .v = frame.data[2][0 .. strides[2] * half], .alpha = if (frame.format == c.AV_PIX_FMT_YUVA420P) frame.data[3][0 .. strides[3] * height] else null, .strides = strides, + .colour = colourOf(frame), + }; +} + +/// How `frame`'s levels make colours, as its decoder read them from the video: by BT.709 where the +/// video says BT.709, or SMPTE 240M, which is close to it, and by BT.601 otherwise, as where it +/// says nothing, as Bink's never does; in the full range where it says so, and the limited range +/// otherwise. +fn colourOf(frame: *const c.AVFrame) bink.picture.Colour { + return .{ + .matrix = switch (frame.colorspace) { + c.AVCOL_SPC_BT709, c.AVCOL_SPC_SMPTE240M => .bt709, + else => .bt601, + }, + .range = if (frame.color_range == c.AVCOL_RANGE_JPEG) .full else .limited, }; } @@ -224,9 +258,11 @@ fn close(_: *anyopaque, handle: bink.Stream) void { var context: ?*c.AVCodecContext = stream.context; var packet: ?*c.AVPacket = stream.packet; var frame: ?*c.AVFrame = stream.frame; + var shown: ?*c.AVFrame = stream.shown; c.avcodec_free_context(&context); c.av_packet_free(&packet); c.av_frame_free(&frame); + c.av_frame_free(&shown); std.heap.c_allocator.destroy(stream); } @@ -245,7 +281,7 @@ test sample16 { test "a damaged packet fails, and the decoder closes" { var decoders: Decoders = .init(); const codec = decoders.codec(); - const video = try codec.openVideo(.{ .revision = 'f', .width = 16, .height = 16, .flags = .{} }); + const video = try codec.openVideo(.{ .bink = .{ .revision = 'f', .width = 16, .height = 16, .flags = .{} } }); defer codec.close(video); try std.testing.expectError(error.Decoding, codec.picture(video, &.{ 0xFF, 0xFF })); const audio = try codec.openAudio(.{ .revision = 'f', .rate = 22050, .channels = 2, .dct = false }); @@ -255,6 +291,25 @@ test "a damaged packet fails, and the decoder closes" { codec.close(audio); } +test "VP9 frames decode to their pictures" { + var decoders: Decoders = .init(); + const codec = decoders.codec(); + const video = try codec.openVideo(.{ .vp9 = .{ .width = 16, .height = 16 } }); + defer codec.close(video); + // Two frames of a grey picture 16 by 16, Y at 124 in the limited range, coded by libvpx: a key + // frame, then a frame that refers to it. + const key = [_]u8{ 0x82, 0x49, 0x83, 0x42, 0x00, 0x00, 0xF0, 0x00, 0xF6, 0x00, 0x38, 0x24, 0x1C, 0x18, 0x3E, 0x00, 0x00, 0x30, 0x60, 0x00, 0x00, 0x10, 0xBD, 0xC6, 0x00, 0x00 }; + const next = [_]u8{ 0x86, 0x00, 0x40, 0x92, 0x9C, 0x00, 0x4F, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x42, 0x40 }; + for ([_][]const u8{ &key, &next }) |packet| { + const shown = try codec.picture(video, packet); + try std.testing.expectEqual(16, shown.width); + try std.testing.expectEqual(124, shown.y[0]); + try std.testing.expectEqual(128, shown.u[0]); + // The encoder said nothing of its colours: BT.601's limited range, as Bink's. + try std.testing.expectEqual(bink.picture.Colour{}, shown.colour); + } +} + test "MP3 frames decode to their samples" { var decoders: Decoders = .init(); const codec = decoders.codec(); diff --git a/src/scripting/front_end.zig b/src/scripting/front_end.zig index d24ea8ed..ebad1f7d 100644 --- a/src/scripting/front_end.zig +++ b/src/scripting/front_end.zig @@ -61,7 +61,7 @@ pub const functions = struct { pub const go_to = api.Function("Asks the front end to go to its screen `screen`, or to the mod's screen that stands in for it. Only menu scripts can use it.", &.{"screen"}, goTo); pub const start_game_mode = api.Function("Asks the front end to start the game mode `name`: the calling mod's by its own name, or any mod's by the qualified one. Only menu scripts can use it. Returns whether the mode is registered.", &.{"name"}, startGameMode); pub const launch_mission = api.Function("From a game mode's briefing screen, asks the front end to fly the mode's next mission (`core.game_mode_mission`). Only menu scripts can use it.", &.{}, launchMission); - pub const play_movie = api.Function("Plays the movie `name`, a Bink file of the game folder's or a mod's such as `\"intro.bik\"`, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it.", &.{"name"}, playMovie); + pub const play_movie = api.Function("Plays the movie `name`, a Bink file of the game folder's or a mod's such as `\"intro.bik\"`, or a mod's WebM or Matroska file with the same name, on a cleared screen, as the front end shows its next frame. Escape or the pointer's right button ends it. Only menu scripts can use it.", &.{"name"}, playMovie); pub const quit = api.Function("Asks the front end to quit the game. Only menu scripts can use it.", &.{}, quitGame); pub const pointer = api.Field(?Pointer, "Where the pointer is, in pixels from the window's top left corner, and whether its left button is down; nil before it has been over the window.", struct { pub fn get(call: Call) ?Pointer { From 46ce7eea8daa1e27f17a89fcf7931094af76f10f Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Sat, 24 Oct 2026 15:13:33 +0200 Subject: [PATCH 2/2] refactor: share the game-file lookups and tidy the mods' movies The Matroska module joins the root's imports, so the suite always runs its tests. A movie's own timing moves into its source, so the player never reads an inactive union field. The mods log's lookups share one check of the game's files, the head readers mirror the stored readers and use the standard library's Dir.readFile, and the movie player's log messages share one format. --- src/engine/bink.zig | 27 ++++++++----- src/engine/bink/picture.zig | 2 +- src/engine/files.zig | 6 +-- src/engine/game/bigfile.zig | 9 +++++ src/engine/game/bigfile/mods.zig | 64 ++++++++++++------------------ src/engine/game/interface/disc.zig | 6 +-- src/engine/game/xtrabits/movie.zig | 18 +++++---- src/formats/matroska.zig | 11 ++--- src/platform/video.zig | 13 +++--- src/root.zig | 1 + 10 files changed, 82 insertions(+), 75 deletions(-) diff --git a/src/engine/bink.zig b/src/engine/bink.zig index 72a0e755..ea55f77c 100644 --- a/src/engine/bink.zig +++ b/src/engine/bink.zig @@ -360,6 +360,18 @@ const Source = union(enum) { .matroska => |movie| .{ .packet = movie.frames[index].data, .keyframe = movie.frames[index].keyframe }, }; } + + /// When its video's frame `number`, from 0, is due after the first, in nanoseconds: by a Bink + /// movie's rate, or at a mod's movie's frame's own time. + fn due(source: Source, number: u32) u64 { + return switch (source) { + .bink => |movie| { + const own: Rate = .{ .frames = movie.header.rate, .seconds = movie.header.rate_divisor }; + return own.due(number); + }, + .matroska => |movie| std.math.cast(u64, movie.frames[number].time - movie.frames[0].time) orelse 0, + }; + } }; /// A movie open (`HBINK`). @@ -376,8 +388,7 @@ pub const Bink = struct { height: u32, frames: u32, frame_number: u32 = 1, - /// The rate it plays at: a Bink movie's header's, or the one set in place of its own; null for - /// a mod's movie in Matroska or WebM, whose frames each show at its own time. + /// The rate it plays at in place of its own (`BinkSetFrameRate`), where one is set. rate: ?Rate, look: Look, /// The last frame decoded. @@ -420,7 +431,7 @@ pub const Bink = struct { .width = header.width, .height = header.height, .frames = header.frames, - .rate = options.rate orelse .{ .frames = header.rate, .seconds = header.rate_divisor }, + .rate = options.rate, .look = options.look, }; if (options.look.deblock) { @@ -546,7 +557,7 @@ pub const Bink = struct { } /// `BinkCopyToBuffer`, in 32-bit RGBA: the last frame decoded into `rgba`, `pitch` bytes to a - /// row, its top left corner at `at`, turned into colours by BT.601 in its limited range + /// row, its top left corner at `at`, turned into colours as the picture says /// (`picture.convert`), deblocked first as its look has it. pub fn copyToBuffer(bink: Bink, rgba: []u8, pitch: usize, at: [2]usize) void { var shown = bink.picture orelse return; @@ -604,12 +615,10 @@ pub const Bink = struct { return now < start + bink.due(bink.frame_number - 1); } - /// When frame `number`, from 0, is due after the first, in nanoseconds: by its rate, or a - /// mod's movie's frame at its own time. + /// When frame `number`, from 0, is due after the first, in nanoseconds: by the rate set in + /// place of its own, or else as its file has it (`Source.due`). fn due(bink: Bink, number: u32) u64 { - if (bink.rate) |rate| return rate.due(number); - const frames = bink.source.matroska.frames; - return std.math.cast(u64, frames[number].time - frames[0].time) orelse 0; + return if (bink.rate) |rate| rate.due(number) else bink.source.due(number); } /// `BinkPause`: stops the movie at `now`, its sound too, or plays it on from where it stopped. diff --git a/src/engine/bink/picture.zig b/src/engine/bink/picture.zig index 01683dc9..997151ea 100644 --- a/src/engine/bink/picture.zig +++ b/src/engine/bink/picture.zig @@ -50,7 +50,7 @@ const Factors = struct { }; const green = 1 - red - blue; const black: i32, const luma: f64, const chroma: f64 = switch (colour.range) { - .limited => .{ 16, 255.0 / 219.0, 255.0 / 224.0 }, + .limited => .{ 16, 255.0 / (235.0 - 16.0), 255.0 / (240.0 - 16.0) }, .full => .{ 0, 1, 1 }, }; return .{ diff --git a/src/engine/files.zig b/src/engine/files.zig index 4dc284bf..533b94db 100644 --- a/src/engine/files.zig +++ b/src/engine/files.zig @@ -82,12 +82,10 @@ pub fn readFile(io: Io, gpa: Allocator, dir: Io.Dir, path: []const u8, limit: Io /// Reads the start of the game's file `path` under `dir`, found as `readFile` finds it, into /// `buffer`: as much as fits, or the whole file where it is shorter. Null where there's no such /// file. -pub fn readHead(io: Io, dir: Io.Dir, path: []const u8, buffer: []u8) (Io.File.OpenError || Io.File.ReadPositionalError)!?[]u8 { +pub fn readHead(io: Io, dir: Io.Dir, path: []const u8, buffer: []u8) Io.Dir.ReadFileError!?[]u8 { var name_buffer: [max_path]u8 = undefined; const spelled = find(io, dir, path, &name_buffer) orelse return null; - const file = try dir.openFile(io, spelled, .{}); - defer file.close(io); - return buffer[0..try file.readPositionalAll(io, buffer, 0)]; + return try dir.readFile(io, spelled, buffer); } /// Writes `data` to the game's file `path` under `dir`, as Windows does: over the file `find` finds, diff --git a/src/engine/game/bigfile.zig b/src/engine/game/bigfile.zig index a72e2941..cbc85e66 100644 --- a/src/engine/game/bigfile.zig +++ b/src/engine/game/bigfile.zig @@ -73,6 +73,15 @@ pub const Hog = struct { const entry = archive.archive.find(name) orelse return null; return try archive.archive.readRaw(gpa, entry); } + + /// Reads the start of the member for `name`, as `readStored` finds it, into `buffer`: as much + /// as fits, or the whole member where it is shorter. Null if neither a mod nor the archive has + /// it. + pub fn readStoredHead(archive: Hog, name: []const u8, buffer: []u8) ReadError!?[]u8 { + if (try archive.mods.readStoredHead(name, buffer)) |head| return head; + const entry = archive.archive.find(name) orelse return null; + return try archive.archive.readHead(entry, buffer); + } }; /// `hog_open` (`0x004C7E20`): the archive at `path` under `dir`. The game opens it with `fopen` and diff --git a/src/engine/game/bigfile/mods.zig b/src/engine/game/bigfile/mods.zig index 1d82a93f..f04959d4 100644 --- a/src/engine/game/bigfile/mods.zig +++ b/src/engine/game/bigfile/mods.zig @@ -309,7 +309,7 @@ pub const Mod = struct { fn readHead(mod: Mod, file: usize, buffer: []u8) bigfile.ReadError![]u8 { return switch (mod.source) { .archive => |archive| try archive.readHead(archive.entries[file], buffer), - .folder => |folder| try folder.readHead(folder.names[file], buffer), + .folder => |folder| try folder.dir.readFile(folder.io, folder.names[file], buffer), }; } @@ -453,14 +453,6 @@ pub const Folder = struct { folder.dir.close(folder.io); } - /// Reads the start of its file `name` into `buffer`: as much as fits, or the whole file where - /// it is shorter. - fn readHead(folder: Folder, name: []const u8, buffer: []u8) bigfile.ReadError![]u8 { - const file = try folder.dir.openFile(folder.io, name, .{}); - defer file.close(folder.io); - return buffer[0..try file.readPositionalAll(folder.io, buffer, 0)]; - } - /// Reads its file `name` as `reading` says. Decompressing reads it the same way as the member /// `hog.packMember` would make of it: RefPack data that the game would decompress, such as a /// member extracted with `--raw`, is decompressed, and any other file is read as it is. @@ -626,9 +618,9 @@ pub const Mods = struct { return try mods.list[found.mod].read(gpa, found.file, .stored); } - /// Reads the start of the mod file `name` as `readStored` reads it, into `buffer`: as much as + /// Reads the start of the mod file `name`, as `readStored` reads it, into `buffer`: as much as /// fits, or the whole file where it is shorter. Null if no mod has it. - pub fn readHead(mods: *const Mods, name: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { + pub fn readStoredHead(mods: *const Mods, name: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { const found = mods.place(name) orelse return null; return try mods.list[found.mod].readHead(found.file, buffer); } @@ -637,7 +629,7 @@ pub const Mods = struct { /// much as fits, or the whole file where it is shorter. Null if neither a mod nor the game /// folder has it. pub fn readLooseHead(mods: *const Mods, io: Io, dir: Io.Dir, path: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { - if (try mods.readHead(files.leaf(path), buffer)) |head| return head; + if (try mods.readStoredHead(files.leaf(path), buffer)) |head| return head; return files.readHead(io, dir, path, buffer); } @@ -938,9 +930,7 @@ const GameFiles = struct { const stem = textureStem(name) orelse return null; for (std.enums.values(srtexture.MapFile)) |kind| { const texture = withoutSuffix(stem, kind.suffix()) orelse continue; - var buffer: [files.max_path]u8 = undefined; - const picture = std.mem.print(&buffer, "{s}" ++ srtexture.picture_extension, .{texture}) catch continue; - if (gathered.kindOf(picture) == .texture) return .{ .texture = texture, .kind = kind }; + if (gathered.has(.texture, texture, srtexture.picture_extension)) return .{ .texture = texture, .kind = kind }; } return null; } @@ -953,34 +943,22 @@ const GameFiles = struct { /// The numbered part of a game file with `extension` that `stem` names (`numbered.parse`); /// null if the game has no such file, or `stem` names no part. - fn partOf(gathered: GameFiles, stem: []const u8, comptime extension: []const u8) ?numbered.Part { + fn partOf(gathered: GameFiles, stem: []const u8, extension: []const u8) ?numbered.Part { const part = numbered.parse(stem) orelse return null; - var buffer: [files.max_path]u8 = undefined; - const file = std.mem.print(&buffer, "{s}" ++ extension, .{part.file}) catch return null; - return if (gathered.kindOf(file) == .file) part else null; + return if (gathered.has(.file, part.file, extension)) part else null; } /// The name, without the extension, of the game's TGA picture that the file `name`, /// `.png`, replaces; null if it doesn't replace one. fn pictureOf(gathered: GameFiles, name: []const u8) ?[]const u8 { - const stem = pictureStem(name) orelse return null; - var buffer: [files.max_path]u8 = undefined; - const picture = std.mem.print(&buffer, "{s}" ++ tga.extension, .{stem}) catch return null; - if (gathered.kindOf(picture) != .file) return null; - return stem; + return gathered.stemReplacing(name, &.{srtexture.picture_extension}, tga.extension); } /// The name, without the extension, of the game's font that the outline font `name`, /// `.ttf` or `.otf` (`fnt.outline_extensions`), replaces; null if it doesn't /// replace one. fn fontOf(gathered: GameFiles, name: []const u8) ?[]const u8 { - for (fnt.outline_extensions) |extension| { - const stem = withoutSuffix(name, extension) orelse continue; - var buffer: [files.max_path]u8 = undefined; - const font = std.mem.print(&buffer, "{s}" ++ fnt.extension, .{stem}) catch return null; - if (gathered.kindOf(font) == .file) return stem; - } - return null; + return gathered.stemReplacing(name, &fnt.outline_extensions, fnt.extension); } /// The file `name` without the extension of a texture's picture, a PNG, DDS or KTX2 file; null @@ -1014,9 +992,7 @@ const GameFiles = struct { fn recordingOf(gathered: GameFiles, name: []const u8) ?Replaced { const stem = recordingStem(name) orelse return null; for (recordings.replaced_extensions) |extension| { - var buffer: [files.max_path]u8 = undefined; - const replaced = std.mem.print(&buffer, "{s}{s}", .{ stem, extension }) catch continue; - if (gathered.kindOf(replaced) == .file) return .{ .stem = stem, .extension = extension }; + if (gathered.has(.file, stem, extension)) return .{ .stem = stem, .extension = extension }; } return null; } @@ -1025,11 +1001,16 @@ const GameFiles = struct { /// `.webm` or `.mkv` (`movie.modern_extensions`), replaces; null if it doesn't /// replace one. fn movieOf(gathered: GameFiles, name: []const u8) ?[]const u8 { - for (movie.modern_extensions) |extension| { + return gathered.stemReplacing(name, &movie.modern_extensions, bink.extension); + } + + /// The name, without the extension, of the game file that the mod's file `name` replaces, + /// where `name` ends in one of `extensions` and the game file in `replaced`; null if it + /// doesn't replace one. + fn stemReplacing(gathered: GameFiles, name: []const u8, extensions: []const []const u8, replaced: []const u8) ?[]const u8 { + for (extensions) |extension| { const stem = withoutSuffix(name, extension) orelse continue; - var buffer: [files.max_path]u8 = undefined; - const replaced = std.mem.print(&buffer, "{s}" ++ bink.extension, .{stem}) catch return null; - if (gathered.kindOf(replaced) == .file) return stem; + if (gathered.has(.file, stem, replaced)) return stem; } return null; } @@ -1046,6 +1027,13 @@ const GameFiles = struct { return withoutSuffix(name, srtexture.picture_extension); } + /// Whether the game has a file of `kind` named `stem` with `extension`, ignoring case. + fn has(gathered: GameFiles, kind: Kind, stem: []const u8, extension: []const u8) bool { + var buffer: [files.max_path]u8 = undefined; + const name = std.mem.print(&buffer, "{s}{s}", .{ stem, extension }) catch return false; + return gathered.kindOf(name) == kind; + } + /// The kind of the game file named `name`, ignoring case; null if there's no such file. fn kindOf(gathered: GameFiles, name: []const u8) ?Kind { var buffer: [files.max_path]u8 = undefined; diff --git a/src/engine/game/interface/disc.zig b/src/engine/game/interface/disc.zig index c7d9b21d..062493b5 100644 --- a/src/engine/game/interface/disc.zig +++ b/src/engine/game/interface/disc.zig @@ -87,10 +87,8 @@ pub const Disc = struct { /// fits, or the whole member where it is shorter. Null if neither a mod nor the open archive /// has it. pub fn readStoredHead(disc: Disc, name: []const u8, buffer: []u8) bigfile.ReadError!?[]u8 { - if (try disc.mods.readHead(name, buffer)) |head| return head; - const hog = disc.hog orelse return null; - const entry = hog.archive.find(name) orelse return null; - return try hog.archive.readHead(entry, buffer); + const hog = disc.hog orelse return disc.mods.readStoredHead(name, buffer); + return hog.readStoredHead(name, buffer); } /// Reads the file that `name` names in the open archive, decompressing RefPack data diff --git a/src/engine/game/xtrabits/movie.zig b/src/engine/game/xtrabits/movie.zig index 08cab21d..6317c71a 100644 --- a/src/engine/game/xtrabits/movie.zig +++ b/src/engine/game/xtrabits/movie.zig @@ -40,6 +40,9 @@ const matroska = @import("../../../formats/matroska.zig"); const log = std.log.scoped(.movies); +/// What the log says of a movie that can't be played: its name, and why. +const left_out = "the movie {s} is left out: {s}"; + /// The movies `renderer_load` plays as the renderer first starts (`0x004AB4D5` on), before its /// loading screens. pub const intro = [_][]const u8{ "new_nms.bik", "new_dalogo_fs_uncmpr.bik", "warty_.bik" }; @@ -266,24 +269,24 @@ pub const Player = struct { /// movie it replaces would be, at that movie's size (`size`). One that can't be played is left /// out, which the log says, and the Bink file plays. pub fn load(gpa: Allocator, codec: bink.Codec, archive: *const disc.Disc, name: []const u8, kind: Kind, sound: ?mss.Driver, look: bink.Look) ?Player { - const source = kind.source(); if (loadModern(gpa, codec, archive, name, kind, sound, look)) |player| return player; + const source = kind.source(); const found = switch (source) { .folder => archive.mods.readLoose(archive.io, gpa, archive.directory, name, .limited(files.max_file_size)), .disc => archive.readStored(gpa, name), } catch |err| { - log.warn("the movie {s} is left out: {s}", .{ name, @errorName(err) }); + log.warn(left_out, .{ name, @errorName(err) }); return null; }; const file = found orelse { - log.warn("the movie {s} is left out: {s}", .{ name, switch (source) { + log.warn(left_out, .{ name, switch (source) { .folder => "the game's folder has none", .disc => "no disc's archive open holds it", } }); return null; }; return open(gpa, codec, file, kind, sound, look) catch |err| { - log.warn("the movie {s} is left out: {s}", .{ name, @errorName(err) }); + log.warn(left_out, .{ name, problem(err) }); return null; }; } @@ -299,11 +302,11 @@ pub const Player = struct { // A name longer than any path the game builds is in no mod (`bigfile.Mods`). const modern = std.mem.print(&buffer, "{s}{s}", .{ files.stem(name), extension }) catch return null; const file = archive.mods.readFile(gpa, modern) catch |err| { - log.warn("the movie {s} is left out: {s}", .{ modern, @errorName(err) }); + log.warn(left_out, .{ modern, @errorName(err) }); continue; } orelse continue; var player = open(gpa, codec, file, kind, sound, look) catch |err| { - log.warn("the movie {s} is left out: {s}", .{ modern, problem(err) }); + log.warn(left_out, .{ modern, problem(err) }); continue; }; if (player.bink.silent) |unplayed| log.warn("the movie {s} plays without its sound: it is {s}, and OpenReliant plays Opus and Vorbis", .{ modern, unplayed }); @@ -325,7 +328,8 @@ pub const Player = struct { return .{ header.width, header.height }; } - /// What the log says of an error opening a mod's movie in another format. + /// What the log says of an error opening a movie: why a mod's movie in another format can't + /// be played, or the error's name. fn problem(err: bink.Error) []const u8 { return switch (err) { error.UnsupportedVideo => "its video isn't VP9 of profile 0, 8 bits with 4:2:0 colour", diff --git a/src/formats/matroska.zig b/src/formats/matroska.zig index 519b8892..897f0b90 100644 --- a/src/formats/matroska.zig +++ b/src/formats/matroska.zig @@ -30,7 +30,7 @@ pub const Error = error{ /// The IDs of the elements OpenReliant reads, with the bits that mark their length, as the /// specifications write them. -pub const Id = enum(u32) { +const Id = enum(u32) { ebml = 0x1A45DFA3, doc_type = 0x4282, segment = 0x18538067, @@ -105,7 +105,8 @@ pub const Movie = struct { else => {}, }; const video = read.video orelse return error.NoVideo; - if (video.encoded or (read.audio != null and read.audio.?.encoded)) return error.Encoded; + if (video.encoded) return error.Encoded; + if (read.audio) |audio| if (audio.encoded) return error.Encoded; var frames: std.ArrayList(Frame) = .empty; defer frames.deinit(gpa); @@ -533,7 +534,7 @@ fn float(data: []const u8) Error!f64 { /// Matroska files for the tests, built by hand. pub const testing = struct { /// Appends the element `id` holding `data` to `file`, its size in 8 bytes. - pub fn element(gpa: Allocator, file: *std.ArrayList(u8), id: Id, data: []const u8) Allocator.Error!void { + fn element(gpa: Allocator, file: *std.ArrayList(u8), id: Id, data: []const u8) Allocator.Error!void { try appendId(gpa, file, id); try file.append(gpa, 0x01); try file.appendSlice(gpa, std.mem.asBytes(&std.mem.nativeToBig(u64, data.len))[1..]); @@ -541,7 +542,7 @@ pub const testing = struct { } /// Appends the element `id` holding the unsigned integer `value` to `file`. - pub fn unsignedElement(gpa: Allocator, file: *std.ArrayList(u8), id: Id, value: u64) Allocator.Error!void { + fn unsignedElement(gpa: Allocator, file: *std.ArrayList(u8), id: Id, value: u64) Allocator.Error!void { try element(gpa, file, id, &std.mem.toBytes(std.mem.nativeToBig(u64, value))); } @@ -553,7 +554,7 @@ pub const testing = struct { } /// A simple block of track `track`, `relative` ticks into its cluster, holding `frame`. - pub fn simpleBlock(gpa: Allocator, file: *std.ArrayList(u8), track: u8, relative: i16, keyframe: bool, frame: []const u8) Allocator.Error!void { + fn simpleBlock(gpa: Allocator, file: *std.ArrayList(u8), track: u8, relative: i16, keyframe: bool, frame: []const u8) Allocator.Error!void { var block: std.ArrayList(u8) = .empty; defer block.deinit(gpa); try block.append(gpa, 0x80 | track); diff --git a/src/platform/video.zig b/src/platform/video.zig index 8b2f8283..e6f2973e 100644 --- a/src/platform/video.zig +++ b/src/platform/video.zig @@ -38,9 +38,9 @@ pub const Decoders = struct { } }; -/// A stream's decoder, the packet and frame it decodes through, and for a video, the frame shown -/// last, which holds where a packet shows none, as one of VP9 that holds a frame kept hidden for -/// the frames after it can. +/// A stream's decoder, and the packet and frame it decodes through. For a video, `shown` keeps the +/// frame shown last, which stays on the screen while a packet shows none: a VP9 packet can hold +/// only a hidden frame, which later frames refer to. const Stream = struct { context: *c.AVCodecContext, packet: *c.AVPacket, @@ -188,10 +188,9 @@ fn picture(_: *anyopaque, handle: bink.Stream, packet: []const u8) bink.Error!bi }; } -/// How `frame`'s levels make colours, as its decoder read them from the video: by BT.709 where the -/// video says BT.709, or SMPTE 240M, which is close to it, and by BT.601 otherwise, as where it -/// says nothing, as Bink's never does; in the full range where it says so, and the limited range -/// otherwise. +/// How `frame`'s levels make colours, as the video says: by BT.709 for BT.709, and for SMPTE 240M, +/// which is close to it; by BT.601 for anything else, including a video that says nothing, such as +/// every Bink movie; and in the full range where it says so, the limited range otherwise. fn colourOf(frame: *const c.AVFrame) bink.picture.Colour { return .{ .matrix = switch (frame.colorspace) { diff --git a/src/root.zig b/src/root.zig index 2811dde9..ad86bd5b 100644 --- a/src/root.zig +++ b/src/root.zig @@ -19,6 +19,7 @@ pub const fnt = @import("formats/fnt.zig"); pub const numbered = @import("formats/numbered.zig"); pub const frc = @import("formats/frc.zig"); pub const hog = @import("formats/hog.zig"); +pub const matroska = @import("formats/matroska.zig"); pub const mp3 = @import("formats/mp3.zig"); pub const ogg = @import("formats/ogg.zig"); pub const refpack = @import("formats/refpack.zig");