Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ jobs:
language-adapters:
name: Language adapter conformance
runs-on: ubuntu-latest
env:
RUSTIC_REQUIRE_ALL_API_ADAPTERS: '1'
timeout-minutes: 40
defaults:
run:
Expand Down Expand Up @@ -156,6 +158,8 @@ jobs:
- run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture
- name: Scene environment scripting across all adapters
run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocapture
- name: Audio, capture and live reads across all adapters
run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play audio_capture_and_live_reads_conform -- --nocapture
- name: Entity foundations across all adapters
env:
RUSTIC_REQUIRE_ALL_ENTITY_ADAPTERS: '1'
Expand Down
156 changes: 156 additions & 0 deletions Engine/crates/engine-play/src/script_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,30 @@ impl GameplayHost for RuntimeHost {
.get("op")
.and_then(serde_json::Value::as_str)
.ok_or("query operation is missing")?;
if op == "find_entity" {
let source = query["source"]
.as_str()
.ok_or("entity source must be a string")?;
return serde_json::to_value(self.find_entity(source)?).map_err(|e| e.to_string());
}
if op == "object_attribute" {
let source = query["source"]
.as_str()
.ok_or("object source must be a string")?;
let name = query["name"]
.as_str()
.ok_or("attribute name must be a string")?;
return Ok(match self.object_attribute(source, name)? {
None | Some(EngineValue::Entity(None)) => serde_json::Value::Null,
Some(EngineValue::Boolean(value)) => serde_json::json!(value),
Some(EngineValue::Integer(value)) => serde_json::json!(value),
Some(EngineValue::Number(value)) => serde_json::json!(value),
Some(EngineValue::String(value)) => serde_json::json!(value),
Some(EngineValue::Vec2(value)) => serde_json::json!(value),
Some(EngineValue::Vec3(value)) => serde_json::json!(value),
Some(EngineValue::Entity(Some(value))) => serde_json::json!(value),
});
}
if crate::entity_services::handles(op) {
let mut world = self.world.lock().map_err(|_| "world lock poisoned")?;
return crate::entity_services::query(&mut world, query);
Expand Down Expand Up @@ -2162,6 +2186,138 @@ mod tests {
};
use engine_world::{EntitySnapshot, SceneDocument, ScriptComponent};

#[test]
#[allow(
clippy::too_many_lines,
reason = "one shared fixture harness verifies all ten adapters"
)]
fn audio_capture_and_live_reads_conform_in_every_available_adapter() {
let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[
(
ScriptLanguage::Lua54,
"parity.lua",
include_bytes!("../tests/fixtures/api-parity/parity.lua").as_slice(),
),
(
ScriptLanguage::Luau,
"parity.luau",
include_bytes!("../tests/fixtures/api-parity/parity.luau").as_slice(),
),
(
ScriptLanguage::JavaScript,
"parity.js",
include_bytes!("../tests/fixtures/api-parity/parity.js").as_slice(),
),
(
ScriptLanguage::Web,
"parity.html",
include_bytes!("../tests/fixtures/api-parity/parity.html").as_slice(),
),
(
ScriptLanguage::Python,
"parity.py",
include_bytes!("../tests/fixtures/api-parity/parity.py").as_slice(),
),
(
ScriptLanguage::C,
"parity.c",
include_bytes!("../tests/fixtures/api-parity/parity.c").as_slice(),
),
(
ScriptLanguage::Cpp,
"parity.cpp",
include_bytes!("../tests/fixtures/api-parity/parity.cpp").as_slice(),
),
(
ScriptLanguage::CSharp,
"parity.cs",
include_bytes!("../tests/fixtures/api-parity/parity.cs").as_slice(),
),
(
ScriptLanguage::Java,
"parity.java",
include_bytes!("../tests/fixtures/api-parity/parity.java").as_slice(),
),
(
ScriptLanguage::Php,
"parity.php",
include_bytes!("../tests/fixtures/api-parity/parity.php").as_slice(),
),
];
for &(language, name, source) in fixtures {
if !matches!(
language,
ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web
) && !engine_scripting::probe_language_toolchain(language).available
{
assert!(
std::env::var_os("RUSTIC_REQUIRE_ALL_API_ADAPTERS").is_none(),
"{} toolchain required for API conformance",
language.display_name()
);
eprintln!(
"SKIPPED API conformance {}: missing toolchain",
language.display_name()
);
continue;
}
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join("assets")).unwrap();
let path = temp.path().join("assets/tone.wav");
let mut wav = hound::WavWriter::create(
&path,
hound::WavSpec {
channels: 1,
sample_rate: 8000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
},
)
.unwrap();
wav.write_sample(1000_i16).unwrap();
wav.finalize().unwrap();
let mut world = SceneWorld::new();
let entity = EntitySnapshot::default();
let id = entity.id;
world
.apply_commands(&[WorldCommand::Spawn(Box::new(entity))])
.unwrap();
let world = Arc::new(Mutex::new(world));
let mut host = environment_host(&world);
host.entity = id;
host.snapshot_root = temp.path().into();
let context = Arc::clone(&host.gameplay);
let mut behavior =
RuntimeBehavior::load(language, ScriptId::new(), source, name, Box::new(host))
.unwrap_or_else(|e| panic!("{} load: {e}", language.display_name()));
behavior
.on_start()
.unwrap_or_else(|e| panic!("{} start: {e}", language.display_name()));
assert_eq!(
context.lock().unwrap().audio.voices.len(),
1,
"{} playback",
language.display_name()
);
std::fs::remove_file(path).unwrap();
context
.lock()
.unwrap()
.audio_capture
.submit(&[0.25, -0.25, 0.5, -0.5]);
behavior
.update(0.01)
.unwrap_or_else(|e| panic!("{} update: {e}", language.display_name()));
assert!(context.lock().unwrap().audio.voices.is_empty());
let mut wav = hound::WavReader::open(temp.path().join("capture.wav")).unwrap();
assert_eq!(
wav.samples::<f32>().collect::<Result<Vec<_>, _>>().unwrap(),
vec![0.25, -0.25, 0.5, -0.5]
);
eprintln!("PASSED API conformance {}", language.display_name());
}
}

