Files
RustRaptor/src/cinematics.rs
T

785 lines
29 KiB
Rust

//! Checked original AGX cinematic deltas and a platform-independent 70 Hz player.
//! MDS catalog files are RIFF MIDI streams; they are not the movie format.
use crate::{
assets::{Catalog, Sprite},
simulation::{LegacyRng, SoundEvent},
sound::SoundBank,
};
use anyhow::{Context, Result, ensure};
#[derive(Debug, Clone)]
pub struct DeltaFrame {
fill: Option<u8>,
spans: Vec<(usize, Vec<u8>)>,
}
impl DeltaFrame {
pub fn decode(bytes: &[u8], first: bool) -> Result<Self> {
let fill = if first {
Some(*bytes.first().context("missing cinematic fill byte")?)
} else {
None
};
let mut offset = usize::from(first);
let mut spans = Vec::new();
loop {
let header = bytes
.get(offset..offset + 8)
.context("truncated AGX span header")?;
offset += 8;
let read = |n: usize| u16::from_le_bytes([header[n], header[n + 1]]) as usize;
if read(0) == 0 {
break;
}
let position = read(4);
let length = read(6);
ensure!(
length > 0
&& position
.checked_add(length)
.is_some_and(|end| end <= 320 * 200),
"AGX span exceeds 320x200 canvas"
);
let pixels = bytes
.get(offset..offset + length)
.context("truncated AGX span pixels")?;
offset += length;
spans.push((position, pixels.to_vec()));
ensure!(spans.len() <= 64000, "too many AGX spans");
}
Ok(Self { fill, spans })
}
pub fn apply(&self, canvas: &mut Sprite) -> Result<()> {
ensure!(
canvas.width == 320 && canvas.height == 200 && canvas.pixels.len() == 64000,
"AGX requires original 320x200 canvas"
);
if let Some(fill) = self.fill {
canvas.pixels.fill(fill as u16);
}
for (start, pixels) in &self.spans {
for (destination, source) in canvas.pixels[*start..*start + pixels.len()]
.iter_mut()
.zip(pixels)
{
*destination = *source as u16;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum Kind {
Intro,
OpeningCredits,
Landing,
Death,
CampaignEnd(u8),
}
#[derive(Clone)]
struct ClipSpec {
name: &'static str,
count: u32,
dwell: u32,
repeats: u32,
fade_in: u32,
fade_out: u32,
fade_color: [u8; 3],
background: Option<&'static str>,
picture_palette: Option<&'static str>,
discard_last_frame: bool,
}
fn clip(name: &'static str, count: u32, dwell: u32, repeats: u32) -> ClipSpec {
ClipSpec {
name,
count,
dwell,
repeats,
fade_in: 0,
fade_out: 0,
fade_color: [0; 3],
background: None,
picture_palette: None,
discard_last_frame: false,
}
}
fn movie_clip(name: &'static str, count: u32, frame_rate: u32, repeats: u32) -> ClipSpec {
// Native GFX_WaitUpdate takes frames per second, then waits integer
// 70 / frame_rate service ticks. It does not take a dwell in ticks.
clip(name, count, 70 / frame_rate.clamp(1, 70), repeats)
}
fn specs(kind: Kind) -> Vec<ClipSpec> {
match kind {
Kind::OpeningCredits => {
let mut apogee = clip("APOGEE_PIC", 1, 120, 1);
apogee.fade_in = 64;
apogee.fade_out = 63;
apogee.picture_palette = Some("POGPAL_DAT");
let mut cygnus = clip("CYGNUS_PIC", 1, 195, 1);
cygnus.fade_in = 64;
cygnus.fade_out = 63;
cygnus.picture_palette = Some("CYGPAL_DAT");
vec![apogee, cygnus]
}
Kind::Intro => {
let mut city = movie_clip("CHASE_AGX", 30, 8, 1);
city.fade_in = 128;
let mut side = movie_clip("SHIPSD1_AGX", 20, 18, 2);
side.background = Some("FX_JETSND");
let mut pilot = movie_clip("PILOT_AGX", 21, 10, 1);
pilot.background = Some("FX_IJETSND");
let mut side_before_fire = side.clone();
side_before_fire.repeats = 1;
// MOVIE_Play stops the prelude's background patch. The firing
// half of INTRO_Side2 does not start that patch again.
let firing = movie_clip("SHIPSD2_AGX", 20, 18, 1);
let mut explosion = movie_clip("EXPLO_AGX", 22, 12, 1);
explosion.background = Some("FX_EJETSND");
explosion.fade_out = 60;
explosion.fade_color = [255, 113, 12];
vec![city, side, pilot, side_before_fire, firing, explosion]
}
Kind::Landing => {
let mut landing = movie_clip("LANDING_AGX", 33, 10, 1);
landing.fade_out = 64;
// The native last FRAME starts with M_FADEOUT, which fades the
// previously displayed frame and never renders its own delta.
landing.discard_last_frame = true;
vec![landing]
}
Kind::Death => {
let mut ground = movie_clip("SDEATH_AGX", 6, 3, 8);
ground.fade_out = 100;
vec![movie_clip("DOWN_AGX", 30, 11, 1), ground]
}
Kind::CampaignEnd(sector) => {
let mut ending = match sector {
0 => movie_clip("GAME1END_AGX", 5, 4, 8),
1 => movie_clip("GAME2END_AGX", 25, 4, 1),
_ => movie_clip("GAME3END_AGX", 39, 8, 1),
};
ending.fade_out = 120;
if sector >= 2 {
ending.background = Some("FX_JETSND");
}
let mut base = movie_clip("BASE_AGX", 30, 10, 1);
base.fade_in = 128;
let mut clips = vec![ending, base];
clips.extend(specs(Kind::Landing));
clips
}
}
}
struct Clip {
spec: ClipSpec,
frames: Vec<DeltaFrame>,
background: Option<(SoundEvent, u64)>,
palette: Option<[[u8; 3]; 256]>,
}
#[derive(Clone, Copy)]
enum Phase {
FadeIn(u32),
Frame(u32),
FadeOut(u32),
Finished,
}
#[derive(Debug, Clone)]
pub enum CinematicAudioEvent {
Play(SoundEvent),
Stop(u32),
}
pub struct Cinematic {
pub image: Sprite,
/// Ordered patches and stops; draining two separate queues would reorder
/// an outgoing clip's stop after the next clip starts the same patch.
pub audio_events: Vec<CinematicAudioEvent>,
pub music_events: Vec<(&'static str, bool)>,
clips: Vec<Clip>,
current: usize,
frame: usize,
repetition: u32,
phase: Phase,
clock: u64,
ticks: u64,
background_at: u64,
rng: LegacyRng,
}
impl Cinematic {
pub fn load(catalog: &Catalog, kind: Kind) -> Result<Self> {
let mut clips = Vec::new();
let mut total = 0_usize;
let mut sound_bank = SoundBank::default();
for mut spec in specs(kind) {
let start = catalog.id(spec.name)?;
let mut frames = Vec::new();
let palette = if let Some(name) = spec.picture_palette {
let picture = Sprite::decode(&catalog.named(spec.name)?)?;
ensure!(
picture.width == 320 && picture.height == 200,
"opening logo requires 320x200 canvas"
);
let pixels = picture
.pixels
.into_iter()
.map(|p| p.min(255) as u8)
.collect();
frames.push(DeltaFrame {
fill: Some(0),
spans: vec![(0, pixels)],
});
if spec.name == "APOGEE_PIC" {
let score = crate::audio::MusScore::decode(&catalog.named("APOGEE_MUS")?)?;
spec.dwell = score
.groups
.last()
.map_or(120, |(tick, _)| (*tick + 1).max(120));
}
Some(crate::assets::decode_palette(&catalog.named(name)?)?)
} else {
for index in 0..spec.count {
let bytes = catalog.read(start + index)?;
total += bytes.len();
ensure!(total <= 32 * 1024 * 1024, "cinematic exceeds 32 MiB");
frames.push(
DeltaFrame::decode(&bytes, index == 0)
.with_context(|| format!("{} frame {}", spec.name, index))?,
);
}
None
};
if spec.discard_last_frame {
frames.pop();
}
let background = if let Some(name) = spec.background {
let event = sound(name, 127, &mut LegacyRng::new(0))
.with_context(|| format!("unknown cinematic background sound {name}"))?;
// FX IDs are zero-byte catalog markers; the sound bank
// resolves their +4 digital device variants.
let sample = sound_bank.original(catalog, event.asset)?;
let ticks = sample.duration_ticks(event.pitch, 70).max(1);
Some((event, ticks))
} else {
None
};
clips.push(Clip {
spec,
frames,
background,
palette,
});
}
let mut result = Self {
image: Sprite::blank(320, 200),
audio_events: Vec::new(),
music_events: Vec::new(),
clips,
current: 0,
frame: 0,
repetition: 0,
phase: Phase::Finished,
clock: 0,
ticks: 0,
background_at: 0,
rng: LegacyRng::new(0),
};
result.begin()?;
Ok(result)
}
fn begin(&mut self) -> Result<()> {
if self.current == self.clips.len() {
self.phase = Phase::Finished;
return Ok(());
}
self.frame = 0;
self.repetition = 0;
self.clips[self.current].frames[0].apply(&mut self.image)?;
let spec = &self.clips[self.current].spec;
self.phase = if spec.fade_in > 0 {
Phase::FadeIn(spec.fade_in)
} else {
Phase::Frame(spec.dwell)
};
if let Some((event, period)) = &self.clips[self.current].background {
self.audio_events
.push(CinematicAudioEvent::Play(event.clone()));
self.background_at = self.ticks + period;
}
Ok(())
}
fn end_clip(&mut self) {
let clip = &self.clips[self.current];
if let Some((event, _)) = &clip.background {
self.audio_events
.push(CinematicAudioEvent::Stop(event.asset));
}
match clip.spec.name {
"CHASE_AGX" => self.audio_events.push(CinematicAudioEvent::Stop(129)),
"SHIPSD2_AGX" => self.audio_events.push(CinematicAudioEvent::Stop(144)),
_ => {}
}
}
fn frame_sound(&mut self) {
let name = self.clips[self.current].spec.name;
let sound_name = match name {
"CHASE_AGX" if self.frame == 4 || self.frame == 9 => Some("FX_FLYBY"),
"SHIPSD2_AGX" if self.frame > 1 => Some("FX_INTROGUN"),
"EXPLO_AGX" if self.frame == 21 || self.frame >= 8 && self.frame % 2 == 1 => {
Some("FX_AIREXPLO")
}
"EXPLO_AGX" if (2..10).contains(&self.frame) => Some("FX_INTROHIT"),
"GAME2END_AGX" if self.frame == 22 || self.frame == 24 => Some("FX_AIREXPLO"),
"GAME3END_AGX" if self.frame == 30 || self.frame == 32 => Some("FX_AIREXPLO"),
_ => None,
};
let pan = match (name, self.frame) {
("CHASE_AGX", 4) => 210,
("CHASE_AGX", 9) => 100,
("GAME3END_AGX", _) => 100,
("EXPLO_AGX", frame) if (2..10).contains(&frame) => 110 + self.rng.next_value() % 40,
_ => 127,
};
if let Some(event) = sound_name.and_then(|name| sound(name, pan, &mut self.rng)) {
self.audio_events.push(CinematicAudioEvent::Play(event));
}
}
pub fn advance(&mut self, nanos: u64) -> Result<()> {
self.clock = self.clock.saturating_add(nanos.min(1_000_000_000) * 70);
while self.clock >= 1_000_000_000 && !self.finished() {
self.clock -= 1_000_000_000;
self.ticks += 1;
if let Some((event, period)) = &self.clips[self.current].background {
if self.ticks >= self.background_at {
self.audio_events
.push(CinematicAudioEvent::Play(event.clone()));
self.background_at = self.ticks + period;
}
}
match self.phase {
Phase::FadeIn(n) if n > 1 => self.phase = Phase::FadeIn(n - 1),
Phase::FadeIn(_) => {
let spec = &self.clips[self.current].spec;
if spec.name == "APOGEE_PIC" {
self.music_events.push(("APOGEE_MUS", false));
}
self.phase = Phase::Frame(spec.dwell);
}
Phase::Frame(n) if n > 1 => {
let spec = &self.clips[self.current].spec;
if spec.name == "CYGNUS_PIC" {
let elapsed = spec.dwell - n + 1;
if elapsed == 3 || elapsed == 120 {
if let Some(event) = sound("FX_BOSS1", 127, &mut self.rng) {
self.audio_events.push(CinematicAudioEvent::Play(event));
}
}
if elapsed == 135 {
self.music_events.push(("RINTRO_MUS", true));
}
}
self.phase = Phase::Frame(n - 1);
}
Phase::Frame(_) => {
// MOVIE_Play dispatches a frame's patch after its timed
// display, before processing the frame's final fade.
self.frame_sound();
if self.frame + 1 < self.clips[self.current].frames.len() {
self.frame += 1;
} else if self.repetition + 1 < self.clips[self.current].spec.repeats {
self.repetition += 1;
self.frame = 0;
} else {
let fade = self.clips[self.current].spec.fade_out;
if fade > 0 {
self.phase = Phase::FadeOut(fade);
} else {
self.end_clip();
self.current += 1;
self.begin()?;
}
continue;
}
self.clips[self.current].frames[self.frame].apply(&mut self.image)?;
self.phase = Phase::Frame(self.clips[self.current].spec.dwell);
}
Phase::FadeOut(n) if n > 1 => self.phase = Phase::FadeOut(n - 1),
Phase::FadeOut(_) => {
self.end_clip();
self.current += 1;
self.begin()?;
}
Phase::Finished => {}
}
}
Ok(())
}
pub fn finished(&self) -> bool {
matches!(self.phase, Phase::Finished)
}
/// Active standalone picture and its source palette, before any fade.
/// AGX delta films retain their original indexed frame composition.
pub fn still_picture(&self) -> Option<(&'static str, &[[u8; 3]; 256])> {
let clip = self.clips.get(self.current)?;
Some((clip.spec.name, clip.palette.as_ref()?))
}
pub fn palette(&self, base: &[[u8; 3]; 256]) -> [[u8; 3]; 256] {
let base = self
.clips
.get(self.current)
.and_then(|clip| clip.palette.as_ref())
.unwrap_or(base);
let (progress, total, target) = match self.phase {
Phase::FadeIn(remaining) => {
let total = self.clips[self.current].spec.fade_in;
(total - remaining, total, [0; 3])
}
Phase::FadeOut(remaining) => {
let spec = &self.clips[self.current].spec;
(remaining, spec.fade_out, spec.fade_color)
}
_ => return *base,
};
std::array::from_fn(|i| {
std::array::from_fn(|j| {
((base[i][j] as u32 * progress + target[j] as u32 * (total - progress))
/ total.max(1)) as u8
})
})
}
}
fn sound(name: &str, pan: i32, rng: &mut LegacyRng) -> Option<SoundEvent> {
let definition = crate::legacy_tables::sounds()
.into_iter()
.find(|sound| sound.name == name)?;
let pitch = definition.pitch
+ if definition.random {
rng.next_value() % 40 - 20
} else {
0
};
Some(SoundEvent {
asset: definition.asset,
pan,
pitch,
volume: definition.volume,
priority: definition.priority,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_player(specs: Vec<ClipSpec>) -> Cinematic {
let clips = specs
.into_iter()
.map(|spec| {
let count = spec.count - u32::from(spec.discard_last_frame);
let frames = (0..count)
.map(|frame| {
DeltaFrame::decode(&delta(frame == 0, 0, &[(frame + 1) as u8]), frame == 0)
.unwrap()
})
.collect();
let palette = spec.picture_palette.map(|_| [[80, 120, 200]; 256]);
Clip {
spec,
frames,
background: None,
palette,
}
})
.collect();
let mut player = Cinematic {
image: Sprite::blank(320, 200),
audio_events: vec![],
music_events: vec![],
clips,
current: 0,
frame: 0,
repetition: 0,
phase: Phase::Finished,
clock: 0,
ticks: 0,
background_at: 0,
rng: LegacyRng::new(0),
};
player.begin().unwrap();
player
}
fn drain_patches(player: &mut Cinematic) -> impl Iterator<Item = SoundEvent> + use<> {
std::mem::take(&mut player.audio_events)
.into_iter()
.filter_map(|event| match event {
CinematicAudioEvent::Play(event) => Some(event),
CinematicAudioEvent::Stop(_) => None,
})
}
fn tick(player: &mut Cinematic) {
// Round upward by less than a nanosecond so each call services one
// 70 Hz tick, without losing a tick to integer division.
player.advance(1_000_000_000_u64.div_ceil(70)).unwrap();
}
fn delta(first: bool, offset: u16, pixels: &[u8]) -> Vec<u8> {
let mut bytes = if first { vec![5] } else { vec![] };
for value in [1_u16, 0, offset, pixels.len() as u16] {
bytes.extend(value.to_le_bytes());
}
bytes.extend(pixels);
bytes.extend([0; 8]);
bytes
}
#[test]
fn deltas_preserve_unchanged_pixels_and_initial_fill() {
let mut canvas = Sprite::blank(320, 200);
DeltaFrame::decode(&delta(true, 4, &[1, 2, 3]), true)
.unwrap()
.apply(&mut canvas)
.unwrap();
assert_eq!(&canvas.pixels[..8], &[5, 5, 5, 5, 1, 2, 3, 5]);
DeltaFrame::decode(&delta(false, 5, &[9]), false)
.unwrap()
.apply(&mut canvas)
.unwrap();
assert_eq!(&canvas.pixels[4..8], &[1, 9, 3, 5]);
}
#[test]
fn malformed_delta_cannot_write_outside_canvas() {
assert!(DeltaFrame::decode(&delta(false, 63999, &[1, 2]), false).is_err());
let valid = delta(false, 5, &[1, 2]);
for length in 0..valid.len() - 8 {
assert!(DeltaFrame::decode(&valid[..length], false).is_err());
}
}
#[test]
fn player_timing_is_independent_of_render_rate() {
fn player() -> Cinematic {
let frames = vec![
DeltaFrame::decode(&delta(true, 0, &[1]), true).unwrap(),
DeltaFrame::decode(&delta(false, 0, &[2]), false).unwrap(),
];
let mut p = Cinematic {
image: Sprite::blank(320, 200),
audio_events: vec![],
music_events: vec![],
clips: vec![Clip {
spec: clip("fixture", 2, 8, 2),
frames,
background: None,
palette: None,
}],
current: 0,
frame: 0,
repetition: 0,
phase: Phase::Finished,
clock: 0,
ticks: 0,
background_at: 0,
rng: LegacyRng::new(0),
};
p.begin().unwrap();
p
}
let mut a = player();
let mut b = player();
a.advance(300_000_000).unwrap();
for _ in 0..30 {
b.advance(10_000_000).unwrap();
}
assert_eq!(a.image, b.image);
assert_eq!(a.frame, b.frame);
assert_eq!(a.repetition, b.repetition);
assert_eq!(a.ticks, b.ticks);
}
#[test]
fn original_movie_rates_and_frame_cues_follow_native_wait_update() {
let expected = [
(Kind::Intro, 925),
(Kind::Landing, 288),
(Kind::Death, 1384),
(Kind::CampaignEnd(0), 1426),
(Kind::CampaignEnd(1), 1171),
(Kind::CampaignEnd(2), 1058),
];
for (kind, ticks) in expected {
let mut movie = synthetic_player(specs(kind));
while !movie.finished() {
tick(&mut movie);
}
assert_eq!(movie.ticks, ticks, "{kind:?}");
}
let mut movie = synthetic_player(specs(Kind::Intro));
let mut cues = Vec::new();
while !movie.finished() {
tick(&mut movie);
for event in drain_patches(&mut movie) {
cues.push((movie.ticks, event.asset, event.pan));
}
}
assert_eq!(&cues[..2], &[(168, 129, 210), (208, 129, 100)]);
assert_eq!(cues[2], (704, 144, 127));
assert_eq!(cues[19], (755, 144, 127));
assert_eq!(cues[20].0, 770);
assert!((110..150).contains(&cues[20].2));
assert_eq!(cues.last(), Some(&(865, 104, 127)));
assert!(specs(Kind::Intro)[4].background.is_none());
}
#[test]
fn background_stop_precedes_the_next_clip_play_even_in_a_batched_advance() {
let mut movie = synthetic_player(vec![clip("first", 1, 2, 1), clip("second", 1, 2, 1)]);
for clip in &mut movie.clips {
clip.background = Some((
sound("FX_JETSND", 127, &mut LegacyRng::new(0)).unwrap(),
100,
));
}
movie.begin().unwrap();
movie.advance(100_000_000).unwrap();
let commands: Vec<_> = std::mem::take(&mut movie.audio_events)
.into_iter()
.map(|event| match event {
CinematicAudioEvent::Play(event) => ("play", event.asset),
CinematicAudioEvent::Stop(asset) => ("stop", asset),
})
.collect();
assert_eq!(
commands,
[("play", 149), ("stop", 149), ("play", 149), ("stop", 149)]
);
assert!(movie.finished());
}
#[test]
fn opening_logos_have_their_own_palettes_and_native_music_cue_times() {
let mut movie = synthetic_player(specs(Kind::OpeningCredits));
let base = [[240, 20, 16]; 256];
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
let mut music = Vec::new();
let mut patches = Vec::new();
while !movie.finished() {
tick(&mut movie);
match movie.ticks {
32 => assert_eq!(movie.palette(&base), [[40, 60, 100]; 256]),
64 | 311 => assert_eq!(movie.palette(&base), [[80, 120, 200]; 256]),
247 => assert_eq!(movie.palette(&base), [[0; 3]; 256]),
_ => {}
}
for event in std::mem::take(&mut movie.music_events) {
music.push((movie.ticks, event));
}
for event in drain_patches(&mut movie) {
patches.push((movie.ticks, event.asset));
}
}
assert_eq!(movie.ticks, 569);
assert_eq!(
music,
[(64, ("APOGEE_MUS", false)), (446, ("RINTRO_MUS", true))]
);
assert_eq!(patches, [(314, 179), (431, 179)]);
assert_eq!(movie.palette(&base), base);
}
#[test]
fn still_picture_exposes_each_unfaded_card_palette_and_excludes_agx_frames() {
let mut movie = synthetic_player(specs(Kind::OpeningCredits));
movie.clips[1].palette = Some([[200, 60, 40]; 256]);
let base = [[240, 20, 16]; 256];
let (name, palette) = movie.still_picture().unwrap();
assert_eq!(name, "APOGEE_PIC");
assert_eq!(*palette, [[80, 120, 200]; 256]);
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
for _ in 0..32 {
tick(&mut movie);
}
assert_eq!(movie.palette(&base), [[40, 60, 100]; 256]);
assert_eq!(*movie.still_picture().unwrap().1, [[80, 120, 200]; 256]);
while movie.ticks < 247 {
tick(&mut movie);
}
let (name, palette) = movie.still_picture().unwrap();
assert_eq!(name, "CYGNUS_PIC");
assert_eq!(*palette, [[200, 60, 40]; 256]);
assert_eq!(movie.palette(&base), [[0; 3]; 256]);
while !movie.finished() {
tick(&mut movie);
}
assert!(movie.still_picture().is_none());
let mut intro = synthetic_player(specs(Kind::Intro));
while !intro.finished() {
assert!(intro.still_picture().is_none());
tick(&mut intro);
}
}
#[test]
#[ignore = "requires original local game assets"]
fn original_opening_palettes_and_complete_intro_timeline_are_preserved() {
let root = std::env::var("RAPTOR_TEST_DATA")
.expect("set RAPTOR_TEST_DATA to the original asset directory");
let catalog = Catalog::open(root).unwrap();
let apogee_palette =
crate::assets::decode_palette(&catalog.named("POGPAL_DAT").unwrap()).unwrap();
let cygnus_palette =
crate::assets::decode_palette(&catalog.named("CYGPAL_DAT").unwrap()).unwrap();
let mut movie = Cinematic::load(&catalog, Kind::OpeningCredits).unwrap();
let mut music = Vec::new();
let mut patches = Vec::new();
while !movie.finished() {
tick(&mut movie);
match movie.ticks {
64 => assert_eq!(movie.palette(&catalog.palette().unwrap()), apogee_palette),
708 => assert_eq!(movie.palette(&catalog.palette().unwrap()), cygnus_palette),
_ => {}
}
for event in std::mem::take(&mut movie.music_events) {
music.push((movie.ticks, event));
}
for event in drain_patches(&mut movie) {
patches.push((movie.ticks, event.asset));
}
}
assert_eq!(movie.ticks, 966);
assert_eq!(
music,
[(64, ("APOGEE_MUS", false)), (843, ("RINTRO_MUS", true))]
);
assert_eq!(patches, [(711, 179), (828, 179)]);
let mut intro = Cinematic::load(&catalog, Kind::Intro).unwrap();
for index in [1, 2, 3, 5] {
let (event, period) = intro.clips[index].background.as_ref().unwrap();
assert_eq!(event.asset, 149);
assert!(*period > 0);
}
assert!(intro.clips[4].background.is_none());
let mut cues = Vec::new();
while !intro.finished() {
tick(&mut intro);
for event in drain_patches(&mut intro) {
if event.asset != 149 {
cues.push((intro.ticks, event.asset, event.pan));
}
}
}
assert_eq!(intro.ticks, 925);
assert_eq!(&cues[..2], &[(168, 129, 210), (208, 129, 100)]);
assert_eq!(cues.last(), Some(&(865, 104, 127)));
assert!(intro.music_events.is_empty());
}
#[test]
#[ignore = "requires original local game assets"]
fn all_original_cinematic_sequences_decode() {
let root = std::env::var("RAPTOR_TEST_DATA")
.expect("set RAPTOR_TEST_DATA to the original asset directory");
let catalog = Catalog::open(root).unwrap();
for kind in [
Kind::OpeningCredits,
Kind::Intro,
Kind::Landing,
Kind::Death,
Kind::CampaignEnd(0),
Kind::CampaignEnd(1),
Kind::CampaignEnd(2),
] {
let mut movie = Cinematic::load(&catalog, kind).unwrap();
while !movie.finished() {
movie.advance(1_000_000_000).unwrap();
}
}
}
}