295 lines
10 KiB
Rust
295 lines
10 KiB
Rust
use raptor_replica::{
|
|
audio::{DigitalSample, Mixer},
|
|
content::{ContentId, ContentPack, Registry},
|
|
modding::{self, ActiveMods},
|
|
sound::{self, SoundBank},
|
|
};
|
|
use serde_json::{Value, json};
|
|
use std::{fs, path::Path};
|
|
|
|
fn mus(body: &[u8], channels: u16) -> Vec<u8> {
|
|
let mut bytes = b"MUS\x1a".to_vec();
|
|
bytes.extend((body.len() as u16).to_le_bytes());
|
|
bytes.extend(16_u16.to_le_bytes());
|
|
bytes.extend(channels.to_le_bytes());
|
|
bytes.extend([0; 6]);
|
|
bytes.extend(body);
|
|
bytes
|
|
}
|
|
fn theme(key: u8) -> Vec<u8> {
|
|
mus(
|
|
&[0x40, 0, 0, 0x90, 128 | key, 100, 4, 0x80, key, 4, 0x60],
|
|
1,
|
|
)
|
|
}
|
|
fn pack(value: Value) -> ContentPack {
|
|
serde_json::from_value(value).unwrap()
|
|
}
|
|
fn install(root: &Path, id: &str, content: Value, score: &[u8]) {
|
|
let path = root.join(id);
|
|
fs::create_dir_all(&path).unwrap();
|
|
fs::write(
|
|
path.join("mod.toml"),
|
|
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"1.0.0\"\napi_version = 1\n"),
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
path.join("content.json"),
|
|
serde_json::to_vec_pretty(&content).unwrap(),
|
|
)
|
|
.unwrap();
|
|
fs::write(path.join("theme.mus"), score).unwrap();
|
|
}
|
|
fn selected(ids: &[&str]) -> Vec<String> {
|
|
ids.iter().map(|id| (*id).to_owned()).collect()
|
|
}
|
|
fn error<T>(result: anyhow::Result<T>) -> String {
|
|
match result {
|
|
Ok(_) => panic!("expected failure"),
|
|
Err(error) => format!("{error:#}"),
|
|
}
|
|
}
|
|
fn music_pack() -> Value {
|
|
json!({
|
|
"music":[{"id":"demo:theme","name":"Demo theme","file":"theme.mus"}],
|
|
"missions":[{"id":"demo:mission","name":"Demo mission","duration":100,"music_track":"demo:theme"}]
|
|
})
|
|
}
|
|
fn build(root: &Path, enabled: &[&str]) -> anyhow::Result<ActiveMods> {
|
|
ActiveMods::build(
|
|
&Registry::default(),
|
|
&modding::discover(root),
|
|
&selected(enabled),
|
|
None,
|
|
)
|
|
}
|
|
fn instruments() -> Vec<u8> {
|
|
let mut bytes = b"#OPL_II#".to_vec();
|
|
for _ in 0..175 {
|
|
let mut instrument = [0; 36];
|
|
instrument[2] = 128;
|
|
for part in 0..2 {
|
|
let start = 4 + part * 16;
|
|
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 32]);
|
|
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x0f, 0, 0, 0]);
|
|
}
|
|
bytes.extend(instrument);
|
|
}
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn music_registry_references_defaults_and_selection_are_checked() {
|
|
let mut registry = Registry::default();
|
|
registry.apply(pack(music_pack()), "demo").unwrap();
|
|
registry.validate().unwrap();
|
|
assert!(registry.music[&ContentId::from("demo:theme")].looping);
|
|
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
|
|
mission.music = Some(86);
|
|
assert!(error(registry.validate()).contains("both music and music_track"));
|
|
let mission = registry.missions.get_mut(&"demo:mission".into()).unwrap();
|
|
mission.music = None;
|
|
mission.music_track = Some("missing:theme".into());
|
|
assert!(error(registry.validate()).contains("missing music track missing:theme"));
|
|
}
|
|
|
|
#[test]
|
|
fn music_additions_and_explicit_overrides_use_the_winning_package_files() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "demo", music_pack(), &theme(60));
|
|
install(
|
|
temp.path(),
|
|
"override",
|
|
json!({
|
|
"overrides":["demo:theme"],
|
|
"music":[{"id":"demo:theme","name":"Replacement","file":"theme.mus","looping":false}]
|
|
}),
|
|
&theme(67),
|
|
);
|
|
let active = build(temp.path(), &["demo", "override"]).unwrap();
|
|
assert_eq!(active.registry.owners[&"demo:theme".into()], "override");
|
|
assert!(!active.registry.music[&"demo:theme".into()].looping);
|
|
let mut bank = SoundBank::default();
|
|
let score = bank
|
|
.mod_music(&active.registry, &"demo:theme".into())
|
|
.unwrap();
|
|
assert_eq!(
|
|
score.groups,
|
|
sound::decode_music(&theme(67)).unwrap().groups
|
|
);
|
|
let mut registry = Registry::default();
|
|
registry.apply(pack(music_pack()), "demo").unwrap();
|
|
assert!(
|
|
error(registry.apply(
|
|
pack(json!({"music":[{"id":"demo:theme","name":"Duplicate","file":"theme.mus"}]})),
|
|
"other"
|
|
))
|
|
.contains("explicit override")
|
|
);
|
|
assert!(error(registry.apply(pack(json!({"overrides":["demo:theme"],"sounds":[{"id":"demo:theme","name":"Wrong type","file":"theme.wav"}]})), "other")).contains("cannot change content type"));
|
|
}
|
|
|
|
#[test]
|
|
fn music_files_are_part_of_package_hashes_and_cache_clears_with_active_mods() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "demo", music_pack(), &theme(60));
|
|
let discovery = modding::discover(temp.path());
|
|
let active =
|
|
ActiveMods::build(&Registry::default(), &discovery, &selected(&["demo"]), None).unwrap();
|
|
let mut bank = SoundBank::default();
|
|
let first = bank
|
|
.mod_music(&active.registry, &"demo:theme".into())
|
|
.unwrap();
|
|
fs::write(temp.path().join("demo/theme.mus"), theme(65)).unwrap();
|
|
assert_ne!(
|
|
discovery.mods["demo"].hash,
|
|
modding::discover(temp.path()).mods["demo"].hash
|
|
);
|
|
assert!(
|
|
error(ActiveMods::build(
|
|
&Registry::default(),
|
|
&discovery,
|
|
&selected(&["demo"]),
|
|
None
|
|
))
|
|
.contains("changed since discovery")
|
|
);
|
|
let cached = bank
|
|
.mod_music(&active.registry, &"demo:theme".into())
|
|
.unwrap();
|
|
assert_eq!(first.groups, cached.groups);
|
|
bank.clear_mods();
|
|
let replaced = bank
|
|
.mod_music(&active.registry, &"demo:theme".into())
|
|
.unwrap();
|
|
assert_ne!(first.groups, replaced.groups);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_malformed_oversized_and_escaping_music_reject_activation() {
|
|
for (file, expected) in [
|
|
("missing.mus", "missing asset"),
|
|
("../outside.mus", "parent traversal"),
|
|
("theme.wav", ".mus file"),
|
|
] {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut content = music_pack();
|
|
content["music"][0]["file"] = json!(file);
|
|
install(temp.path(), "demo", content, &theme(60));
|
|
assert!(error(build(temp.path(), &["demo"])).contains(expected));
|
|
}
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "demo", music_pack(), b"MThd");
|
|
assert!(error(build(temp.path(), &["demo"])).contains("invalid MUS signature"));
|
|
fs::write(
|
|
temp.path().join("demo/theme.mus"),
|
|
vec![0; sound::MAX_MUSIC_BYTES + 1],
|
|
)
|
|
.unwrap();
|
|
assert!(error(build(temp.path(), &["demo"])).contains("128 KiB"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn music_symlinks_cannot_escape_package_ownership() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
install(temp.path(), "demo", music_pack(), &theme(60));
|
|
let outside = temp.path().join("outside.mus");
|
|
fs::write(&outside, theme(67)).unwrap();
|
|
fs::remove_file(temp.path().join("demo/theme.mus")).unwrap();
|
|
std::os::unix::fs::symlink(&outside, temp.path().join("demo/theme.mus")).unwrap();
|
|
assert!(sound::load_music(&temp.path().join("demo"), "theme.mus").is_err());
|
|
assert!(!modding::discover(temp.path()).errors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn mod_mus_has_bounded_events_density_channels_and_duration() {
|
|
assert!(sound::decode_music(&mus(&[0x60], 1)).is_err());
|
|
assert!(sound::decode_music(&mus(&[0x90, 188, 100, 1, 0x60], 16)).is_err());
|
|
let mut simultaneous = vec![0x10, 60].repeat(256);
|
|
simultaneous.extend([0x90, 60, 1, 0x60]);
|
|
assert!(error(sound::decode_music(&mus(&simultaneous, 1))).contains("256 events"));
|
|
let mut events = vec![0x90, 60, 1].repeat(16385);
|
|
events.push(0x60);
|
|
assert!(error(sound::decode_music(&mus(&events, 1))).contains("16384 events"));
|
|
// 126001 encoded as a MUS base-128 time delta.
|
|
assert!(
|
|
error(sound::decode_music(&mus(
|
|
&[0x90, 188, 100, 0x87, 0xd8, 0x31, 0x60],
|
|
1
|
|
)))
|
|
.contains("30 minutes")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mod_music_uses_original_fm_music_volume_and_its_declared_loop() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut content = music_pack();
|
|
content["music"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(json!({"id":"demo:once","name":"One play","file":"theme.mus","looping":false}));
|
|
install(temp.path(), "demo", content, &theme(60));
|
|
let active = build(temp.path(), &["demo"]).unwrap();
|
|
let mut bank = SoundBank::default();
|
|
let score = bank
|
|
.mod_music(&active.registry, &"demo:theme".into())
|
|
.unwrap();
|
|
let mut looping = Mixer::new(44100, &instruments()).unwrap();
|
|
looping.music_volume = 127;
|
|
looping.fx_volume = 0;
|
|
looping.play_with_fade(
|
|
score.clone(),
|
|
active.registry.music[&"demo:theme".into()].looping,
|
|
true,
|
|
);
|
|
let mut first = vec![0; 44100 * 2];
|
|
looping.mix(&mut first);
|
|
assert!(first.iter().any(|value| *value != 0));
|
|
looping.music_volume = 0;
|
|
let mut muted = vec![1; 4096];
|
|
looping.mix(&mut muted);
|
|
assert!(muted.iter().all(|value| *value == 0));
|
|
looping.fx_volume = 127;
|
|
looping.sample(
|
|
DigitalSample {
|
|
rate: 11025,
|
|
data: vec![220; 500],
|
|
},
|
|
127,
|
|
128,
|
|
100,
|
|
1,
|
|
);
|
|
looping.mix(&mut muted);
|
|
assert!(muted.iter().any(|value| *value != 0));
|
|
looping.music_volume = 127;
|
|
looping.fx_volume = 0;
|
|
let mut repeating_tail = vec![0; 44100 * 2];
|
|
looping.mix(&mut repeating_tail);
|
|
assert!(repeating_tail.iter().any(|value| *value != 0));
|
|
let mut once = Mixer::new(44100, &instruments()).unwrap();
|
|
once.music_volume = 127;
|
|
once.play_with_fade(
|
|
score,
|
|
active.registry.music[&"demo:once".into()].looping,
|
|
true,
|
|
);
|
|
once.mix(&mut first);
|
|
let mut ended_tail = vec![0; 44100 * 2];
|
|
once.mix(&mut ended_tail);
|
|
let repeating_energy: u64 = repeating_tail
|
|
.iter()
|
|
.map(|value| value.unsigned_abs() as u64)
|
|
.sum();
|
|
let ended_energy: u64 = ended_tail
|
|
.iter()
|
|
.map(|value| value.unsigned_abs() as u64)
|
|
.sum();
|
|
assert!(
|
|
ended_energy < repeating_energy / 20,
|
|
"ended energy {ended_energy}, loop energy {repeating_energy}"
|
|
);
|
|
}
|