#[test]
fn lua_and_luau_recording_handles_retain_samples_and_save_wav() {
for language in [ScriptLanguage::Lua54, ScriptLanguage::Luau] {
Expand Down
29 changes: 29 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#include "rustic.h"
#include <assert.h>
RusticLoadedAudio audio;
RusticCaptureHandle rec;
void start(void){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
const char *id=rustic.entity_id();
assert(strcmp(rustic.find_entity(id),id)==0);
assert(rustic.get_object_attribute(id,"Position").vector[0]==3);
RusticList ids=Game.scene.List(NULL);assert(ids.count==1 && strcmp(ids.items[0],id)==0);
assert(Clock.state().scale==1);
audio=Audio.load("assets/tone.wav");
RusticAudioOptions options={.volume=1,.pitch=1,.loop=true};
LoadedAudio.play(audio,&options);LoadedAudio.pause(audio);LoadedAudio.resume(audio);LoadedAudio.stop(audio);LoadedAudio.play(audio,&options);
rec=Game.startRecording("capture.wav",NULL);
}
void update(double dt){
(void)dt;
LoadedAudio.play(audio,NULL);LoadedAudio.stop(audio);LoadedAudio.unload(&audio);LoadedAudio.unload(&audio);
Game.stopRecording(&rec);
RusticCaptureData packet=Capture.read(rec);
assert(packet.sampleRate==48000 && packet.channels==2 && !packet.active);
assert(packet.count==4 && packet.samples[0]==0.25 && packet.samples[3]==-0.5);
assert(Capture.read(rec).count==0);
assert(packet.samples[0]==0.25); /* Reads retain earlier packets during this callback. */
Capture.unload(rec);
}
int main(void){return rustic_run((RusticBehavior){.on_start=start,.update=update});}
27 changes: 27 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#include "rustic.hpp"
#include <cassert>
std::optional<Audio::LoadedAudio> audio;
RusticCaptureHandle rec;
void start(){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
auto id=rustic.entity_id();
assert(rustic.find_entity(id)==id);
assert(rustic.get_object_attribute(id,"Position").array()[0].number()==3);
assert(Game.scene.List()==std::vector<std::string>{id});
assert(Clock::state().at("scale").number()==1);
audio=Audio::load("assets/tone.wav");
assert(&audio->load()==&*audio);
audio->play(1,1,true);audio->pause();audio->resume();audio->stop();audio->play(1,1,true);
rec=Game.startRecording("capture.wav");
}
void update(double){
audio->play();audio->stop();audio->unload();audio->unload();
Game.stopRecording(&rec);
auto packet=rec.read();
assert(packet.at("sampleRate").number()==48000 && packet.at("channels").number()==2 && !packet.at("active").boolean());
assert(packet.at("samples").array().size()==4 && packet.at("samples").array()[0].number()==0.25 && packet.at("samples").array()[3].number()==-0.5);
assert(rec.read().at("samples").array().empty());
rec.unload();
}
int main(){return rustic_run(RusticBehavior{.on_start=start,.update=update});}
28 changes: 28 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using static Rustic;
class Program {
static Audio.LoadedAudio audio=null!;
static CaptureHandle rec=null!;
static void Main()=>Rustic.Run((callback,dt)=>{
if(callback=="on_start"){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
string id=rustic.entity_id();
if(rustic.find_entity(id)!=id || ((double[])rustic.get_object_attribute(id,"Position")!)[0]!=3)throw new Exception("live object read");
if(System.Linq.Enumerable.Count(Game.scene.List())!=1)throw new Exception("scene list");
if(Clock.state().GetProperty("scale").GetDouble()!=1)throw new Exception("clock");
audio=Audio.load("assets/tone.wav");
if(audio.load()!=audio)throw new Exception("load identity");
audio.play(loop:true);audio.pause();audio.resume();audio.stop();audio.play(loop:true);
rec=Game.startRecording("capture.wav");
}else if(callback=="update"){
audio.play();audio.stop();audio.unload();audio.unload();
Game.stopRecording(rec);
var packet=rec.read();
var samples=packet.GetProperty("samples");
if(packet.GetProperty("sampleRate").GetInt32()!=48000 || packet.GetProperty("channels").GetInt32()!=2 || packet.GetProperty("active").GetBoolean() || samples.GetArrayLength()!=4 || samples[0].GetDouble()!=0.25 || samples[3].GetDouble()!=-0.5)throw new Exception("PCM");
if(rec.read().GetProperty("samples").GetArrayLength()!=0)throw new Exception("cursor");
rec.unload();
}
});
}
25 changes: 25 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html><html><body><script>let audio,rec;
globalThis.behavior={Start(){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
const id=rustic.entity_id();
if(rustic.find_entity(id)!==id || rustic.get_object_attribute(id,'Position')[0]!==3)throw Error('live object read');
if(Game.scene.List().length!==1 || Game.scene.List()[0]!==id)throw Error('scene list IDs');
if(Clock.state().scale!==1)throw Error('clock');
const asset=rustic.game.assets["tone.wav"];
const loaded=asset.load();
if(loaded.load()!==loaded)throw Error('proxy load identity');
asset.play();asset.stop();asset.unload();
audio=Audio.load('assets/tone.wav');
if(audio.load()!==audio)throw Error('load identity');
audio.play({loop:true});audio.pause();audio.resume();audio.stop();audio.play({loop:true});
rec=Game.startRecording('capture.wav');
},Update(){
audio.play();audio.stop();audio.unload();audio.unload();
Game.stopRecording(rec);
const packet=rec.read();
if(packet.sampleRate!==48000 || packet.channels!==2 || packet.active || packet.samples.length!==4 || packet.samples[0]!==0.25 || packet.samples[3]!==-0.5)throw Error('PCM');
if(rec.read().samples.length!==0)throw Error('cursor');
rec.unload();
}};
</script></body></html>
28 changes: 28 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class RusticBehavior extends Rustic {
static Audio.LoadedAudio audio;
static CaptureHandle rec;
public static void main(String[] args) throws Exception {
run((callback,dt)->{
if(callback.equals("on_start")){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
String id=rustic.entity_id();
if(!id.equals(rustic.find_entity(id)) || ((double[])rustic.get_object_attribute(id,"Position"))[0]!=3)throw new AssertionError("live object read");
if(!Game.scene.List().equals(java.util.List.of(id)))throw new AssertionError("scene list");
if(((Number)((java.util.Map<?,?>)Clock.state()).get("scale")).doubleValue()!=1)throw new AssertionError("clock");
audio=Audio.load("assets/tone.wav");
if(audio.load()!=audio)throw new AssertionError("load identity");
audio.play(1,1,true);audio.pause();audio.resume();audio.stop();audio.play(1,1,true);
rec=Game.startRecording("capture.wav");
}else if(callback.equals("update")){
audio.play();audio.stop();audio.unload();audio.unload();
Game.stopRecording(rec);
var packet=(java.util.Map<?,?>)rec.read();
var samples=(java.util.List<?>)packet.get("samples");
if(((Number)packet.get("sampleRate")).intValue()!=48000 || ((Number)packet.get("channels")).intValue()!=2 || (Boolean)packet.get("active") || samples.size()!=4 || ((Number)samples.get(0)).doubleValue()!=0.25 || ((Number)samples.get(3)).doubleValue()!=-0.5)throw new AssertionError("PCM");
if(!((java.util.List<?>)((java.util.Map<?,?>)rec.read()).get("samples")).isEmpty())throw new AssertionError("cursor");
rec.unload();
}
});
}
}
24 changes: 24 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
let audio,rec;
globalThis.behavior={Start(){
rustic.print("info");rustic.warn("warn");
rustic.set_translation(3,2,1);
const id=rustic.entity_id();
if(rustic.find_entity(id)!==id || rustic.get_object_attribute(id,'Position')[0]!==3)throw Error('live object read');
if(Game.scene.List().length!==1 || Game.scene.List()[0]!==id)throw Error('scene list IDs');
if(Clock.state().scale!==1)throw Error('clock');
const asset=rustic.game.assets["tone.wav"];
const loaded=asset.load();
if(loaded.load()!==loaded)throw Error('proxy load identity');
asset.play();asset.stop();asset.unload();
audio=Audio.load('assets/tone.wav');
if(audio.load()!==audio)throw Error('load identity');
audio.play({loop:true});audio.pause();audio.resume();audio.stop();audio.play({loop:true});
rec=Game.startRecording('capture.wav');
},Update(){
audio.play();audio.stop();audio.unload();audio.unload();
Game.stopRecording(rec);
const packet=rec.read();
if(packet.sampleRate!==48000 || packet.channels!==2 || packet.active || packet.samples.length!==4 || packet.samples[0]!==0.25 || packet.samples[3]!==-0.5)throw Error('PCM');
if(rec.read().samples.length!==0)throw Error('cursor');
rec.unload();
}};
22 changes: 22 additions & 0 deletions Engine/crates/engine-play/tests/fixtures/api-parity/parity.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
local audio,rec
return {Start=function()
rustic.print("info");rustic.warn("warn")
rustic.set_translation(3,2,1)
local id=rustic.entity_id()
assert(rustic.find_entity(id)==id)
assert(rustic.get_object_attribute(id,'Position')[1]==3)
assert(#Game.scene.List()==1)
assert(Clock.state().scale==1)
audio=Audio.load('assets/tone.wav')
assert(audio:load()==audio)
audio:play({loop=true});audio:pause();audio:resume();audio:stop();audio:play({loop=true})
rec=Game.startRecording('capture.wav')
end,Update=function()
audio:play();audio:stop();audio:unload();audio:unload()
Game.stopRecording(rec)
local packet=rec:read()
assert(packet.sampleRate==48000 and packet.channels==2 and not packet.active)
assert(#packet.samples==4 and packet.samples[1]==0.25 and packet.samples[4]==-0.5)
assert(#rec:read().samples==0)
rec:unload()
end}
Loading
Loading