1mod helptext; 2 3use crate::app::widgets::NumericTextEdit; 4use crate::app::{App, OpenWindow, widgets}; 5use crate::emurunner::EmuRunnerStatus; 6use egui::emath::Align; 7use egui::epaint::Color32; 8use egui::epaint::ImageDelta; 9use egui::epaint::textures::TextureWrapMode; 10use egui::{ 11 ColorImage, Context, ImageData, Layout, Slider, TextureFilter, TextureId, TextureOptions, Ui, 12 Window, 13}; 14use emath::Vec2; 15use jgenesis_common::frontend::TimingMode; 16use nes_config::palettes::PaletteGenerationArgs; 17use nes_config::{NesAspectRatio, NesAudioResampler, NesPalette, Overscan, PaletteLoadError}; 18use rfd::FileDialog; 19use std::mem; 20use std::sync::Arc; 21 22pub struct OverscanState { 23 top_text: String, 24 top_invalid: bool, 25 bottom_text: String, 26 bottom_invalid: bool, 27 left_text: String, 28 left_invalid: bool, 29 right_text: String, 30 right_invalid: bool, 31} 32 33impl Default for OverscanState { 34 fn default() -> Self { 35 let zero_str = "0".to_string(); 36 37 Self { 38 top_text: zero_str.clone(), 39 top_invalid: false, 40 bottom_text: zero_str.clone(), 41 bottom_invalid: false, 42 left_text: zero_str.clone(), 43 left_invalid: false, 44 right_text: zero_str, 45 right_invalid: false, 46 } 47 } 48} 49 50impl From<Overscan> for OverscanState { 51 fn from(value: Overscan) -> Self { 52 Self { 53 top_text: value.top.to_string(), 54 top_invalid: false, 55 bottom_text: value.bottom.to_string(), 56 bottom_invalid: false, 57 left_text: value.left.to_string(), 58 left_invalid: false, 59 right_text: value.right.to_string(), 60 right_invalid: false, 61 } 62 } 63} 64 65pub struct NesPaletteState { 66 texture_64_color: TextureId, 67 texture_512_color: TextureId, 68 display_512_color_palette: bool, 69 load_error: Option<PaletteLoadError>, 70 generate_args: PaletteGenerationArgs, 71} 72 73impl NesPaletteState { 74 pub fn create(ctx: &Context, palette: &NesPalette) -> Self { 75 let NesPaletteTextures { texture_64_color, texture_512_color } = 76 create_palette_textures(ctx, palette); 77 78 Self { 79 texture_64_color, 80 texture_512_color, 81 display_512_color_palette: false, 82 load_error: None, 83 generate_args: PaletteGenerationArgs::default(), 84 } 85 } 86} 87 88impl App { 89 pub(super) fn render_nes_general_settings(&mut self, ctx: &Context) { 90 const WINDOW: OpenWindow = OpenWindow::NesGeneral; 91 92 let mut open = true; 93 Window::new(WINDOW.title()).open(&mut open).resizable(false).show(ctx, |ui| { 94 let rect = ui 95 .group(|ui| { 96 ui.add_enabled_ui( 97 self.emu_runner.status() != EmuRunnerStatus::RunningNes, 98 |ui| { 99 ui.label("Timing / display mode"); 100 101 ui.horizontal(|ui| { 102 ui.radio_value( 103 &mut self.config.nes.forced_timing_mode, 104 None, 105 "Auto", 106 ); 107 ui.radio_value( 108 &mut self.config.nes.forced_timing_mode, 109 Some(TimingMode::Ntsc), 110 "NTSC (60Hz)", 111 ); 112 ui.radio_value( 113 &mut self.config.nes.forced_timing_mode, 114 Some(TimingMode::Pal), 115 "PAL (50Hz)", 116 ); 117 }); 118 }, 119 ); 120 }) 121 .response 122 .interact_rect; 123 if ui.rect_contains_pointer(rect) { 124 self.state.help_text.insert(WINDOW, helptext::TIMING_MODE); 125 } 126 127 let rect = ui 128 .checkbox( 129 &mut self.config.nes.dma_dummy_joy_reads, 130 "Allow DMA to trigger dummy joypad reads", 131 ) 132 .interact_rect; 133 if ui.rect_contains_pointer(rect) { 134 self.state.help_text.insert(WINDOW, helptext::DMA_DUMMY_JOY_READ); 135 } 136 137 self.render_help_text(ui, WINDOW); 138 }); 139 if !open { 140 self.state.open_windows.remove(&WINDOW); 141 } 142 } 143 144 pub(super) fn render_nes_video_settings(&mut self, ctx: &Context) { 145 const WINDOW: OpenWindow = OpenWindow::NesVideo; 146 147 let mut open = true; 148 Window::new(WINDOW.title()).open(&mut open).min_width(600.0).show(ctx, |ui| { 149 widgets::render_vertical_scroll_area(ui, |ui| { 150 self.render_aspect_ratio_setting(ui, WINDOW); 151 152 let rect = ui 153 .checkbox( 154 &mut self.config.nes.ntsc_crop_vertical_overscan, 155 "(NTSC) Crop vertical overscan", 156 ) 157 .interact_rect; 158 if ui.rect_contains_pointer(rect) { 159 self.state.help_text.insert(WINDOW, helptext::NTSC_V_OVERSCAN); 160 } 161 162 let rect = ui 163 .checkbox( 164 &mut self.config.nes.remove_sprite_limit, 165 "Remove sprite-per-scanline limit", 166 ) 167 .on_hover_text( 168 "Eliminates most sprite flickering but can cause visual glitches", 169 ) 170 .interact_rect; 171 if ui.rect_contains_pointer(rect) { 172 self.state.help_text.insert(WINDOW, helptext::REMOVE_SPRITE_LIMIT); 173 } 174 175 let rect = ui 176 .checkbox(&mut self.config.nes.pal_black_border, "Render PAL black border") 177 .on_hover_text("Crops the image from 256x240 to 252x239") 178 .interact_rect; 179 if ui.rect_contains_pointer(rect) { 180 self.state.help_text.insert(WINDOW, helptext::PAL_BLACK_BORDER); 181 } 182 183 ui.separator(); 184 self.render_overscan_setting(ui, WINDOW); 185 186 ui.separator(); 187 self.render_palette_settings(ui, WINDOW); 188 }); 189 190 self.render_help_text(ui, WINDOW); 191 }); 192 if !open { 193 self.state.open_windows.remove(&WINDOW); 194 } 195 } 196 197 fn render_aspect_ratio_setting(&mut self, ui: &mut Ui, window: OpenWindow) { 198 let rect = ui 199 .group(|ui| { 200 ui.label("Aspect ratio"); 201 202 ui.horizontal(|ui| { 203 ui.radio_value(&mut self.config.nes.aspect_ratio, NesAspectRatio::Ntsc, "NTSC") 204 .on_hover_text("8:7 pixel aspect ratio"); 205 ui.radio_value(&mut self.config.nes.aspect_ratio, NesAspectRatio::Pal, "PAL") 206 .on_hover_text("11:8 pixel aspect ratio"); 207 ui.radio_value( 208 &mut self.config.nes.aspect_ratio, 209 NesAspectRatio::SquarePixels, 210 "Square pixels", 211 ) 212 .on_hover_text("1:1 pixel aspect ratio"); 213 ui.radio_value( 214 &mut self.config.nes.aspect_ratio, 215 NesAspectRatio::Stretched, 216 "Stretched", 217 ) 218 .on_hover_text("Stretched to fill the window"); 219 }); 220 }) 221 .response 222 .interact_rect; 223 if ui.rect_contains_pointer(rect) { 224 self.state.help_text.insert(window, helptext::ASPECT_RATIO); 225 } 226 } 227 228 fn render_overscan_setting(&mut self, ui: &mut Ui, window: OpenWindow) { 229 let rect = ui 230 .group(|ui| { 231 ui.label("Overscan in pixels"); 232 233 ui.vertical_centered(|ui| { 234 ui.label("Top"); 235 ui.add( 236 NumericTextEdit::new( 237 &mut self.state.overscan.top_text, 238 &mut self.config.nes.overscan.top, 239 &mut self.state.overscan.top_invalid, 240 ) 241 .desired_width(30.0), 242 ); 243 }); 244 245 ui.horizontal(|ui| { 246 ui.label("Left"); 247 ui.add( 248 NumericTextEdit::new( 249 &mut self.state.overscan.left_text, 250 &mut self.config.nes.overscan.left, 251 &mut self.state.overscan.left_invalid, 252 ) 253 .desired_width(30.0), 254 ); 255 256 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 257 ui.label("Right"); 258 ui.add( 259 NumericTextEdit::new( 260 &mut self.state.overscan.right_text, 261 &mut self.config.nes.overscan.right, 262 &mut self.state.overscan.right_invalid, 263 ) 264 .desired_width(30.0), 265 ); 266 }); 267 }); 268 269 ui.vertical_centered(|ui| { 270 ui.add( 271 NumericTextEdit::new( 272 &mut self.state.overscan.bottom_text, 273 &mut self.config.nes.overscan.bottom, 274 &mut self.state.overscan.bottom_invalid, 275 ) 276 .desired_width(30.0), 277 ); 278 ui.label("Bottom"); 279 }); 280 281 for (invalid, label) in [ 282 (self.state.overscan.top_invalid, "Top"), 283 (self.state.overscan.bottom_invalid, "Bottom"), 284 (self.state.overscan.left_invalid, "Left"), 285 (self.state.overscan.right_invalid, "Right"), 286 ] { 287 if invalid { 288 ui.colored_label( 289 Color32::RED, 290 format!("{label} value must be a non-negative integer"), 291 ); 292 } 293 } 294 }) 295 .response 296 .interact_rect; 297 if ui.rect_contains_pointer(rect) { 298 self.state.help_text.insert(window, helptext::OVERSCAN); 299 } 300 } 301 302 fn render_palette_settings(&mut self, ui: &mut Ui, window: OpenWindow) { 303 let rect = ui 304 .scope(|ui| { 305 ui.heading("Palette"); 306 307 ui.add_space(5.0); 308 309 ui.horizontal(|ui| { 310 if ui.button("Restore default").clicked() { 311 self.config.nes.palette = NesPalette::default(); 312 } 313 314 if ui.button("Load from file...").clicked() { 315 self.state.nes_palette.load_error = None; 316 self.load_palette_from_file(); 317 } 318 319 if ui.button("Export to file...").clicked() { 320 self.export_palette_to_file(); 321 } 322 }); 323 324 if let Some(err) = &self.state.nes_palette.load_error { 325 ui.colored_label(Color32::RED, format!("Error loading palette: {err}")); 326 } 327 328 ui.add_space(5.0); 329 330 self.render_palette_generator(ui); 331 332 ui.checkbox( 333 &mut self.state.nes_palette.display_512_color_palette, 334 "Display full 512-color palette", 335 ); 336 337 let image = if self.state.nes_palette.display_512_color_palette { 338 (self.state.nes_palette.texture_512_color, Vec2::new(600.0, 500.0)) 339 } else { 340 (self.state.nes_palette.texture_64_color, Vec2::new(600.0, 500.0 / 8.0)) 341 }; 342 ui.image(image); 343 }) 344 .response 345 .interact_rect; 346 if ui.rect_contains_pointer(rect) { 347 self.state.help_text.insert(window, helptext::PALETTE); 348 } 349 } 350 351 fn render_palette_generator(&mut self, ui: &mut Ui) { 352 let original_slider_width = mem::replace(&mut ui.style_mut().spacing.slider_width, 450.0); 353 354 let fmt_2f = |v: f64, _| format!("{v:.2}"); 355 356 ui.collapsing("Palette Generator", |ui| { 357 ui.add( 358 Slider::new(&mut self.state.nes_palette.generate_args.brightness, 0.0..=5.0) 359 .step_by(0.01) 360 .text("Brightness") 361 .custom_formatter(fmt_2f), 362 ); 363 364 ui.add( 365 Slider::new(&mut self.state.nes_palette.generate_args.saturation, 0.0..=5.0) 366 .step_by(0.01) 367 .text("Saturation") 368 .custom_formatter(fmt_2f), 369 ); 370 371 ui.add( 372 Slider::new(&mut self.state.nes_palette.generate_args.contrast, 0.0..=5.0) 373 .step_by(0.01) 374 .text("Contrast") 375 .custom_formatter(fmt_2f), 376 ); 377 378 ui.add( 379 Slider::new(&mut self.state.nes_palette.generate_args.hue_offset, -6.0..=6.0) 380 .step_by(0.01) 381 .text("Hue offset") 382 .custom_formatter(fmt_2f), 383 ); 384 385 ui.add( 386 Slider::new(&mut self.state.nes_palette.generate_args.gamma, 0.1..=3.0) 387 .step_by(0.1) 388 .text("Gamma") 389 .custom_formatter(|v, _| format!("{v:.1}")), 390 ); 391 392 ui.horizontal(|ui| { 393 if ui.button("Generate").clicked() { 394 self.config.nes.palette = 395 nes_config::palettes::generate(self.state.nes_palette.generate_args); 396 } 397 398 if ui.button("Default settings").clicked() { 399 self.state.nes_palette.generate_args = PaletteGenerationArgs::default(); 400 } 401 }); 402 }); 403 404 ui.style_mut().spacing.slider_width = original_slider_width; 405 } 406 407 fn load_palette_from_file(&mut self) { 408 let Some(path) = FileDialog::new() 409 .add_filter("pal", &["pal"]) 410 .add_filter("All Files", &["*"]) 411 .pick_file() 412 else { 413 return; 414 }; 415 416 match NesPalette::read_from(&path) { 417 Ok(palette) => { 418 self.config.nes.palette = palette; 419 } 420 Err(err) => { 421 log::error!("Failed to load palette from path '{}': {err}", path.display()); 422 423 self.state.nes_palette.load_error = Some(err); 424 } 425 } 426 } 427 428 fn export_palette_to_file(&self) { 429 let Some(path) = FileDialog::new() 430 .set_file_name("nespalette.pal") 431 .add_filter("pal", &["pal"]) 432 .save_file() 433 else { 434 return; 435 }; 436 437 if let Err(err) = self.config.nes.palette.write_to(&path) { 438 log::error!("Error saving palette to path '{}': {err}", path.display()); 439 } 440 } 441 442 pub(super) fn render_nes_audio_settings(&mut self, ctx: &Context) { 443 const WINDOW: OpenWindow = OpenWindow::NesAudio; 444 445 let mut open = true; 446 Window::new(WINDOW.title()).open(&mut open).resizable(false).show(ctx, |ui| { 447 let rect = ui 448 .checkbox( 449 &mut self.config.nes.silence_ultrasonic_triangle_output, 450 "Silence ultrasonic triangle channel output", 451 ) 452 .interact_rect; 453 if ui.rect_contains_pointer(rect) { 454 self.state.help_text.insert(WINDOW, helptext::ULTRASONIC_TRIANGLE); 455 } 456 457 let rect = ui 458 .checkbox(&mut self.config.nes.audio_60hz_hack, "Enable audio sync timing hack") 459 .interact_rect; 460 if ui.rect_contains_pointer(rect) { 461 self.state.help_text.insert(WINDOW, helptext::AUDIO_TIMING_HACK); 462 } 463 464 ui.add_space(5.0); 465 let rect = ui 466 .group(|ui| { 467 ui.label("Audio resampling algorithm"); 468 469 ui.radio_value( 470 &mut self.config.nes.audio_resampler, 471 NesAudioResampler::LowPassNearestNeighbor, 472 "Low-pass filter + nearest neighbor (Faster)", 473 ); 474 ui.radio_value( 475 &mut self.config.nes.audio_resampler, 476 NesAudioResampler::WindowedSinc, 477 "Windowed sinc interpolation (Higher quality)", 478 ); 479 }) 480 .response 481 .interact_rect; 482 if ui.rect_contains_pointer(rect) { 483 self.state.help_text.insert(WINDOW, helptext::AUDIO_RESAMPLING); 484 } 485 486 self.render_help_text(ui, WINDOW); 487 }); 488 if !open { 489 self.state.open_windows.remove(&WINDOW); 490 } 491 } 492} 493 494fn nes_palette_to_pixels(palette: &NesPalette) -> Vec<Color32> { 495 let mut pixels = vec![Color32::BLACK; 512]; 496 for (i, pixel) in pixels.iter_mut().enumerate() { 497 let (r, g, b) = palette[i]; 498 *pixel = Color32::from_rgb(r, g, b); 499 } 500 501 pixels 502} 503 504const PALETTE_TEXTURE_SIZE_64: [usize; 2] = [16, 4]; 505const PALETTE_TEXTURE_SIZE_64_1D: usize = PALETTE_TEXTURE_SIZE_64[0] * PALETTE_TEXTURE_SIZE_64[1]; 506const PALETTE_TEXTURE_SIZE_512: [usize; 2] = [16, 32]; 507 508fn tex_size_to_vec2(tex_size: [usize; 2]) -> Vec2 { 509 Vec2::new(tex_size[0] as f32, tex_size[1] as f32) 510} 511 512const PALETTE_TEXTURE_OPTIONS: TextureOptions = TextureOptions { 513 magnification: TextureFilter::Nearest, 514 minification: TextureFilter::Nearest, 515 wrap_mode: TextureWrapMode::ClampToEdge, 516 mipmap_mode: None, 517}; 518 519pub struct NesPaletteTextures { 520 pub texture_64_color: TextureId, 521 pub texture_512_color: TextureId, 522} 523 524pub fn create_palette_textures(ctx: &Context, palette: &NesPalette) -> NesPaletteTextures { 525 let pixels = nes_palette_to_pixels(palette); 526 527 let pixels_64_color = pixels[..PALETTE_TEXTURE_SIZE_64_1D].to_vec(); 528 let texture_64_color = ctx.tex_manager().write().alloc( 529 "nes_palette_texture_64c".into(), 530 ImageData::Color(Arc::new(ColorImage { 531 size: PALETTE_TEXTURE_SIZE_64, 532 source_size: tex_size_to_vec2(PALETTE_TEXTURE_SIZE_64), 533 pixels: pixels_64_color, 534 })), 535 PALETTE_TEXTURE_OPTIONS, 536 ); 537 538 let texture_512_color = ctx.tex_manager().write().alloc( 539 "nes_palette_texture_512c".into(), 540 ImageData::Color(Arc::new(ColorImage { 541 size: PALETTE_TEXTURE_SIZE_512, 542 source_size: tex_size_to_vec2(PALETTE_TEXTURE_SIZE_512), 543 pixels, 544 })), 545 PALETTE_TEXTURE_OPTIONS, 546 ); 547 548 NesPaletteTextures { texture_64_color, texture_512_color } 549} 550 551pub fn update_palette_textures(ctx: &Context, state: &NesPaletteState, palette: &NesPalette) { 552 let pixels = nes_palette_to_pixels(palette); 553 554 let pixels_64_color = pixels[..PALETTE_TEXTURE_SIZE_64_1D].to_vec(); 555 ctx.tex_manager().write().set( 556 state.texture_64_color, 557 ImageDelta { 558 image: ImageData::Color(Arc::new(ColorImage { 559 size: PALETTE_TEXTURE_SIZE_64, 560 source_size: tex_size_to_vec2(PALETTE_TEXTURE_SIZE_64), 561 pixels: pixels_64_color, 562 })), 563 options: PALETTE_TEXTURE_OPTIONS, 564 pos: None, 565 }, 566 ); 567 568 ctx.tex_manager().write().set( 569 state.texture_512_color, 570 ImageDelta { 571 image: ImageData::Color(Arc::new(ColorImage { 572 size: PALETTE_TEXTURE_SIZE_512, 573 source_size: tex_size_to_vec2(PALETTE_TEXTURE_SIZE_512), 574 pixels, 575 })), 576 options: PALETTE_TEXTURE_OPTIONS, 577 pos: None, 578 }, 579 ); 580}