Load engines from TOML and improve scenario audio and carb tuning

Migrate 32 engine definitions into validated startup-loaded TOML files and separate configuration and snapshots from scenario assembly. Add camera-based stereo propagation from shared pipe geometry, preserve exhaust character, tune carburetor mixtures and idle, use ignition cuts for carb engines, and support returning to the scenario menu.

Validation: full Windows build and all 14 CTest checks passed.
This commit is contained in:
max
2026-10-24 03:24:35 +02:00
parent 5607dcf2b5
commit bf8621ad39
82 changed files with 24084 additions and 648 deletions

View File

@@ -33,5 +33,18 @@ int main() {
camera.orbit({40,20});camera.pan({20,15},500);camera.zoom(2);
check(camera.pitch<=80 && camera.pitch>=-75 && camera.distance>.35f,"camera bounds");
check(camera.target.x!=before.x || camera.target.y!=before.y,"camera panning must move target");
for(float yaw:{-90.f,-28.f,0.f,90.f,180.f})for(float pitch:{-60.f,0.f,70.f}) {
camera.yaw=yaw;camera.pitch=pitch;camera.target={.7f,.4f,-.2f};camera.distance=3;
const auto listener=camera.listener(1.8f,1.1f);
const float y=yaw*.017453293f,p=pitch*.017453293f;
const float dx=listener.position.x-camera.target.x-1.1f;
const float dy=listener.position.y-camera.target.y,dz=listener.position.z-camera.target.z;
const float x1=std::cos(y)*dx+std::sin(y)*dz,z1=-std::sin(y)*dx+std::cos(y)*dz;
check(std::abs(x1)<2e-6f && std::abs(std::cos(p)*dy-std::sin(p)*z1)<2e-6f &&
std::abs(std::sin(p)*dy+std::cos(p)*z1-5.4f)<2e-6f,
"audio listener must coincide with the rendered camera including fit offsets");
check(std::abs(std::cos(y)*listener.right.x+std::sin(y)*listener.right.z-1)<1e-6f,
"audio ears must rotate with the camera right axis");
}
std::cout<<"Desktop layout, spring pedal, keyboard travel and orbit camera checks passed\n";
}

View File

@@ -0,0 +1,78 @@
#include "FluidSim/ScenarioCatalog.hpp"
#include "FluidSim/DesktopScenario.hpp"
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const std::string& message) {if(!condition)throw std::runtime_error(message);}
std::string read(const std::filesystem::path& path) {
std::ifstream file(path);std::ostringstream text;text<<file.rdbuf();return text.str();
}
void replace(std::string& text,const std::string& from,const std::string& to) {
const auto pos=text.find(from);check(pos!=std::string::npos,"test fixture missing "+from);text.replace(pos,from.size(),to);
}
struct TemporaryDirectory {
std::filesystem::path path=std::filesystem::temp_directory_path()/
("fluidsim-engine-config-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
TemporaryDirectory() {std::filesystem::create_directory(path);}
~TemporaryDirectory() {std::error_code error;std::filesystem::remove_all(path,error);}
};
}
int main() {
const auto& catalog=scenario_examples();
check(catalog.size()>=32,"bundled engines missing");
const auto& thumper=scenario_example("single-450");
check(thumper.engine.carbureted && std::abs(thumper.engine.cylinder.bore-.096f)<1e-7f,
"four-stroke units or fuel type changed");
check(std::abs(scenario_example("yz-racing").two.idle_slide_opening-.03f)<1e-7f,
"125 idle tune lost");
for(const auto& e:catalog) {
DesktopScenario s(e);s.set_controls(.35f,0,1);
for(int i=0;i<256;++i) {
const auto audio=s.process();
check(std::isfinite(audio.left) && std::isfinite(audio.right),e.id+": invalid audio after TOML load");
}
const auto state=s.snapshot();
check(state.rpm>0 && state.pipe_positivity_repairs==0,e.id+": startup changed");
}
TemporaryDirectory temp;
const auto file=temp.path/"test.toml";
const auto original=read(engine_directory()/"twin.toml");
const auto reject=[&](std::string text,const std::string& expected) {
{std::ofstream output(file);output<<text;}
bool rejected=false;
try {load_engine_file(file);}
catch(const std::exception& error) {
const std::string message=error.what();
check(message.find("test.toml")!=std::string::npos,"diagnostic lost the file path");
check(message.find(expected)!=std::string::npos,"unexpected diagnostic: "+message);
rejected=true;
}
check(rejected,"invalid config accepted: "+expected);
};
auto changed=original;replace(changed,"schema_version = 1","schema_version = 99");reject(changed,"schema_version");
changed=original;replace(changed,"bore_mm = 75.0","bore_mn = 75.0");reject(changed,"bore_mm");
changed=original;replace(changed,"bore_mm = 75.0","bore_mm = -75.0");reject(changed,"cylinder");
changed=original;replace(changed,"cylinders = 2","cylinders = 2.5");reject(changed,"integer");
changed=original;replace(changed,"layout = \"inline\"","layout = \"flatbread\"");reject(changed,"layout");
changed=original;replace(changed,"topology = \"collector\"","topology = \"american_v8\"");reject(changed,"V8");
changed=original;replace(changed,"cells = 10","cells = 1000000");reject(changed,"cell");
changed=original;replace(changed,"phase_degrees = [0.0, 180.0]","phase_degrees = [0.0]");reject(changed,"phase_degrees");
reject(original+"\n[unknown_section]\nvalue = 1\n","unknown field");
reject("schema_version = [","Error");
{std::ofstream output(file);output<<original;}
std::filesystem::copy_file(file,temp.path/"duplicate.toml");
bool duplicate=false;
try {load_engine_directory(temp.path);}catch(const std::exception& error) {
duplicate=std::string(error.what()).find("duplicate engine id")!=std::string::npos;
}
check(duplicate,"duplicate engine IDs accepted");
std::cout<<catalog.size()<<" engine imports, startup smoke checks and invalid-config diagnostics passed\n";
}

