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

@@ -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);