Add independent Rust engine, deterministic mod API and asset pipeline

This commit is contained in:
raptorreplica committed 2026-10-10 12:17:43 +11:00
1 parent f2cce6f51b
commit dbb1d4596b
53 files changed
+18314 -56

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
use raptor_replica::audio::{DigitalSample, Mixer, MusScore};
use std::{fs, path::Path, process::Command};
fn instruments(double: bool) -> Vec<u8> {
let mut bytes = b"#OPL_II#".to_vec();
for n in 0..175 {
let mut instrument = [0u8; 36];
instrument[0] = if n == 1 {
1
} else if double {
4
} else {
0
};
instrument[2] = 128;
instrument[3] = 48;
for part in 0..2 {
let start = 4 + part * 16;
instrument[start..start + 6].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 32]);
instrument[start + 6] = 0;
instrument[start + 7..start + 13].copy_from_slice(&[0x21, 0xf2, 0x52, 0, 0, 0]);
}
bytes.extend(instrument);
}
bytes
}
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(16u16.to_le_bytes());
bytes.extend(channels.to_le_bytes());
bytes.extend([0; 6]);
bytes.extend(body);
bytes
}
fn score() -> Vec<u8> {
mus(
&[
0x40, 0, 0, 0x90, 188, 100, 10, 0xa0, 144, 5, 0xc0, 3, 90, 5, 0x80, 60, 10, 0x60,
],
1,
)
}
fn sample(data: Vec<u8>) -> DigitalSample {
DigitalSample { rate: 11025, data }
}
fn patch(data: &[u8]) -> Vec<u8> {
let mut bytes = vec![3, 0];
bytes.extend(11025u16.to_le_bytes());
bytes.extend(((data.len() + 32) as u32).to_le_bytes());
bytes.extend([0xff; 16]);
bytes.extend(data);
bytes.extend([0; 16]);
bytes
}
fn mixer() -> Mixer {
Mixer::new(44100, &instruments(false)).unwrap()
}
#[test]
fn digital_decoder_excludes_both_padding_regions() {
let decoded = DigitalSample::decode(&patch(&[0, 64, 128, 192, 255])).unwrap();
assert_eq!(decoded.rate, 11025);
assert_eq!(decoded.data, vec![0, 64, 128, 192, 255]);
assert!(DigitalSample::decode(&patch(&[])).is_err());
}
#[test]
fn music_events_start_on_the_first_seventy_hertz_service_tick() {
let mut mixer = mixer();
mixer.music_volume = 127;
mixer.play(MusScore::decode(&score()).unwrap(), false);
let mut prefix = vec![1; 630 * 2];
mixer.mix(&mut prefix);
assert!(prefix.iter().all(|n| *n == 0));
let mut following = vec![0; 3000 * 2];
mixer.mix(&mut following);
assert!(following.iter().any(|n| *n != 0));
}
#[test]
fn fm_and_digital_output_do_not_depend_on_audio_callback_size() {
let table = instruments(true);
let mut whole = Mixer::new(44117, &table).unwrap();
let mut chunked = Mixer::new(44117, &table).unwrap();
for m in [&mut whole, &mut chunked] {
m.play_with_fade(MusScore::decode(&score()).unwrap(), true, true);
m.sample(
sample((0..255).cycle().take(5000).collect()),
37,
192,
100,
1,
);
}
let mut expected = vec![0; 44117 * 2];
whole.mix(&mut expected);
let mut actual = vec![0; expected.len()];
let mut frame = 0;
for count in [1usize, 3, 17, 512, 127].into_iter().cycle() {
let count = count.min(actual.len() / 2 - frame);
chunked.mix(&mut actual[frame * 2..(frame + count) * 2]);
frame += count;
if frame == actual.len() / 2 {
break;
}
}
assert_eq!(actual, expected);
}
#[test]
fn digital_mixing_uses_the_original_11025_hertz_sample_hold() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.sample(sample(vec![255, 255]), 128, 128, 127, 1);
let mut out = [0; 24];
mixer.mix(&mut out);
assert_eq!(&out[..6], &[0; 6]);
assert!(out[6] > 0 && out[7] > 0);
assert_eq!(&out[6..8], &out[8..10]);
assert_eq!(&out[6..8], &out[10..12]);
assert_eq!(&out[6..8], &out[12..14]);
assert_eq!(&out[22..24], &[0, 0]);
}
#[test]
fn pitch_zero_is_a_valid_quarter_speed_pitch() {
let mut normal = mixer();
let mut slow = mixer();
normal.fx_volume = 127;
slow.fx_volume = 127;
normal.sample(sample(vec![255; 8]), 128, 128, 127, 1);
slow.sample(sample(vec![255; 8]), 128, 0, 127, 1);
let mut fast = [0; 100];
let mut quarter = [0; 100];
normal.mix(&mut fast);
slow.mix(&mut quarter);
assert!(fast[80..].iter().all(|v| *v == 0));
assert!(quarter[80..].iter().any(|v| *v > 0));
}
#[test]
fn lower_numeric_digital_priority_wins() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.set_fx_channels(1);
mixer.sample(sample(vec![255; 1000]), 128, 128, 127, 1);
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 8);
let mut protected = [0; 64];
mixer.mix(&mut protected);
assert!(protected[8..].iter().all(|v| *v > 0));
mixer.sample(sample(vec![0; 1000]), 128, 128, 127, 0);
let mut replaced = [0; 64];
mixer.mix(&mut replaced);
assert!(replaced[8..].iter().all(|v| *v < 0));
}
#[test]
fn digital_voices_clip_together_before_being_added_to_fm() {
let mut mixer = mixer();
mixer.fx_volume = 127;
mixer.set_fx_channels(8);
for _ in 0..8 {
mixer.sample(sample(vec![255; 100]), 128, 128, 127, 1);
}
let mut out = [0; 64];
mixer.mix(&mut out);
assert_eq!(out[20], 127 * 128);
assert_eq!(out[21], 127 * 128);
}
#[test]
fn malformed_scores_controller_values_and_missing_end_events_are_rejected() {
for body in [
vec![0x90, 188],
vec![0x90, 188, 200, 1, 0x60],
vec![0xc0, 3, 255, 1, 0x60],
vec![0x30, 9, 0x60],
vec![0x90, 188, 127, 1],
] {
assert!(MusScore::decode(&mus(&body, 1)).is_err());
}
assert!(MusScore::decode(&mus(&[0x60], 16)).is_err());
}
#[test]
fn a_zero_duration_loop_and_an_odd_output_slice_terminate_cleanly() {
let mut mixer = mixer();
mixer.play(MusScore::decode(&mus(&[0x60], 1)).unwrap(), true);
let mut out = [99; 2001];
mixer.mix(&mut out);
assert_eq!(out[2000], 0);
}
#[test]
#[ignore = "requires the original untracked catalog"]
fn original_music_and_sound_assets_decode_locally() {
use raptor_replica::assets::Catalog;
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
let catalog = Catalog::open(root).unwrap();
let mut music = 0;
let mut sounds = 0;
for entry in catalog.entries.values() {
let bytes = catalog.read(entry.id).unwrap();
if bytes.starts_with(b"MUS\x1a") {
MusScore::decode(&bytes).unwrap();
music += 1;
}
if bytes.get(..4) == Some(&[3, 0, 0x11, 0x2b]) {
DigitalSample::decode(&bytes).unwrap();
sounds += 1;
}
}
assert_eq!(music, 16);
assert_eq!(sounds, 26, "digital samples");
Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
}
const NATIVE_REFERENCE: &str = r#"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#include <fstream>
#include <iterator>
#include "SDL.h"
#include "musapi.h"
#include "i_oplmusic.h"
#include "dspapi.h"
#include "cards.h"
int fx_freq=44100, gsshack=0, gssrate=0, sys_midi=0;
musdevice_t mus_device_alsa={}, mus_device_tsf={};
void SND_Lock(){} void SND_Unlock(){} void GSS_Service(){} void GSS_Poll(){}
extern "C" Uint32 SDL_GetTicks(){return 0;}
std::vector<char> read(const char *path){std::ifstream in(path,std::ios::binary);return {std::istreambuf_iterator<char>(in),{}};}
int main(int argc,char **argv){
if(argc!=9)return 2;
auto gen=read(argv[1]),score=read(argv[2]),patch=read(argv[3]);
int frames=std::atoi(argv[4]),mode=std::atoi(argv[5]),fade=std::atoi(argv[6]),loop=std::atoi(argv[7]);
std::vector<int16_t> out(frames*2,0);
if(mode!=1){LoadInstrumentTable(gen.data());MUS_Init(M_ADLIB,0);MUS_SetVolume(127);MUS_PlaySong(score.data(),loop,fade);}
if(mode!=0){DSP_Init(8,11025);DSP_StartPatch((dsp_t*)patch.data(),37,192,85,1);}
if(mode!=1)MUS_Mix(out.data(),frames);
if(mode!=0)DSP_Mix(out.data(),frames);
FILE *file=fopen(argv[8],"wb");if(!file)return 3;
fwrite(out.data(),sizeof(int16_t),out.size(),file);fclose(file);return 0;
}
"#;
#[test]
#[ignore = "requires g++ and SDL2 development headers; synthetic assets only"]
fn rust_fm_mus_and_digital_pcm_match_the_retained_native_reference() {
let temp = tempfile::tempdir().unwrap();
let binary = compile_reference(temp.path());
let patch_data: Vec<_> = (0..255).cycle().take(2000).collect();
let patch_path = temp.path().join("patch.bin");
fs::write(&patch_path, patch(&patch_data)).unwrap();
for (double, mode, fade, looping) in [
(false, 0, false, false),
(true, 0, false, false),
(false, 1, false, false),
(true, 2, true, true),
] {
let bank = instruments(double);
let song = score();
let frames = 44100;
let gen_path = temp.path().join("genmidi.bin");
fs::write(&gen_path, &bank).unwrap();
let score_path = temp.path().join("score.bin");
fs::write(&score_path, &song).unwrap();
let output = temp.path().join("reference.pcm");
let status = Command::new(&binary)
.arg(&gen_path)
.arg(&score_path)
.arg(&patch_path)
.arg(frames.to_string())
.arg(mode.to_string())
.arg(i32::from(fade).to_string())
.arg(i32::from(looping).to_string())
.arg(&output)
.status()
.unwrap();
assert!(status.success());
let expected = fs::read(output).unwrap();
let mut mixer = Mixer::new(44100, &bank).unwrap();
mixer.music_volume = 127;
mixer.fx_volume = 127;
mixer.set_fx_channels(8);
if mode != 1 {
mixer.play_with_fade(MusScore::decode(&song).unwrap(), looping, fade);
}
if mode != 0 {
mixer.sample(sample(patch_data.clone()), 37, 192, 85, 1);
}
let mut out = vec![0; frames * 2];
mixer.mix(&mut out);
let actual: Vec<_> = out.iter().flat_map(|v| v.to_le_bytes()).collect();
if let Some(index) = actual
.chunks_exact(2)
.zip(expected.chunks_exact(2))
.position(|(a, b)| a != b)
{
let want = i16::from_le_bytes(expected[index * 2..index * 2 + 2].try_into().unwrap());
panic!(
"PCM mismatch double={double} mode={mode} fade={fade} loop={looping}: frame={} channel={} got={} expected={want}",
index / 2,
index % 2,
out[index]
);
}
assert_eq!(actual.len(), expected.len());
}
}
fn compile_reference(directory: &Path) -> std::path::PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = directory.join("reference.cpp");
fs::write(&source, NATIVE_REFERENCE).unwrap();
let binary = directory.join("reference");
let mut compile = Command::new("g++");
compile
.args(["-std=c++17", "-O2"])
.arg("-I")
.arg(root.join("raptor/src"));
compile.arg("-I").arg("/usr/include/SDL2");
if let Some(home) = std::env::var_os("HOME") {
compile
.arg("-I")
.arg(Path::new(&home).join(".local/include/SDL2"));
}
compile.arg(&source);
for path in ["i_oplmusic.cpp", "opl3.cpp", "musapi.cpp", "dspapi.cpp"] {
compile.arg(root.join("raptor/src").join(path));
}
let result = compile.arg("-o").arg(&binary).output().unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
binary
}
#[test]
#[ignore = "requires original untracked music assets, g++ and SDL2 headers"]
fn original_intro_pcm_matches_the_retained_native_reference() {
use raptor_replica::assets::Catalog;
let temp = tempfile::tempdir().unwrap();
let root = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data"));
let catalog = Catalog::open(root).unwrap();
let bank = catalog.named("GENMIDI_OP2").unwrap();
let song = catalog.named("RINTRO_MUS").unwrap();
let binary = compile_reference(temp.path());
fs::write(temp.path().join("genmidi.bin"), &bank).unwrap();
fs::write(temp.path().join("score.bin"), &song).unwrap();
fs::write(temp.path().join("patch.bin"), patch(&[128; 33])).unwrap();
let frames = 44100 * 4;
let output = temp.path().join("reference.pcm");
let status = Command::new(binary)
.arg(temp.path().join("genmidi.bin"))
.arg(temp.path().join("score.bin"))
.arg(temp.path().join("patch.bin"))
.arg(frames.to_string())
.args(["0", "1", "1"])
.arg(&output)
.status()
.unwrap();
assert!(status.success());
let expected = fs::read(output).unwrap();
let mut mixer = Mixer::new(44100, &bank).unwrap();
mixer.music_volume = 127;
mixer.play_with_fade(MusScore::decode(&song).unwrap(), true, true);
let mut actual = vec![0i16; frames * 2];
mixer.mix(&mut actual);
for (index, (got, bytes)) in actual.iter().zip(expected.chunks_exact(2)).enumerate() {
let want = i16::from_le_bytes(bytes.try_into().unwrap());
assert_eq!(
*got,
want,
"original intro frame {} channel {}",
index / 2,
index % 2
);
}
assert_eq!(actual.len() * 2, expected.len());
}
+269
View File
@@ -0,0 +1,269 @@
use raptor_replica::{
assets::{Catalog, Sprite, TRANSPARENT, contained, i16_at, i32_at},
audio::{DigitalSample, Mixer, MusScore},
content::{ContentPack, Registry},
persistence::{Config, Pilot},
render::upscale,
};
fn pilot_registry() -> Registry {
let missions: Vec<_> = (1..=3).flat_map(|s| (1..=9).map(move |w| serde_json::json!({
"id":format!("base:sector_{s}_wave_{w}"),"name":"Synthetic wave","duration":100,"legacy_map":100+s*9+w
}))).collect();
let pack: ContentPack = serde_json::from_value(serde_json::json!({
"items":[
{"id":"base:forward_guns","name":"Forward Guns","price":12000,"legacy":0},
{"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"legacy":16}
],"missions":missions
})).unwrap();
let mut registry = Registry::default();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
#[test]
fn training_graduation_and_sector_cycles_preserve_original_progression() {
let registry = pilot_registry();
let mut pilot = Pilot {
difficulty: 0,
credits: 900000,
mission: "base:sector_2_wave_4".into(),
..Pilot::default()
};
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
assert_eq!(pilot.difficulty, 1);
assert_eq!(pilot.credits, 10000);
assert_eq!(pilot.inventory[&"base:energy".into()], 75);
assert!(pilot.legacy_progress.as_ref().unwrap().training_completed);
pilot.mission = "base:sector_3_wave_9".into();
pilot.difficulty = 2;
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:sector_1_wave_1");
assert_eq!(pilot.legacy_progress.as_ref().unwrap().difficulties[2], 3);
assert_eq!(pilot.difficulty, 1);
pilot.mission = "base:sector_1_wave_2".into();
pilot.complete_mission(&registry).unwrap();
assert_eq!(pilot.mission.0, "base:sector_1_wave_3");
}
#[test]
fn legacy_save_import_retains_metadata_without_modifying_source() {
fn encrypt(bytes: &[u8]) -> Vec<u8> {
let key = b"CASTLE";
let mut index = 25 % key.len();
let mut previous = key[index];
bytes
.iter()
.map(|clear| {
let byte = clear.wrapping_add(key[index]).wrapping_add(previous);
previous = byte;
index = (index + 1) % key.len();
byte
})
.collect()
}
let mut header = vec![0u8; 88];
header[..9].copy_from_slice(b"Synthetic");
header[20..24].copy_from_slice(b"TEST");
for (offset, value) in [
(32, 65814i32),
(36, 98765),
(40, -1),
(44, 1),
(48, 3),
(52, 8),
(56, 1),
(60, 2),
(64, 0),
(68, 2),
(72, 1),
(76, 3),
(80, 0),
(84, 1),
] {
header[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
let mut body = vec![0u8; 48];
for (offset, value) in [(8, 1i32), (12, 0), (32, 71), (36, 16)] {
body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
let bytes = [encrypt(&header), encrypt(&body)].concat();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CHAR0000.FIL");
std::fs::write(&path, &bytes).unwrap();
let registry = pilot_registry();
let pilot = Pilot::import_legacy(&path, &registry).unwrap();
assert_eq!(pilot.callsign, "TEST");
assert_eq!(pilot.credits, 98765);
assert_eq!(pilot.mission.0, "base:sector_2_wave_9");
assert_eq!(pilot.inventory[&"base:energy".into()], 71);
assert_eq!(pilot.completed.len(), 12);
let progress = pilot.legacy_progress.as_ref().unwrap();
assert_eq!(progress.portrait, 65814);
assert_eq!(progress.difficulties, [0, 2, 1, 3]);
assert!(progress.training_completed);
let output = dir.path().join("pilot.json");
pilot.save(&output).unwrap();
assert_eq!(Pilot::load(&output, &registry, &[]).unwrap().credits, 98765);
assert_eq!(std::fs::read(path).unwrap(), bytes);
}
fn header(kind: i32, width: i32, height: i32) -> Vec<u8> {
[kind, 0, 0, width, height]
.into_iter()
.flat_map(i32::to_le_bytes)
.collect()
}
#[test]
fn raw_pictures_keep_zero_opaque_and_spans_keep_empty_pixels_transparent() {
let mut raw = header(1, 2, 1);
raw.extend([0, 255]);
assert_eq!(Sprite::decode(&raw).unwrap().pixels, [0, 255]);
let mut span = header(0, 3, 2);
span.extend([1i32, 1, 4, 2].into_iter().flat_map(i32::to_le_bytes));
span.extend([0, 17]);
span.extend([0i32, 0, -1, 0].into_iter().flat_map(i32::to_le_bytes));
assert_eq!(
Sprite::decode(&span).unwrap().pixels,
[TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0, 17]
);
for n in 0..span.len() {
assert!(Sprite::decode(&span[..n]).is_err());
}
}
#[test]
fn malformed_fields_and_spans_fail_without_panicking() {
assert!(i16_at(&[], usize::MAX).is_err());
assert!(i32_at(&[], usize::MAX).is_err());
let mut span = header(0, 2, 2);
span.extend([i32::MAX, 0, 0, 2].into_iter().flat_map(i32::to_le_bytes));
span.extend([1, 2]);
assert!(Sprite::decode(&span).is_err());
assert!(Sprite::decode(&header(1, 4096, 4096)).is_err());
}
#[test]
fn scale_kernels_have_published_corner_results() {
let input = Sprite {
width: 3,
height: 3,
pixels: vec![9, 1, 8, 1, 2, 3, 7, 4, 6],
};
let two = upscale(&input, 2).unwrap();
let four = upscale(&input, 4).unwrap();
assert_eq!(
[
two.pixels[2 * 6 + 2],
two.pixels[2 * 6 + 3],
two.pixels[3 * 6 + 2],
two.pixels[3 * 6 + 3]
],
[1, 2, 2, 2]
);
let three = upscale(&input, 3).unwrap();
let center: Vec<_> = (3..6)
.flat_map(|y| (3..6).map(move |x| y * 9 + x))
.map(|n| three.pixels[n])
.collect();
assert_eq!(center, [1, 1, 2, 1, 2, 2, 2, 2, 2]);
assert_eq!(four, upscale(&two, 2).unwrap());
assert!(upscale(&input, 5).is_err());
}
#[test]
fn catalog_paths_cannot_escape_or_follow_an_external_symlink() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
std::fs::write(other.path().join("outside"), b"outside").unwrap();
assert!(contained(root.path(), std::path::Path::new("../outside")).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(other.path().join("outside"), root.path().join("link")).unwrap();
assert!(contained(root.path(), std::path::Path::new("link")).is_err());
}
}
#[test]
fn importing_settings_preserves_source_and_uses_sdl_key_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SETUP.INI");
let original = b"[Keyboard]\nFire=29\nFireSp=56\nMegaFire=54\n[Video]\nupscaler=3\n";
std::fs::write(&path, original).unwrap();
let config = Config::import_ini(&path).unwrap();
assert_eq!(config.scale, 3);
assert_eq!(config.bindings["fire"], "Left Ctrl");
assert_eq!(config.bindings["special"], "Left Alt");
assert_eq!(std::fs::read(path).unwrap(), original);
#[cfg(feature = "desktop")]
for key in config.bindings.values() {
assert!(
sdl2::keyboard::Scancode::from_name(key).is_some(),
"unknown SDL key {key}"
);
}
}
// Original pixels never enter the test fixtures or version control. Set this
// variable only on a machine with the licensed extracted asset catalog.
#[test]
fn original_data_decoders_and_all_campaigns_when_available() {
let Some(data) = std::env::var_os("RAPTOR_TEST_DATA") else {
return;
};
let catalog = Catalog::open(data).unwrap();
let registry = Registry::base(&catalog).unwrap();
registry.validate().unwrap();
assert_eq!(registry.missions.len(), 27);
assert_eq!(registry.campaigns.len(), 3);
let mut sprites = 0;
for entry in catalog.entries.values() {
let bytes = catalog.read(entry.id).unwrap();
if bytes.len() >= 20
&& (i32_at(&bytes, 0).ok() == Some(0)
|| (i32_at(&bytes, 0).ok() == Some(1)
&& i32_at(&bytes, 4).ok() == Some(1)
&& i32_at(&bytes, 8).ok() == Some(0)))
&& matches!(i32_at(&bytes, 12).ok(), Some(1..=4096))
&& matches!(i32_at(&bytes, 16).ok(), Some(1..=4096))
{
Sprite::decode(&bytes).unwrap_or_else(|e| panic!("asset {:x}: {e}", entry.id));
sprites += 1;
}
if bytes.starts_with(b"MUS\x1a") {
MusScore::decode(&bytes).unwrap();
}
}
assert!(
sprites > 1000,
"format discovery must include unnamed graphics"
);
for sector in 0..3 {
let pilot = Pilot::demo(sector, &registry).unwrap();
pilot.validate(&registry, &[]).unwrap();
let selected = &registry.items[pilot.selected.as_ref().unwrap()];
assert_eq!(selected.legacy, Some([5, 8, 8][sector]));
}
let mut mixer = Mixer::new(44100, &catalog.named("GENMIDI_OP2").unwrap()).unwrap();
mixer.play(
MusScore::decode(&catalog.named("RAP1_MUS").unwrap()).unwrap(),
false,
);
let mut output = vec![0i16; 44100 * 2];
mixer.mix(&mut output);
assert!(
output.iter().any(|v| *v != 0),
"FM playback must produce sound"
);
let sample = catalog
.entries
.values()
.find_map(|e| DigitalSample::decode(&catalog.read(e.id).ok()?).ok())
.unwrap();
mixer.sample(sample, 128, 128, 127, 1);
mixer.mix(&mut output);
assert!(output.iter().any(|v| *v != 0));
}
+186
View File
@@ -0,0 +1,186 @@
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, LineMove, World},
};
use serde_json::json;
fn fixture() -> Registry {
let pack: ContentPack = serde_json::from_value(json!({
"weapons": [{"id":"base:projectile_forward_guns", "name":"Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all","legacy":0,"art":{"asset":42,"logical_width":4,"logical_height":8,"frames":4}}],
"items":[
{"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:projectile_forward_guns","legacy":0},
{"id":"base:energy","name":"Shield","price":0,"max_count":100,"legacy":16},
{"id":"base:super_shield","name":"Super Shield","price":0,"max_count":100,"legacy":15},
{"id":"base:medkit","name":"Medkit","price":0,"max_count":5,"special":true,"consumable":true,"shield":25,"script":"medkit.rhai"}
],
"enemies":[{"id":"base:drone","name":"Drone","speed":1,"health":20,"path":[[0,200]]}],
"missions":[{"id":"base:sector_1_wave_1","name":"Synthetic Mission","duration":1000}]
})).unwrap();
let mut reg = Registry::default();
reg.apply(pack, "base").unwrap();
reg.validate().unwrap();
reg
}
#[test]
fn original_guns_use_banked_muzzles_and_accelerate_after_position_snapshot() {
let reg = fixture();
let mut world = World::new(&"base:sector_1_wave_1".into(), &Pilot::default(), &reg).unwrap();
let mut scripts = ScriptRuntime::new();
world
.step(
Input {
fire: true,
demo_bank: Some(3),
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
// Center (160,176), gun offsets +6/-7, sprite half-size (2,4).
assert_eq!(
world
.projectiles
.iter()
.map(|p| (p.x, p.y))
.collect::<Vec<_>>(),
[(164, 172), (151, 172)]
);
world
.step(
Input {
fire: true,
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.projectiles.len(), 2);
assert_eq!(world.projectiles[0].y, 163); // Speed rose from 8 to 9 after the first position.
world
.step(
Input {
fire: true,
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.projectiles.len(), 4);
assert_eq!(world.projectiles[0].y, 153);
}
#[test]
fn enemy_paths_stop_at_endpoint_but_shots_cross_it() {
let mut enemy = LineMove::new(0, 0, 2, 1);
enemy.advance(5);
assert_eq!((enemy.x, enemy.y), (2, 1));
let mut shot = LineMove::new(0, 0, 2, 1);
shot.advance_shot(5);
assert_eq!((shot.x, shot.y), (5, 3));
assert!(shot.done);
}
#[test]
fn mission_transition_preserves_remaining_super_shield() {
let reg = fixture();
let mut pilot = Pilot::default();
pilot.inventory.insert("base:super_shield".into(), 31);
let mut world = World::new(&pilot.mission, &pilot, &reg).unwrap();
assert_eq!(world.player.super_shield, 31);
world
.apply_commands(
&[Command::Damage {
entity: 0,
amount: 12,
}],
&reg,
)
.unwrap();
let saved = world.pilot(&pilot);
assert_eq!(saved.inventory[&ContentId::from("base:super_shield")], 19);
assert_eq!(
World::new(&pilot.mission, &saved, &reg)
.unwrap()
.player
.super_shield,
19
);
}
#[test]
fn moving_an_entity_rebases_its_path() {
let reg = fixture();
let mut world = World::new(&"base:sector_1_wave_1".into(), &Pilot::default(), &reg).unwrap();
let entity = world.spawn(&"base:drone".into(), 100, 0, &reg).unwrap();
world
.apply_commands(
&[Command::Move {
entity,
x: 20,
y: 20,
}],
&reg,
)
.unwrap();
let mut scripts = ScriptRuntime::new();
world.step(Input::default(), &reg, &mut scripts).unwrap();
assert_eq!((world.enemies[0].x, world.enemies[0].y), (20, 20));
world.step(Input::default(), &reg, &mut scripts).unwrap();
assert_eq!((world.enemies[0].x, world.enemies[0].y), (20, 21));
}
#[test]
fn consumable_use_has_snapshot_and_only_fires_on_press() {
let reg = fixture();
let mut pilot = Pilot::default();
pilot.inventory.insert("base:medkit".into(), 2);
pilot.inventory.insert("base:energy".into(), 50);
pilot.selected = Some("base:medkit".into());
let mut world = World::new(&pilot.mission, &pilot, &reg).unwrap();
let mut scripts = ScriptRuntime::new();
scripts.add("base","medkit.rhai",r#"fn on_use(event,state,rng) {#{commands:[#{op:"credits",amount:7}], state:#{shield:event.player.shield,quantity:event.player.inventory["base:medkit"]}}}"#).unwrap();
world
.step(
Input {
special: true,
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.player.shield, 75);
assert_eq!(world.player.credits, 7);
assert_eq!(world.player.inventory[&ContentId::from("base:medkit")], 1);
world
.step(
Input {
special: true,
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.player.credits, 7);
world.step(Input::default(), &reg, &mut scripts).unwrap();
world
.step(
Input {
special: true,
..Input::default()
},
&reg,
&mut scripts,
)
.unwrap();
assert_eq!(world.player.shield, 100);
assert_eq!(world.player.credits, 14);
assert_eq!(world.player.selected, None);
}
+949
View File
@@ -0,0 +1,949 @@
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{self, ActiveMods, Command, Manifest, ScriptRuntime},
persistence::Pilot,
simulation::{Input, World},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs, path::Path};
fn pack(value: Value) -> ContentPack {
serde_json::from_value(value).unwrap()
}
fn item(id: &str, price: u32) -> Value {
json!({"id": id, "name": id, "price": price, "max_count": 100})
}
fn base() -> Registry {
let mut reg = Registry::default();
reg.apply(pack(json!({
"weapons": [{"id":"base:projectile_forward_guns", "name":"Forward Guns", "damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
"items": [
{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns"},
{"id":"base:energy","name":"Shield","price":0,"max_count":100}
],
"missions":[{"id":"base:sector_1_wave_1","name":"Test Mission","duration":1000}]
})), "base").unwrap();
reg.validate().unwrap();
reg
}
fn install(root: &Path, folder: &str, id: &str, content: Value) -> Manifest {
let dir = root.join(folder);
fs::create_dir_all(&dir).unwrap();
let manifest = Manifest {
id: id.into(),
name: id.into(),
version: "1.0.0".into(),
api_version: 1,
description: String::new(),
dependencies: BTreeMap::new(),
conflicts: vec![],
content: "content.json".into(),
scripts: vec![],
};
write_manifest(&dir, &manifest);
fs::write(
dir.join("content.json"),
serde_json::to_vec_pretty(&content).unwrap(),
)
.unwrap();
manifest
}
fn write_manifest(dir: &Path, manifest: &Manifest) {
fs::write(dir.join("mod.toml"), toml::to_string(manifest).unwrap()).unwrap();
}
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).into()).collect()
}
fn error<T>(result: anyhow::Result<T>) -> String {
match result {
Ok(_) => panic!("expected failure"),
Err(e) => format!("{e:#}"),
}
}
#[test]
fn dependencies_load_before_dependents_and_keep_independent_priority() {
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "alpha", "alpha", json!({}));
let mut beta = install(temp.path(), "beta", "beta", json!({}));
beta.dependencies.insert("alpha".into(), "^1.0".into());
write_manifest(&temp.path().join("beta"), &beta);
install(temp.path(), "gamma", "gamma", json!({}));
let d = modding::discover(temp.path());
assert!(d.errors.is_empty());
assert_eq!(
modding::order(&d, &ids(&["gamma", "beta", "alpha"])).unwrap(),
ids(&["gamma", "alpha", "beta"])
);
}
#[test]
fn missing_disabled_wrong_version_and_cyclic_dependencies_are_actionable() {
let temp = tempfile::tempdir().unwrap();
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
let mut beta = install(temp.path(), "beta", "beta", json!({}));
beta.dependencies.insert("alpha".into(), "^2".into());
write_manifest(&temp.path().join("beta"), &beta);
let d = modding::discover(temp.path());
assert!(error(modding::order(&d, &ids(&["beta"]))).contains("requires enabled mod alpha"));
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("installed 1.0.0"));
beta.dependencies.insert("alpha".into(), "^1".into());
alpha.dependencies.insert("beta".into(), "^1".into());
write_manifest(&temp.path().join("beta"), &beta);
write_manifest(&temp.path().join("alpha"), &alpha);
assert!(
error(modding::order(
&modding::discover(temp.path()),
&ids(&["alpha", "beta"])
))
.contains("cyclic")
);
}
#[test]
fn api_conflicts_and_duplicate_selected_ids_reject_activation() {
let temp = tempfile::tempdir().unwrap();
let mut alpha = install(temp.path(), "alpha", "alpha", json!({}));
install(temp.path(), "beta", "beta", json!({}));
alpha.api_version = 99;
write_manifest(&temp.path().join("alpha"), &alpha);
assert!(
error(modding::order(
&modding::discover(temp.path()),
&ids(&["alpha"])
))
.contains("requires API 99")
);
alpha.api_version = 1;
alpha.conflicts.push("beta".into());
write_manifest(&temp.path().join("alpha"), &alpha);
let d = modding::discover(temp.path());
assert!(error(modding::order(&d, &ids(&["alpha", "beta"]))).contains("conflicts with beta"));
assert!(error(modding::order(&d, &ids(&["beta", "beta"]))).contains("duplicate mod"));
}
#[test]
fn every_duplicate_installed_namespace_is_unusable() {
let temp = tempfile::tempdir().unwrap();
for folder in ["first", "second", "third"] {
install(temp.path(), folder, "collision", json!({}));
}
let d = modding::discover(temp.path());
assert!(!d.mods.contains_key("collision"));
assert_eq!(d.errors.len(), 2);
assert!(
error(ActiveMods::build(&base(), &d, &ids(&["collision"]), None)).contains("not installed")
);
}
#[test]
fn adding_a_duplicate_after_discovery_requires_rescanning() {
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "first", "alpha", json!({}));
let original = modding::discover(temp.path());
install(temp.path(), "second", "alpha", json!({}));
let message = error(ActiveMods::build(
&base(),
&original,
&ids(&["alpha"]),
None,
));
assert!(message.contains("duplicated since discovery") && message.contains("rescan"));
}
#[test]
fn changed_files_require_a_new_discovery_snapshot() {
let temp = tempfile::tempdir().unwrap();
install(
temp.path(),
"alpha",
"alpha",
json!({"items":[item("alpha:cell", 10)]}),
);
let original = modding::discover(temp.path());
let before = ActiveMods::build(&base(), &original, &ids(&["alpha"]), None).unwrap();
fs::write(
temp.path().join("alpha/content.json"),
serde_json::to_vec(&json!({"items":[item("alpha:cell", 12)]})).unwrap(),
)
.unwrap();
assert!(
error(ActiveMods::build(
&base(),
&original,
&ids(&["alpha"]),
None
))
.contains("rescan")
);
let after = ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None,
)
.unwrap();
assert_ne!(before.pins[0].hash, after.pins[0].hash);
assert_eq!(
after.registry.items[&ContentId::from("alpha:cell")].price,
12
);
}
#[test]
fn package_hash_covers_added_assets_and_manifest_changes() {
let temp = tempfile::tempdir().unwrap();
let mut manifest = install(temp.path(), "alpha", "alpha", json!({}));
let d = modding::discover(temp.path());
fs::write(temp.path().join("alpha/readme.txt"), "Extra package file").unwrap();
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
let d = modding::discover(temp.path());
manifest.version = "1.0.1".into();
write_manifest(&temp.path().join("alpha"), &manifest);
assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan"));
}
#[test]
fn package_paths_depth_and_size_are_bounded() {
let temp = tempfile::tempdir().unwrap();
let mut m = install(temp.path(), "traversal", "traversal", json!({}));
m.content = "../outside.json".into();
write_manifest(&temp.path().join("traversal"), &m);
install(temp.path(), "deep", "deep", json!({}));
let mut nested = temp.path().join("deep");
for _ in 0..17 {
nested = nested.join("nested");
}
fs::create_dir_all(nested).unwrap();
install(temp.path(), "large", "large", json!({}));
fs::File::create(temp.path().join("large/huge.bin"))
.unwrap()
.set_len(64 * 1024 * 1024 + 1)
.unwrap();
let d = modding::discover(temp.path());
assert!(d.mods.is_empty());
assert!(d.errors.iter().any(|e| e.contains("parent traversal")));
assert!(d.errors.iter().any(|e| e.contains("depth exceeds")));
assert!(d.errors.iter().any(|e| e.contains("64 MiB")));
}
#[cfg(unix)]
#[test]
fn symlink_folders_and_files_are_rejected() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
install(temp.path(), "alpha", "alpha", json!({}));
symlink("/etc/passwd", temp.path().join("alpha/link")).unwrap();
symlink(temp.path().join("alpha"), temp.path().join("alias")).unwrap();
let d = modding::discover(temp.path());
assert!(d.mods.is_empty());
assert_eq!(d.errors.len(), 2);
assert!(d.errors.iter().all(|e| e.contains("symlink")));
}
#[test]
fn invalid_manifest_versions_and_duplicate_sources_fail_discovery() {
let temp = tempfile::tempdir().unwrap();
let mut m = install(temp.path(), "alpha", "alpha", json!({}));
m.dependencies
.insert("beta".into(), "obviously invalid".into());
write_manifest(&temp.path().join("alpha"), &m);
assert!(modding::discover(temp.path()).mods.is_empty());
m.dependencies.clear();
m.scripts = ids(&["script.rhai", "script.rhai"]);
fs::write(temp.path().join("alpha/script.rhai"), "").unwrap();
write_manifest(&temp.path().join("alpha"), &m);
assert!(modding::discover(temp.path()).errors[0].contains("duplicate script"));
}
#[test]
fn later_explicit_overrides_win_and_report_the_previous_owner() {
let temp = tempfile::tempdir().unwrap();
install(
temp.path(),
"alpha",
"alpha",
json!({"items":[item("base:energy", 10)], "overrides":["base:energy"]}),
);
install(
temp.path(),
"beta",
"beta",
json!({"items":[item("base:energy", 20)], "overrides":["base:energy"]}),
);
let d = modding::discover(temp.path());
let active = ActiveMods::build(&base(), &d, &ids(&["alpha", "beta"]), None).unwrap();
assert_eq!(
active.registry.items[&ContentId::from("base:energy")].price,
20
);
assert_eq!(
active.registry.owners[&ContentId::from("base:energy")],
"beta"
);
assert_eq!(
active.registry.overrides,
ids(&["base:energy: base -> alpha", "base:energy: alpha -> beta"])
);
let reversed = ActiveMods::build(&base(), &d, &ids(&["beta", "alpha"]), None).unwrap();
assert_eq!(
reversed.registry.items[&ContentId::from("base:energy")].price,
10
);
}
#[test]
fn accidental_duplicates_type_changes_and_failed_packs_leave_registry_intact() {
let mut registry = base();
let previous = registry.items.len();
assert!(
registry
.apply(
pack(json!({"items":[item("alpha:valid", 1), item("base:energy", 2)]})),
"alpha"
)
.is_err()
);
assert_eq!(registry.items.len(), previous);
assert!(
!registry
.owners
.contains_key(&ContentId::from("alpha:valid"))
);
assert!(
registry
.apply(
pack(json!({"items":[item("alpha:twice", 1), item("alpha:twice", 2)]})),
"alpha"
)
.is_err()
);
assert!(registry.apply(pack(json!({"enemies":[{"id":"base:energy","name":"Wrong type"}],"overrides":["base:energy"]})), "alpha").is_err());
assert!(
registry
.apply(pack(json!({"items":[item("foreign:cell", 1)]})), "alpha")
.is_err()
);
assert!(
registry
.apply(
pack(json!({"items":[item("alpha:new", 1)],"overrides":["alpha:new"]})),
"alpha"
)
.is_err()
);
}
#[test]
fn content_references_and_undeclared_scripts_reject_the_whole_mod() {
let temp = tempfile::tempdir().unwrap();
install(
temp.path(),
"alpha",
"alpha",
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"weapon":"alpha:missing"}]}),
);
assert!(
error(ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None
))
.contains("missing weapon")
);
fs::write(temp.path().join("alpha/content.json"), serde_json::to_vec(&json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"script":"undeclared.rhai"}]})).unwrap()).unwrap();
assert!(
error(ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None
))
.contains("manifest.scripts")
);
}
#[test]
fn script_failure_rolls_back_world_state_and_deterministic_rng() {
let mut registry = base();
registry
.missions
.get_mut(&ContentId::from("base:sector_1_wave_1"))
.unwrap()
.script = Some("mission.rhai".into());
let mut runtime = ScriptRuntime::new();
runtime
.add(
"base",
"mission.rhai",
r#"fn on_mission_start(e,s,r) {
#{commands:[#{op:"credits",amount:7}, #{op:"move",entity:999,x:0,y:0}],state:#{random:r[0]}}
}"#,
)
.unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let before = world.digest();
assert!(
world
.step(Input::default(), &registry, &mut runtime)
.is_err()
);
assert_eq!(world.digest(), before);
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
let source = r#"fn on_mission_start(e,s,r) { #{commands:[#{op:"credits",amount:r[0]}],state:#{random:r[0]}} }"#;
runtime.add("base", "mission.rhai", source).unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
let mut fresh_runtime = ScriptRuntime::new();
fresh_runtime.add("base", "mission.rhai", source).unwrap();
let mut fresh_world = World::new(&pilot.mission, &pilot, &registry).unwrap();
fresh_world
.step(Input::default(), &registry, &mut fresh_runtime)
.unwrap();
assert_eq!(world.digest(), fresh_world.digest());
assert_eq!(runtime.states, fresh_runtime.states);
assert_eq!(runtime.rng, fresh_runtime.rng);
}
#[test]
fn command_batches_are_atomic_even_after_an_earlier_valid_command() {
let registry = base();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let before = world.digest();
let commands = [
Command::Credits { amount: 100 },
Command::Spawn {
enemy: "missing:enemy".into(),
x: 0,
y: 0,
},
];
assert!(world.apply_commands(&commands, &registry).is_err());
assert_eq!(world.digest(), before);
}
#[test]
fn malformed_result_excessive_commands_and_oversized_state_are_rejected() {
let mut runtime = ScriptRuntime::new();
for (source, expected) in [
(
r#"fn on_tick(e,s,r) { #{commands:[#{op:"unknown"}],state:#{}} }"#,
"invalid result",
),
(
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..257 { a.push(#{op:"complete"}); } #{commands:a,state:#{}} }"#,
"too many commands",
),
(
r#"fn on_tick(e,s,r) { let a=[]; for i in 0..4000 { a.push(999999999999999999); } #{commands:[],state:a} }"#,
"state exceeds",
),
] {
runtime.begin_tick();
runtime.add("test", "limits.rhai", source).unwrap();
let message = error(runtime.invoke("test", "limits.rhai", "on_tick", &json!({}), 17));
assert!(
message.contains(expected),
"expected {expected}, got {message}"
);
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
}
#[test]
fn execution_budget_is_shared_across_callbacks_and_resets_per_tick() {
let mut runtime = ScriptRuntime::new();
runtime.add("test", "budget.rhai", r#"fn on_tick(e,s,r) { let x=0; for n in 0..2500 { x+=1; } #{commands:[],state:#{iterations:x}} }"#).unwrap();
runtime.begin_tick();
let mut success = 0;
for _ in 0..100 {
if runtime
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
.is_err()
{
break;
}
success += 1;
}
assert!(
success > 1 && success < 100,
"successful callbacks: {success}"
);
runtime.begin_tick();
runtime
.invoke("test", "budget.rhai", "on_tick", &json!({}), 1)
.unwrap();
}
#[test]
fn missing_hook_does_not_advance_rng_or_state() {
let mut runtime = ScriptRuntime::new();
runtime
.add(
"test",
"empty.rhai",
"fn other(e,s,r) { #{commands:[],state:s} }",
)
.unwrap();
assert!(
runtime
.invoke("test", "empty.rhai", "on_tick", &json!({}), 1)
.unwrap()
.is_empty()
);
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
#[test]
fn incompatible_save_pins_and_disabled_equipment_do_not_modify_the_save() {
let temp = tempfile::tempdir().unwrap();
install(
temp.path(),
"alpha",
"alpha",
json!({"items":[item("alpha:cell", 1)]}),
);
let active = ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None,
)
.unwrap();
let mut pilot = Pilot::default();
pilot.mods = active.pins.clone();
pilot.inventory.insert("alpha:cell".into(), 1);
let path = temp.path().join("pilot.json");
pilot.save(&path).unwrap();
let before = fs::read(&path).unwrap();
Pilot::load(&path, &active.registry, &active.pins).unwrap();
assert!(error(Pilot::load(&path, &base(), &[])).contains("enable the matching mods"));
let mut changed = active.pins.clone();
changed[0].hash = "different-content-hash".into();
assert!(Pilot::load(&path, &active.registry, &changed).is_err());
assert!(
error(Pilot::load(&path, &base(), &active.pins))
.contains("saved item alpha:cell is unavailable")
);
assert_eq!(fs::read(path).unwrap(), before);
}
#[test]
fn saved_script_state_and_rng_resume_the_same_callback_sequence() {
let mut runtime = ScriptRuntime::new();
let source = r#"fn on_tick(e,s,r) { let n=if s.contains("n") {s.n+1} else {1}; #{commands:[#{op:"credits",amount:r[0]}],state:#{n:n,last:r[0]}} }"#;
runtime.add("test", "state.rhai", source).unwrap();
runtime
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
.unwrap();
let mut pilot = Pilot::default();
pilot.script_state = runtime.states.clone();
pilot.script_rng = runtime.rng.clone();
let restored_pilot: Pilot =
serde_json::from_slice(&serde_json::to_vec(&pilot).unwrap()).unwrap();
let mut restored = ScriptRuntime::new();
restored.add("test", "state.rhai", source).unwrap();
restored.states = restored_pilot.script_state;
restored.rng = restored_pilot.script_rng;
let a = runtime
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
.unwrap();
let b = restored
.invoke("test", "state.rhai", "on_tick", &json!({}), 42)
.unwrap();
assert_eq!(
serde_json::to_value(a).unwrap(),
serde_json::to_value(b).unwrap()
);
assert_eq!(runtime.states, restored.states);
assert_eq!(runtime.rng, restored.rng);
}
#[test]
fn example_mod_compiles_and_its_weapon_enemy_item_and_mission_hooks_execute() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods");
let mut active =
ActiveMods::build(&base(), &modding::discover(&dir), &ids(&["aurora"]), None).unwrap();
assert!(
active
.registry
.weapons
.contains_key(&ContentId::from("aurora:pulse_projectile"))
);
assert!(
active
.registry
.items
.contains_key(&ContentId::from("aurora:pulse_cannon"))
);
assert!(
active
.registry
.enemies
.contains_key(&ContentId::from("aurora:weaver"))
);
assert!(
active
.registry
.campaigns
.contains_key(&ContentId::from("aurora:trials"))
);
let mission = ContentId::from("aurora:field_trials");
let pilot = Pilot::default();
let mut world = World::new(&mission, &pilot, &active.registry).unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(
world
.player
.inventory
.get(&ContentId::from("aurora:pulse_cannon")),
Some(&1)
);
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
assert_eq!(
active.scripts.states["aurora/scripts/weapon.rhai"]["shots"],
1
);
assert!(
world
.projectiles
.iter()
.filter(|p| p.weapon.0 == "aurora:pulse_projectile")
.count()
>= 3
);
let entity = world
.spawn(&ContentId::from("aurora:weaver"), 60, 30, &active.registry)
.unwrap();
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert_eq!(
world
.enemies
.iter()
.find(|e| e.entity == entity)
.unwrap()
.vx,
1
);
let commands = active
.scripts
.invoke(
"aurora",
"scripts/cell.rhai",
"on_pickup",
&json!({"entity":1}),
0,
)
.unwrap();
world.apply_commands(&commands, &active.registry).unwrap();
assert_eq!(world.player.credits, 25);
}
#[test]
fn png_strips_are_validated_without_the_original_catalog() {
let temp = tempfile::tempdir().unwrap();
let art = json!({"png":"cell.png","logical_width":1,"logical_height":1,"frames":2});
install(
temp.path(),
"alpha",
"alpha",
json!({"items":[{"id":"alpha:cell","name":"Cell","price":1,"art":art}]}),
);
image::RgbaImage::from_pixel(3, 1, image::Rgba([20, 40, 60, 255]))
.save(temp.path().join("alpha/cell.png"))
.unwrap();
let message = error(ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None,
));
assert!(message.contains("strip width"));
image::RgbaImage::from_pixel(4, 2, image::Rgba([20, 40, 60, 255]))
.save(temp.path().join("alpha/cell.png"))
.unwrap();
ActiveMods::build(
&base(),
&modding::discover(temp.path()),
&ids(&["alpha"]),
None,
)
.unwrap();
}
#[test]
fn callback_arity_and_source_size_are_checked_before_activation() {
let mut runtime = ScriptRuntime::new();
assert!(
error(runtime.add("test", "bad.rhai", "fn on_fire(event,state) { state }"))
.contains("callbacks require")
);
assert!(
error(runtime.add("test", "huge.rhai", &" ".repeat(1024 * 1024 + 1)))
.contains("source exceeds 1 MiB")
);
}
#[test]
fn callbacks_can_query_player_entities_projectiles_pickups_and_objectives() {
let mut registry = base();
registry
.missions
.get_mut(&ContentId::from("base:sector_1_wave_1"))
.unwrap()
.script = Some("queries.rhai".into());
let mut runtime = ScriptRuntime::new();
runtime.add("base", "queries.rhai", r#"fn on_mission_start(e,s,r) {
#{commands:[],state:#{shield:e.player.shield,inventory:e.player.inventory,
enemies:e.entities.len,shots:e.projectiles.len,pickups:e.pickups.len,objectives:e.objectives}}
}"#).unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
let state = &runtime.states["base/queries.rhai"];
assert_eq!(state["shield"], 100);
assert_eq!(state["inventory"]["base:forward_guns"], 1);
assert_eq!(state["enemies"], 0);
assert_eq!(state["shots"], 0);
assert_eq!(state["pickups"], 0);
assert_eq!(state["objectives"], json!({}));
}
#[test]
fn consumable_special_items_run_on_use_once_per_button_press() {
let mut registry = base();
registry
.apply(
pack(json!({"items":[{
"id":"test:repair","name":"Repair","price":5,"max_count":10,"shield":7,
"special":true,"consumable":true,"script":"repair.rhai"
}]})),
"test",
)
.unwrap();
let mut runtime = ScriptRuntime::new();
runtime
.add(
"test",
"repair.rhai",
r#"fn on_use(e,s,r) {
let n=if s.contains("n") {s.n+1} else {1};
#{commands:[#{op:"credits",amount:5}],state:#{n:n}}
}"#,
)
.unwrap();
let mut pilot = Pilot::default();
pilot.inventory.insert("base:energy".into(), 50);
pilot.inventory.insert("test:repair".into(), 2);
pilot.selected = Some("test:repair".into());
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let input = Input {
special: true,
..Input::default()
};
world.step(input, &registry, &mut runtime).unwrap();
world.step(input, &registry, &mut runtime).unwrap();
assert_eq!(world.player.shield, 57);
assert_eq!(world.player.inventory[&ContentId::from("test:repair")], 1);
assert_eq!(runtime.states["test/repair.rhai"]["n"], 1);
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
world.step(input, &registry, &mut runtime).unwrap();
assert_eq!(world.player.shield, 64);
assert_eq!(world.player.credits, 10);
assert_eq!(runtime.states["test/repair.rhai"]["n"], 2);
}
#[test]
fn wall_clock_sleep_and_indirect_native_calls_are_not_exposed() {
let mut runtime = ScriptRuntime::new();
for source in [
"fn on_tick(e,s,r) { timestamp(); #{commands:[],state:s} }",
"fn on_tick(e,s,r) { sleep(0); #{commands:[],state:s} }",
"fn on_tick(e,s,r) { let f=Fn(\"sleep\"); f.call(0); #{commands:[],state:s} }",
"fn on_tick(e,s,r) { let f=Fn(\"timestamp\"); f.call(); #{commands:[],state:s} }",
] {
runtime.begin_tick();
runtime.add("test", "unsafe.rhai", source).unwrap();
let message = error(runtime.invoke("test", "unsafe.rhai", "on_tick", &json!({}), 1));
assert!(message.contains("Function not found"), "{message}");
}
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
#[test]
fn dynamic_eval_and_imports_cannot_be_reintroduced_by_function_pointers() {
let mut runtime = ScriptRuntime::new();
assert!(
runtime
.add("test", "eval.rhai", "fn on_tick(e,s,r) { eval(\"42\"); }")
.is_err()
);
assert!(
runtime
.add("test", "imports.rhai", "import \"external\" as x;")
.is_err()
);
runtime
.add(
"test",
"indirect.rhai",
"fn on_tick(e,s,r) { let f=Fn(\"eval\"); f.call(\"42\"); #{commands:[],state:s} }",
)
.unwrap();
assert!(
runtime
.invoke("test", "indirect.rhai", "on_tick", &json!({}), 1)
.is_err()
);
}
#[test]
fn script_inventory_commands_preserve_the_twenty_item_capacity() {
let mut registry = base();
let extra: Vec<_> = (0..19)
.map(|n| item(&format!("test:item_{n}"), 1))
.collect();
registry
.apply(pack(json!({"items":extra})), "test")
.unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for n in 0..18 {
world
.apply_commands(
&[Command::GiveItem {
item: format!("test:item_{n}"),
count: 1,
}],
&registry,
)
.unwrap();
}
assert_eq!(world.player.inventory.len(), 20);
let before = world.digest();
assert!(
world
.apply_commands(
&[Command::GiveItem {
item: "test:item_18".into(),
count: 1
}],
&registry
)
.is_err()
);
assert_eq!(before, world.digest());
world
.apply_commands(
&[Command::GiveItem {
item: "test:item_0".into(),
count: 1,
}],
&registry,
)
.unwrap();
assert_eq!(world.player.inventory[&ContentId::from("test:item_0")], 2);
}
#[test]
fn existing_objectives_remain_updatable_at_the_capacity_limit() {
let registry = base();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
for n in 0..1024 {
world.objectives.insert(format!("test:{n}"), 0);
}
world
.apply_commands(
&[Command::Objective {
key: "test:0".into(),
value: 10,
}],
&registry,
)
.unwrap();
assert_eq!(world.objectives["test:0"], 10);
assert!(
world
.apply_commands(
&[Command::Objective {
key: "test:new".into(),
value: 1
}],
&registry
)
.is_err()
);
assert_eq!(world.objectives.len(), 1024);
}
#[test]
fn enemy_weapons_and_enemies_receive_fire_and_damage_hooks() {
let mut registry = base();
registry.apply(pack(json!({
"weapons":[{"id":"test:bolt","name":"Bolt","damage":1,"speed":1,"cooldown":2,"behavior":"straight","target":"all","script":"weapon.rhai"}],
"enemies":[{"id":"test:fighter","name":"Fighter","health":100,"speed":1,"cooldown":2,"weapon":"test:bolt","script":"enemy.rhai"}]
})), "test").unwrap();
let mut runtime = ScriptRuntime::new();
runtime
.add(
"test",
"weapon.rhai",
r#"fn on_fire(e,s,r) {
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
}"#,
)
.unwrap();
runtime
.add(
"test",
"enemy.rhai",
r#"fn on_fire(e,s,r) { #{commands:[],state:#{fired:true}} }
fn on_hit(e,s,r) { s.hit=e.damage; s.scripted=e.scripted; #{commands:[],state:s} }"#,
)
.unwrap();
let pilot = Pilot::default();
let mut world = World::new(&pilot.mission, &pilot, &registry).unwrap();
let entity = world
.spawn(&ContentId::from("test:fighter"), 30, 30, &registry)
.unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(runtime.states["test/weapon.rhai"]["hostile"], true);
assert_eq!(runtime.states["test/weapon.rhai"]["entity"], entity);
assert_eq!(runtime.states["test/enemy.rhai"]["fired"], true);
world
.apply_commands(&[Command::Damage { entity, amount: 7 }], &registry)
.unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(runtime.states["test/enemy.rhai"]["hit"], 7);
assert_eq!(runtime.states["test/enemy.rhai"]["scripted"], true);
}
+119
View File
@@ -0,0 +1,119 @@
use raptor_replica::{
content::{ContentPack, Registry},
modding::{self, ActiveMods, Command, ScriptRuntime},
persistence::Pilot,
simulation::{Input, Outcome, World},
sound,
};
use serde_json::json;
fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec<u8> {
let align = channels * bits / 8;
let mut bytes = b"RIFF".to_vec();
bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes());
bytes.extend(b"WAVEfmt ");
bytes.extend(16u32.to_le_bytes());
bytes.extend(1u16.to_le_bytes());
bytes.extend(channels.to_le_bytes());
bytes.extend(11025u32.to_le_bytes());
bytes.extend((11025u32 * align as u32).to_le_bytes());
bytes.extend(align.to_le_bytes());
bytes.extend(bits.to_le_bytes());
bytes.extend(b"data");
bytes.extend((data.len() as u32).to_le_bytes());
bytes.extend(data);
if data.len() % 2 == 1 {
bytes.push(0);
}
bytes
}
fn registry() -> Registry {
let mut registry = Registry::default();
let pack: ContentPack=serde_json::from_value(json!({
"items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100},
{"id":"base:forward_guns","name":"Guns","price":0}],
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}],
"sounds":[{"id":"base:test","name":"Test","file":"test.wav"}]
})).unwrap();
registry.apply(pack, "base").unwrap();
registry.validate().unwrap();
registry
}
#[test]
fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() {
let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap();
assert_eq!(decoded.data, [0, 128, 255]);
assert_eq!(decoded.rate, 11025);
let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767]
.into_iter()
.flat_map(i16::to_le_bytes)
.collect();
assert_eq!(
sound::decode(&wav(2, 16, &samples)).unwrap().data,
[128, 0, 255]
);
}
#[test]
fn malformed_wave_ranges_formats_and_padding_fail() {
let bytes = wav(1, 8, &[128, 200, 0]);
for end in 0..bytes.len() {
assert!(sound::decode(&bytes[..end]).is_err());
}
assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err());
assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err());
let mut huge = bytes.clone();
huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(sound::decode(&huge).is_err());
}
#[test]
fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() {
let registry = registry();
let mut world = World::new(&Pilot::default().mission, &Pilot::default(), &registry).unwrap();
let mut scripts = ScriptRuntime::new();
scripts.add("base","mission.rhai",r#"
fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} }
fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} }
"#).unwrap();
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.outcome, Outcome::Complete);
assert_eq!(world.mod_sounds.len(), 1);
world
.step(Input::default(), &registry, &mut scripts)
.unwrap();
assert_eq!(world.mod_sounds.len(), 1);
assert!(
world
.apply_commands(
&[Command::PlaySound {
sound: "base:missing".into()
}],
&registry
)
.is_err()
);
}
#[test]
fn package_local_sounds_validate_and_missing_files_reject_activation() {
let dir = tempfile::tempdir().unwrap();
let package = dir.path().join("tone");
std::fs::create_dir_all(&package).unwrap();
std::fs::write(
package.join("mod.toml"),
"id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n",
)
.unwrap();
std::fs::write(
package.join("content.json"),
serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]}))
.unwrap(),
)
.unwrap();
std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap();
let found = modding::discover(dir.path());
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok());
std::fs::remove_file(package.join("shot.wav")).unwrap();
let found = modding::discover(dir.path());
assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err());
}