@@ -1299,4 +1299,185 @@ init_mode = "Random"
12991299 assert ! ( ( new_state. temporal_lag_frames - 12.0 ) . abs( ) < 1e-6 ) ;
13001300 assert_eq ! ( new_state. temporal_mode, TemporalMode :: Accent ) ;
13011301 }
1302+
1303+ #[ test]
1304+ fn diffusion_decay_art_knobs_round_trip ( ) {
1305+ // Verify all five diffusion/decay art knobs survive from_runtime →
1306+ // TOML serialize → TOML deserialize → apply_to_runtime_state.
1307+ let mut state = create_test_runtime_state ( ) ;
1308+ state. afterglow = 0.4 ;
1309+ state. afterglow_rate = 0.03 ;
1310+ state. decay_gamma = 0.6 ;
1311+ state. diffuse_weight = 0.5 ;
1312+ state. diffusion_sigma = 3.0 ;
1313+
1314+ let sim_config = SimConfig {
1315+ sensor_angle : state. sensor_angle ,
1316+ sensor_distance : 9.0 ,
1317+ rotation_angle : state. rotation_angle ,
1318+ step_size : state. step_size ,
1319+ decay_factor : state. decay_factor ,
1320+ deposit_amount : state. deposit_amount ,
1321+ diffusion_kernel : state. diffusion_kernel ,
1322+ diffusion_sigma : state. diffusion_sigma ,
1323+ afterglow : state. afterglow ,
1324+ afterglow_rate : state. afterglow_rate ,
1325+ diffuse_weight : state. diffuse_weight ,
1326+ decay_gamma : state. decay_gamma ,
1327+ max_brightness : state. max_brightness ,
1328+ time_scale : 1.0 ,
1329+ attractors : Vec :: new ( ) ,
1330+ attractor_strength : 1.0 ,
1331+ mouse_attractors : Vec :: new ( ) ,
1332+ mouse_timeout : 3.0 ,
1333+ species_configs : vec ! [ SpeciesConfig {
1334+ name: "default" . to_string( ) ,
1335+ count: 1000 ,
1336+ sensor_angle: state. sensor_angle,
1337+ rotation_angle: state. rotation_angle,
1338+ step_size: state. step_size,
1339+ deposit_amount: state. deposit_amount,
1340+ color: RgbColor :: from_hex( 0x228b22 ) ,
1341+ trail_modulation: None ,
1342+ } ] ,
1343+ separate_species_trails : false ,
1344+ use_simd : true ,
1345+ food_image_path : None ,
1346+ food_image_invert : false ,
1347+ food_image_scale : 1.0 ,
1348+ obstacles : Vec :: new ( ) ,
1349+ obstacle_masks : Vec :: new ( ) ,
1350+ wind : None ,
1351+ terrain : crate :: simulation:: config:: TerrainType :: None ,
1352+ terrain_strength : 1.0 ,
1353+ background_color : None ,
1354+ preferred_init_mode : None ,
1355+ boundary_mode : crate :: simulation:: config:: BoundaryMode :: Bounce ,
1356+ respawn_config : crate :: simulation:: config:: RespawnConfig :: default ( ) ,
1357+ sampling_mode : crate :: simulation:: config:: SamplingMode :: Nearest ,
1358+ window_frame : crate :: simulation:: config:: WindowFrame :: None ,
1359+ chrome_style : crate :: simulation:: config:: ChromeStyle :: default ( ) ,
1360+ aspect : crate :: simulation:: config:: Aspect :: default ( ) ,
1361+ window_padding : crate :: simulation:: config:: WindowPadding :: default ( ) ,
1362+ show_status_bar : false ,
1363+ min_sim_size : crate :: simulation:: config:: TerminalSizeThreshold :: default ( ) ,
1364+ min_frame_size : crate :: simulation:: config:: TerminalSizeThreshold {
1365+ width : 12 ,
1366+ height : 6 ,
1367+ } ,
1368+ } ;
1369+
1370+ let saved = SavedConfig :: from_runtime (
1371+ "art_knobs_rt" . to_string ( ) ,
1372+ & sim_config,
1373+ crate :: cli:: Palette :: Organic ,
1374+ crate :: render:: charset:: Charset :: HalfBlock ,
1375+ false ,
1376+ false ,
1377+ 0 ,
1378+ false ,
1379+ false ,
1380+ false ,
1381+ None ,
1382+ crate :: simulation:: config:: InitMode :: Random ,
1383+ None ,
1384+ None ,
1385+ 0.0 ,
1386+ 8.0 ,
1387+ crate :: render:: palette:: TemporalMode :: Hue ,
1388+ state. afterglow ,
1389+ state. afterglow_rate ,
1390+ state. decay_gamma ,
1391+ state. diffuse_weight ,
1392+ ) ;
1393+
1394+ // Serialize and deserialize through TOML
1395+ let toml_str = toml:: to_string ( & saved) . expect ( "serialize must succeed" ) ;
1396+ let reloaded: SavedConfig = toml:: from_str ( & toml_str) . expect ( "deserialize must succeed" ) ;
1397+
1398+ // Restore into a fresh RuntimeState
1399+ let mut new_state = create_test_runtime_state ( ) ;
1400+ reloaded
1401+ . apply_to_runtime_state ( & mut new_state)
1402+ . expect ( "apply must succeed" ) ;
1403+
1404+ assert ! (
1405+ ( new_state. afterglow - 0.4 ) . abs( ) < 1e-6 ,
1406+ "afterglow must survive round-trip (got {})" ,
1407+ new_state. afterglow
1408+ ) ;
1409+ assert ! (
1410+ ( new_state. afterglow_rate - 0.03 ) . abs( ) < 1e-6 ,
1411+ "afterglow_rate must survive round-trip (got {})" ,
1412+ new_state. afterglow_rate
1413+ ) ;
1414+ assert ! (
1415+ ( new_state. decay_gamma - 0.6 ) . abs( ) < 1e-6 ,
1416+ "decay_gamma must survive round-trip (got {})" ,
1417+ new_state. decay_gamma
1418+ ) ;
1419+ assert ! (
1420+ ( new_state. diffuse_weight - 0.5 ) . abs( ) < 1e-6 ,
1421+ "diffuse_weight must survive round-trip (got {})" ,
1422+ new_state. diffuse_weight
1423+ ) ;
1424+ assert ! (
1425+ ( new_state. diffusion_sigma - 3.0 ) . abs( ) < 1e-6 ,
1426+ "diffusion_sigma must survive round-trip (got {})" ,
1427+ new_state. diffusion_sigma
1428+ ) ;
1429+ }
1430+
1431+ #[ test]
1432+ fn old_toml_without_art_knobs_loads_with_defaults ( ) {
1433+ // An OLD TOML without afterglow/decay_gamma/diffuse_weight must still
1434+ // deserialize, and apply_to_runtime_state must produce the canonical
1435+ // defaults (afterglow=0.0, afterglow_rate=0.05, decay_gamma=1.0,
1436+ // diffuse_weight=1.0).
1437+ let toml = r#"name = "old_no_knobs"
1438+ population = 1000
1439+ sensor_angle = 22.5
1440+ sensor_distance = 9.0
1441+ rotation_angle = 45.0
1442+ step_size = 1.0
1443+ decay_factor = 0.9
1444+ deposit_amount = 5.0
1445+ max_brightness = 100.0
1446+ diffusion_kernel = "Mean3x3"
1447+ diffusion_sigma = 1.0
1448+ palette = "Organic"
1449+ charset = "HalfBlock"
1450+ reverse_palette = false
1451+ invert_palette = false
1452+ warmup_frames = 0
1453+ food_persist = false
1454+ auto_reset = false
1455+ grid = false
1456+ init_mode = "Random"
1457+ "# ;
1458+ let cfg: SavedConfig =
1459+ toml:: from_str ( toml) . expect ( "old config without art knobs must load" ) ;
1460+ assert ! (
1461+ cfg. afterglow. is_none( ) ,
1462+ "missing key must deserialize as None"
1463+ ) ;
1464+ assert ! ( cfg. afterglow_rate. is_none( ) ) ;
1465+ assert ! ( cfg. decay_gamma. is_none( ) ) ;
1466+ assert ! ( cfg. diffuse_weight. is_none( ) ) ;
1467+
1468+ // apply_to_runtime_state must fill defaults from the unwrap_or paths.
1469+ let mut state = create_test_runtime_state ( ) ;
1470+ cfg. apply_to_runtime_state ( & mut state)
1471+ . expect ( "legacy config must still apply" ) ;
1472+ assert_eq ! ( state. afterglow, 0.0 , "default afterglow must be 0.0" ) ;
1473+ assert ! (
1474+ ( state. afterglow_rate - 0.05 ) . abs( ) < 1e-6 ,
1475+ "default afterglow_rate must be 0.05"
1476+ ) ;
1477+ assert_eq ! ( state. decay_gamma, 1.0 , "default decay_gamma must be 1.0" ) ;
1478+ assert_eq ! (
1479+ state. diffuse_weight, 1.0 ,
1480+ "default diffuse_weight must be 1.0"
1481+ ) ;
1482+ }
13021483}
0 commit comments