View File

@@ -1,5 +1,9 @@
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/ReedValve.hpp"
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioGeometry.hpp"
#include "FluidSim/DesktopScenario.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
@@ -98,6 +102,85 @@ int main() {
}
check(right < left, "source on right does not reach right ear first");
// A moved listener must converge to the same propagation as one created
// there, including the reversed ear orientation and the reflected field.
auto moved_config=stereo_config;
moved_config.listener={-.5f,1.5f,4};
moved_config.listener_right={-1,0,0};
OutdoorAudio moving(44100,stereo_config),destination(44100,moved_config);
moving.set_listener({moved_config.listener,moved_config.listener_right});
for(auto* p:{&moving,&destination})p->set_monitor_filter(false);
for(int i=0;i<44100;++i) {moving.process(0,0);destination.process(0,0);}
for(int i=0;i<6000;++i) {
const auto a=moving.process(0,.001f),b=destination.process(0,.001f);
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
"moving listener does not update direct/reflected stereo paths");
}
// Exercise rapid pose changes while a wet tail is still ringing.
for(int i=0;i<22050;++i) {
if(i%735==0) {
const float angle=i*.0002f;
moving.set_listener({{3*std::sin(angle),1,3*std::cos(angle)},
{std::cos(angle),0,-std::sin(angle)}});
}
const auto a=moving.process(0,.001f);
check(std::isfinite(a.left) && std::isfinite(a.right) &&
std::abs(a.left)<1 && std::abs(a.right)<1,"moving wet paths became unstable");
}
// Source relocation must use geometry without resetting audio state.
auto source_config=config();source_config.exhaust_position={2,1,-1};
OutdoorAudio relocated(44100,config()),fixed_source(44100,source_config);
relocated.set_source_positions({source_config.intake_position,source_config.exhaust_position,
source_config.second_exhaust_position});
for(int i=0;i<44100;++i) {relocated.process(0,0);fixed_source.process(0,0);}
for(int i=0;i<3000;++i) {
const auto a=relocated.process(0,.001f),b=fixed_source.process(0,.001f);
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
"relocated mouth does not match world source position");
}
ScenarioSnapshot geometry;
geometry.two_stroke=true;
auto mouths=scenario_sound_sources(geometry);
check(mouths[0].x==-1.05f && mouths[1].x==1.45f && mouths[1].y==.23f,
"two-stroke mouths are not on the drawn pipe ends");
geometry.two_stroke=false;geometry.cylinder_count=8;geometry.layout=EngineLayout::V;
geometry.v8_headers=true;geometry.dual_exhaust=true;
mouths=scenario_sound_sources(geometry);
check(mouths[1].x==2.94f && mouths[1].z==-.39f && mouths[2].z==.39f,
"dual V8 outlets are not at their separate tailpipe mouths");
geometry.v8_headers=false;geometry.dual_exhaust=false;geometry.silencers=true;
geometry.cylinder_count=4;geometry.layout=EngineLayout::Inline;
const auto& e=geometry.exhaust_config;
mouths=scenario_sound_sources(geometry);
check(std::abs(mouths[1].x-(.74f+e.inlet_length+e.resonator.length+e.connecting_length+
e.muffler.length+e.outlet_length))<1e-6f,
"silenced exhaust source does not follow the configured pipe length");
// Crossing a reflecting plane must not turn its virtual image into a
// source that arrives before the actual pipe mouth.
auto behind_config=config();behind_config.listener={-4,1,0};
OutdoorAudio behind_wall(44100,behind_config);
behind_wall.set_monitor_filter(false);
for(int i=0;i<4410;++i)behind_wall.process(0,0);
int first_behind=-1;
for(int i=0;i<1000;++i) {
const auto a=behind_wall.process(0,.001f);
if(first_behind<0 && std::abs(a.left)>1e-9f)first_behind=i;
}
check(first_behind==514,"reflection crosses wall and precedes the physical source");
for(const auto& example:scenario_examples()) {
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.junctions,example.engine);
scenario.set_listener({{2,1,3},{1,0,0}});
const auto snapshot=scenario.snapshot();
const auto sources=scenario_sound_sources(snapshot);
for(auto source:sources)
check(std::isfinite(source.x) && std::isfinite(source.y) && std::isfinite(source.z),
"catalog scenario has an invalid sound source");
const auto frame=scenario.process();
check(std::isfinite(frame.left) && std::isfinite(frame.right),"catalog listener wiring failed");
}
ReedValve reed;
for (int i = 0; i < 10000; i++)
reed.step(1.f / 44100, 100);

