1use crate::app::{App, OpenWindow}; 2use egui::text::CCursorRange; 3use egui::{ 4 Button, CentralPanel, Context, FontFamily, Key, KeyboardShortcut, Label, Modifiers, Panel, 5 Response, RichText, ScrollArea, Sense, TextEdit, TextStyle, Ui, ViewportCommand, Window, 6}; 7use egui_extras::{Column, TableBuilder}; 8use genesis_config::cheats::{GenesisCheat, GenesisCheatCodeType, GenesisCheats}; 9use jgenesis_common::cheats::ByteCheatCodeU16Address; 10use jgenesis_native_driver::extensions::Console; 11use serde::Serialize; 12use smsgg_config::cheats::{SmsGgCheat, SmsGgCheatCodeType, SmsGgCheats}; 13use std::borrow::Cow; 14use std::path::Path; 15use std::sync::{Arc, LazyLock}; 16 17#[derive(Debug, Clone, PartialEq, Eq)] 18pub enum ActiveCheats { 19 None, 20 SmsGg(SmsGgCheats), 21 Genesis(GenesisCheats), 22} 23 24impl ActiveCheats { 25 pub fn genesis_or_default(&self) -> &GenesisCheats { 26 static DEFAULT: LazyLock<GenesisCheats> = LazyLock::new(GenesisCheats::default); 27 28 match self { 29 Self::Genesis(cheats) => cheats, 30 _ => &DEFAULT, 31 } 32 } 33 34 pub fn smsgg_or_default(&self) -> &SmsGgCheats { 35 static DEFAULT: LazyLock<SmsGgCheats> = LazyLock::new(SmsGgCheats::default); 36 37 match self { 38 Self::SmsGg(cheats) => cheats, 39 _ => &DEFAULT, 40 } 41 } 42} 43 44#[derive(Debug, Clone)] 45pub struct CheatState { 46 name: String, 47 enabled: bool, 48 codes: Vec<String>, 49 codes_buffer: String, 50 code_messages: Vec<Cow<'static, str>>, 51 cursor_range: Option<CCursorRange>, 52} 53 54impl CheatState { 55 fn new() -> Self { 56 Self { 57 name: String::new(), 58 enabled: true, 59 codes: vec![], 60 codes_buffer: String::new(), 61 code_messages: vec![], 62 cursor_range: None, 63 } 64 } 65} 66 67#[derive(Debug, Clone, Copy, PartialEq, Eq)] 68pub enum CheatConsole { 69 Genesis, 70 SmsGg, 71} 72 73impl CheatConsole { 74 pub fn from_console(console: Console) -> Option<Self> { 75 match console { 76 Console::Genesis | Console::SegaCd | Console::Sega32X | Console::SegaCd32X => { 77 Some(Self::Genesis) 78 } 79 Console::MasterSystem | Console::GameGear | Console::Sg1000 => Some(Self::SmsGg), 80 _ => None, 81 } 82 } 83} 84 85trait SystemCheats: PartialEq + Eq + Serialize { 86 type MemoryOverrides: PartialEq + Eq; 87 88 fn from_active_or_default(active: &ActiveCheats) -> &Self; 89 90 fn from_state(state: &CheatWindowState) -> Self; 91 92 fn into_active(self) -> ActiveCheats; 93 94 fn to_memory_overrides(&self) -> Self::MemoryOverrides; 95} 96 97impl SystemCheats for GenesisCheats { 98 type MemoryOverrides = Vec<(u32, u16)>; 99 100 fn from_active_or_default(active: &ActiveCheats) -> &Self { 101 active.genesis_or_default() 102 } 103 104 fn from_state(state: &CheatWindowState) -> Self { 105 Self { 106 cheats: state 107 .cheats 108 .iter() 109 .map(|cheat| GenesisCheat { 110 name: cheat.name.clone(), 111 enabled: cheat.enabled, 112 codes: cheat.codes.clone(), 113 }) 114 .collect(), 115 } 116 } 117 118 fn into_active(self) -> ActiveCheats { 119 ActiveCheats::Genesis(self) 120 } 121 122 fn to_memory_overrides(&self) -> Self::MemoryOverrides { 123 self.to_memory_override_vec() 124 } 125} 126 127impl SystemCheats for SmsGgCheats { 128 type MemoryOverrides = Vec<ByteCheatCodeU16Address>; 129 130 fn from_active_or_default(active: &ActiveCheats) -> &Self { 131 active.smsgg_or_default() 132 } 133 134 fn from_state(state: &CheatWindowState) -> Self { 135 Self { 136 cheats: state 137 .cheats 138 .iter() 139 .map(|cheat| SmsGgCheat { 140 name: cheat.name.clone(), 141 enabled: cheat.enabled, 142 codes: cheat.codes.clone(), 143 }) 144 .collect(), 145 } 146 } 147 148 fn into_active(self) -> ActiveCheats { 149 ActiveCheats::SmsGg(self) 150 } 151 152 fn to_memory_overrides(&self) -> Self::MemoryOverrides { 153 self.to_memory_override_vec() 154 } 155} 156 157#[derive(Debug, Clone)] 158pub struct CheatWindowState { 159 cheats: Vec<CheatState>, 160 selected_cheat: usize, 161 active: Arc<ActiveCheats>, 162 active_console: Option<(Console, CheatConsole)>, 163} 164 165impl CheatWindowState { 166 pub fn new() -> Self { 167 Self { 168 cheats: vec![], 169 selected_cheat: 0, 170 active: Arc::new(ActiveCheats::None), 171 active_console: None, 172 } 173 } 174} 175 176impl App { 177 pub(super) fn render_cheats_window(&mut self, ctx: &Context) { 178 const WINDOW: OpenWindow = OpenWindow::Cheats; 179 180 let mut open = true; 181 Window::new(WINDOW.title()).open(&mut open).default_width(800.0).show(ctx, |ui| { 182 let cheats_state = &mut self.state.cheats; 183 184 Panel::top("cheats_top_panel").show_inside(ui, |ui| { 185 ui.checkbox(&mut self.config.common.cheats_enabled, "Cheats enabled"); 186 }); 187 188 let emu_runner_status = self.emu_runner.status(); 189 if !emu_runner_status.is_running() { 190 render_centered_message(ui, "Load a game to edit cheats"); 191 return; 192 } 193 194 let Some((_, console)) = cheats_state.active_console else { 195 render_centered_message(ui, "Cheats are not currently supported for this console"); 196 return; 197 }; 198 199 ui.add_enabled_ui(self.config.common.cheats_enabled, |ui| { 200 let mut cheat_just_created = false; 201 202 Panel::left("cheats_left_panel").show_inside(ui, |ui| { 203 render_left_panel(ui, cheats_state, &mut cheat_just_created); 204 }); 205 206 CentralPanel::default().show_inside(ui, |ui| { 207 let Some(cheat_state) = 208 cheats_state.cheats.get_mut(cheats_state.selected_cheat) 209 else { 210 return; 211 }; 212 213 render_central_panel(ui, console, cheat_state, cheat_just_created); 214 }); 215 }); 216 217 self.update_active_cheats(); 218 }); 219 if !open { 220 self.state.open_windows.remove(&WINDOW); 221 } 222 } 223 224 fn update_active_cheats(&mut self) { 225 match self.state.cheats.active_console { 226 Some((console, CheatConsole::Genesis)) => { 227 self.update_system_active_cheats::<GenesisCheats>(console); 228 } 229 Some((console, CheatConsole::SmsGg)) => { 230 self.update_system_active_cheats::<SmsGgCheats>(console); 231 } 232 None => {} 233 } 234 } 235 236 fn update_system_active_cheats<Cheats: SystemCheats>(&mut self, console: Console) { 237 let prev_active_cheats = Arc::clone(&self.state.cheats.active); 238 let prev_active_cheats = Cheats::from_active_or_default(&prev_active_cheats); 239 240 let system_cheats = Cheats::from_state(&self.state.cheats); 241 242 let changed = *prev_active_cheats != system_cheats; 243 if changed { 244 if let Err(err) = self.config.save_cheats( 245 &self.config_path, 246 &self.state.current_file_path, 247 console.standard_extension(), 248 &system_cheats, 249 ) { 250 log::error!("Error saving cheats file: {err}"); 251 } 252 253 let new_memory_overrides = system_cheats.to_memory_overrides(); 254 self.state.cheats.active = Arc::new(system_cheats.into_active()); 255 256 let prev_memory_overrides = prev_active_cheats.to_memory_overrides(); 257 if prev_memory_overrides != new_memory_overrides { 258 self.reload_config(); 259 } 260 } 261 } 262 263 pub(super) fn check_cheat_names_for_cjk(&mut self, ctx: &Context) { 264 self.load_cjk_fonts_if_needed(ctx, |app, predicate| match app.active_cheats().as_ref() { 265 ActiveCheats::None => None, 266 ActiveCheats::SmsGg(cheats) => { 267 cheats.cheats.iter().find_map(|cheat| predicate(&cheat.name)) 268 } 269 ActiveCheats::Genesis(cheats) => { 270 cheats.cheats.iter().find_map(|cheat| predicate(&cheat.name)) 271 } 272 }); 273 } 274 275 pub(super) fn load_cheats_for_game(&mut self, console: Console, rom_file_path: &Path) { 276 match CheatConsole::from_console(console) { 277 Some(CheatConsole::Genesis) => { 278 let cheats = self 279 .config 280 .try_load_cheats::<GenesisCheats>( 281 &self.config_path, 282 rom_file_path, 283 console.standard_extension(), 284 ) 285 .unwrap_or_default(); 286 287 self.state.cheats = CheatWindowState { 288 cheats: cheats 289 .cheats 290 .iter() 291 .map(|cheat| { 292 let codes_buffer = cheat.codes.join("\n"); 293 let code_messages = 294 cheat.codes.iter().map(|code| genesis_code_message(code)).collect(); 295 296 CheatState { 297 name: cheat.name.clone(), 298 enabled: cheat.enabled, 299 codes: cheat.codes.clone(), 300 codes_buffer, 301 code_messages, 302 cursor_range: None, 303 } 304 }) 305 .collect(), 306 selected_cheat: 0, 307 active: Arc::new(ActiveCheats::Genesis(cheats)), 308 active_console: Some((console, CheatConsole::Genesis)), 309 }; 310 } 311 Some(CheatConsole::SmsGg) => { 312 let cheats = self 313 .config 314 .try_load_cheats::<SmsGgCheats>( 315 &self.config_path, 316 rom_file_path, 317 console.standard_extension(), 318 ) 319 .unwrap_or_default(); 320 321 self.state.cheats = CheatWindowState { 322 cheats: cheats 323 .cheats 324 .iter() 325 .map(|cheat| { 326 let codes_buffer = cheat.codes.join("\n"); 327 let code_messages = 328 cheat.codes.iter().map(|code| smsgg_code_message(code)).collect(); 329 330 CheatState { 331 name: cheat.name.clone(), 332 enabled: cheat.enabled, 333 codes: cheat.codes.clone(), 334 codes_buffer, 335 code_messages, 336 cursor_range: None, 337 } 338 }) 339 .collect(), 340 selected_cheat: 0, 341 active: Arc::new(ActiveCheats::SmsGg(cheats)), 342 active_console: Some((console, CheatConsole::SmsGg)), 343 }; 344 } 345 None => { 346 self.state.cheats.active = Arc::new(ActiveCheats::None); 347 self.state.cheats.active_console = None; 348 } 349 } 350 } 351 352 pub(super) fn active_cheats(&self) -> &Arc<ActiveCheats> { 353 static NONE: LazyLock<Arc<ActiveCheats>> = LazyLock::new(|| Arc::new(ActiveCheats::None)); 354 355 if !self.config.common.cheats_enabled { 356 return &NONE; 357 } 358 359 &self.state.cheats.active 360 } 361} 362 363fn render_left_panel( 364 ui: &mut Ui, 365 cheats_state: &mut CheatWindowState, 366 cheat_just_created: &mut bool, 367) { 368 ui.add_space(5.0); 369 370 if ui.button("Add New").clicked() { 371 cheats_state.cheats.push(CheatState::new()); 372 373 cheats_state.selected_cheat = cheats_state.cheats.len() - 1; 374 *cheat_just_created = true; 375 } 376 377 ui.add_space(5.0); 378 379 let mut rows_to_remove = Vec::<usize>::new(); 380 381 TableBuilder::new(ui) 382 .column(Column::auto()) 383 .column(Column::auto().at_least(200.0)) 384 .column(Column::remainder().at_least(60.0)) 385 .sense(Sense::click()) 386 .body(|body| { 387 body.rows(15.0, cheats_state.cheats.len(), |mut row| { 388 let row_index = row.index(); 389 row.set_selected(row_index == cheats_state.selected_cheat); 390 391 let cheat = &mut cheats_state.cheats[row_index]; 392 393 row.col(|ui| { 394 ui.checkbox(&mut cheat.enabled, ""); 395 }); 396 397 row.col(|ui| { 398 let name = if !cheat.name.is_empty() { &cheat.name } else { "(No name)" }; 399 ui.add(Label::new(name).selectable(false)); 400 }); 401 402 if row.response().clicked() { 403 cheats_state.selected_cheat = row_index; 404 } 405 406 row.col(|ui| { 407 if ui.button("Delete").clicked() { 408 rows_to_remove.push(row_index); 409 } 410 }); 411 }); 412 }); 413 414 for i in rows_to_remove { 415 if i < cheats_state.cheats.len() { 416 cheats_state.cheats.remove(i); 417 418 if cheats_state.selected_cheat > i { 419 cheats_state.selected_cheat -= 1; 420 } 421 } 422 } 423} 424 425fn render_central_panel( 426 ui: &mut Ui, 427 console: CheatConsole, 428 cheat_state: &mut CheatState, 429 cheat_just_created: bool, 430) { 431 const TEXTEDIT_WIDTH: f32 = 150.0; 432 const TEXTEDIT_ROWS: usize = 10; 433 434 ui.horizontal(|ui| { 435 ui.label("Name"); 436 437 let resp = ui.add(TextEdit::singleline(&mut cheat_state.name).hint_text("New Cheat")); 438 if cheat_just_created { 439 resp.request_focus(); 440 } 441 }); 442 443 ui.add_space(10.0); 444 445 ui.label("Codes"); 446 447 ScrollArea::vertical().show(ui, |ui| { 448 ui.horizontal(|ui| { 449 let cheats_resp = text_edit_with_copy_paste_menu( 450 TextEdit::multiline(&mut cheat_state.codes_buffer) 451 .font(TextStyle::Monospace) 452 .desired_width(TEXTEDIT_WIDTH) 453 .desired_rows(TEXTEDIT_ROWS), 454 &mut cheat_state.cursor_range, 455 ui, 456 ); 457 458 if cheats_resp.changed() { 459 cheat_state.codes.clear(); 460 cheat_state.code_messages.clear(); 461 462 for line in cheat_state.codes_buffer.split('\n') { 463 let line = line.trim(); 464 if line.is_empty() { 465 cheat_state.code_messages.push("".into()); 466 continue; 467 } 468 469 cheat_state.codes.push(line.into()); 470 471 let message = match console { 472 CheatConsole::Genesis => genesis_code_message(line), 473 CheatConsole::SmsGg => smsgg_code_message(line), 474 }; 475 cheat_state.code_messages.push(message); 476 } 477 } 478 479 ui.vertical(|ui| { 480 ui.add_space(3.0); 481 482 ui.spacing_mut().item_spacing.y = 0.0; 483 484 for message in &cheat_state.code_messages { 485 ui.label(RichText::new(message.as_ref()).family(FontFamily::Monospace)); 486 } 487 }); 488 }); 489 490 ui.add_space(5.0); 491 492 ui.label("Enter one code per line."); 493 ui.add_space(5.0); 494 ui.label("Supported code formats:"); 495 496 match console { 497 CheatConsole::Genesis => { 498 ui.label(RichText::new(" • Game Genie (ABCD-EGHJ)").monospace()); 499 ui.label( 500 RichText::new(" • Pro Action Replay (AAAAA AVVVV) (Little-endian value)") 501 .monospace(), 502 ); 503 ui.label( 504 RichText::new(" • Memory address/value (AAAAAA:VVVV) (Big-endian value)") 505 .monospace(), 506 ); 507 } 508 CheatConsole::SmsGg => { 509 ui.label(RichText::new(" • Game Genie (ABC-DEF or ABC-DEF-012)").monospace()); 510 ui.label(RichText::new(" • Pro Action Replay (00AA AAVV)").monospace()); 511 } 512 } 513 }); 514} 515 516fn text_edit_with_copy_paste_menu( 517 text_edit: TextEdit<'_>, 518 cursor_range: &mut Option<CCursorRange>, 519 ui: &mut Ui, 520) -> Response { 521 let mut resp = text_edit.show(ui); 522 523 // Workaround for: https://github.com/emilk/egui/issues/4393 524 // 525 // egui's builtin TextEdit widget does not distinguish between left and right clicks when updating 526 // the cursor position, which is obviously problematic for a right click copy/paste menu 527 let right_mouse_down = resp.response.secondary_clicked() 528 || ui.input(|i| i.pointer.secondary_down() && !i.pointer.is_decidedly_dragging()); 529 let cursor_range_selected = cursor_range.is_some_and(|range| range.primary != range.secondary); 530 if right_mouse_down && cursor_range_selected { 531 resp.state.cursor.set_char_range(*cursor_range); 532 resp.state.store(ui, resp.response.id); 533 } else { 534 *cursor_range = resp.cursor_range; 535 } 536 537 resp.response.context_menu(|ui| { 538 for (label, key, command) in [ 539 ("Cut", Key::X, ViewportCommand::RequestCut), 540 ("Copy", Key::C, ViewportCommand::RequestCopy), 541 ("Paste", Key::V, ViewportCommand::RequestPaste), 542 ] { 543 let button = Button::new(label) 544 .shortcut_text(ui.format_shortcut(&KeyboardShortcut::new(Modifiers::CTRL, key))); 545 if ui.add(button).clicked() { 546 resp.response.request_focus(); 547 ui.send_viewport_cmd(command); 548 } 549 } 550 }); 551 552 resp.response.response 553} 554 555const INVALID_CODE: &str = "Invalid code"; 556 557fn genesis_code_message(line: &str) -> Cow<'static, str> { 558 let Some(code_type) = GenesisCheatCodeType::guess_from(line) else { 559 return INVALID_CODE.into(); 560 }; 561 562 let Some((address, value)) = code_type.decode(line) else { 563 return INVALID_CODE.into(); 564 }; 565 566 format!("${address:06X} = 0x{value:04X}").into() 567} 568 569fn smsgg_code_message(line: &str) -> Cow<'static, str> { 570 let Some(code_type) = SmsGgCheatCodeType::guess_from(line) else { 571 return INVALID_CODE.into(); 572 }; 573 574 let Some(code) = code_type.decode(line) else { 575 return INVALID_CODE.into(); 576 }; 577 578 match code.reference { 579 Some(reference) => { 580 format!("${:04X} = 0x{:02X} (if = 0x{reference:02X})", code.address, code.value).into() 581 } 582 None => format!("${:04X} = 0x{:02X}", code.address, code.value).into(), 583 } 584} 585 586fn render_centered_message(ui: &mut Ui, message: &str) { 587 CentralPanel::default().show_inside(ui, |ui| { 588 ui.centered_and_justified(|ui| { 589 ui.label(message); 590 }); 591 }); 592}