View File

@@ -10,7 +10,7 @@ int main() {
std::set<std::string> ids;
for(const auto& example:scenario_examples()) {
if(!ids.insert(example.id).second)throw std::runtime_error("duplicate example id");
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.v8,example.engine,example.title);
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.junctions,example.engine,example.title);
scenario.set_audio_scene(OutdoorScene::Dry);
const auto started=std::chrono::steady_clock::now();
float peak_power=0;
@@ -25,9 +25,17 @@ int main() {
if(example.id=="big-carb" && peak_power<10000)throw std::runtime_error("big carb V8 failed to produce power");
if(!std::isfinite(snapshot.rpm)||snapshot.rpm<=0)throw std::runtime_error(example.id+" invalid engine speed");
if(example.engine.cylinders && (snapshot.cylinder_count!=example.engine.cylinders || snapshot.layout!=example.engine.layout))throw std::runtime_error("custom cylinder layout lost");
const bool custom_v8=example.engine.american_headers();
if(custom_v8 && (!snapshot.v8_headers || snapshot.h_pipe!=example.junctions.h_pipe || snapshot.pipe_states[25].empty()))
throw std::runtime_error("custom V8 lost American junction headers/crossover");
if(example.engine.staged_headers() && example.engine.layout==EngineLayout::Inline) {
if(!snapshot.staged_headers || snapshot.dual_exhaust)
throw std::runtime_error("inline engine lost staged junction collectors");
}
if(example.engine.staged_headers() && example.engine.layout==EngineLayout::V && (!snapshot.dual_exhaust || !snapshot.staged_headers))
throw std::runtime_error("large V engine lost separate bank outlets");
if(snapshot.example_name!=example.title)throw std::runtime_error("example name lost");
if(example.kind==ScenarioKind::AmericanV8 && snapshot.h_pipe!=example.v8.h_pipe)throw std::runtime_error("example crossover selection lost");
if(example.kind==ScenarioKind::SaabB205 && snapshot.silencers!=example.exhaust.enabled)throw std::runtime_error("example silencer selection lost");
if(example.engine.has_silenced_exhaust() && snapshot.silencers!=example.exhaust.enabled)throw std::runtime_error("example silencer selection lost");
std::cout<<example.id<<" passed, rpm="<<snapshot.rpm<<", wall/sim="<<std::chrono::duration<double>(std::chrono::steady_clock::now()-started).count()/2<<std::endl;
}
}