diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index abff568..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "i686-pc-windows-gnu" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4fffb2f..1d4616a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /Cargo.lock +.cargo/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ac2720d..81d3dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "re0box" -version = "0.3.1" +version = "0.4.0" authors = ["descawed "] edition = "2021" description = "An item box mod for Resident Evil 0" @@ -20,9 +20,11 @@ panic = "abort" [dependencies] anyhow = "1.0" -binrw = "0.11" +binrw = "0.12" configparser = "3.0" -windows = { version = "0.51", features = [ "Win32_Foundation", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading" ] } +log = "0.4" +simplelog = "0.12" +windows = { version = "0.51", features = [ "Win32_Foundation", "Win32_System_Diagnostics_Debug", "Win32_System_Memory", "Win32_System_ProcessStatus", "Win32_System_Kernel", "Win32_System_SystemServices", "Win32_System_Threading" ] } [build-dependencies] winresource = "0.1" \ No newline at end of file diff --git a/README.md b/README.md index 75c227d..032deb2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ file (see the Configuration section below). The mod comes with a configuration file called re0box.ini with a couple options. You'll find it in your Resident Evil 0 folder and you can open it with any text editor. Note that changes to this file won't take effect while the game is running; you'll need to restart the game for it to pick up any changes. + +**Enable** + +This section controls whether mod features are enabled. + - Mod: this controls whether the mod is enabled. When Mod=1 (which is the default), the mod is enabled. If you change it to Mod=0, the mod is disabled. - Leave: this controls whether you're allowed to drop items (the "Leave" option in the inventory). The default is @@ -40,12 +45,23 @@ running; you'll need to restart the game for it to pick up any changes. drop items is OP. But if you want both, you can change it to Leave=1, and then you'll be able to drop items and still access the item box. +**Log** + +This section controls logging behavior. + +- Level: this controls how much information is logged. The options are off, error, warn, info, debug, and trace, where + each option logs progressively more information. High log levels (such as trace) may impact performance but are + useful for troubleshooting issues like crashes. The default is info. +- Path: path to the log file, relative to the game folder. The default is re0box.log. If you don't want it to go in the + game folder, you can also use an absolute path for a different location, such as C:\Users\Bob\Documents\re0box.log. + ## Uninstall Delete scripts\re0box.asi from the Resident Evil 0 folder. None of the other mod files will have any effect once that's gone, but if you want to purge everything, this is the full list of files added by the mod: - dinput8.dll (note that other mods may need this file. if you have anything in your scripts folder besides re0box.asi, you should leave this one alone.) - re0box.ini +- re0box.log - re0box_readme.txt - nativePC\arc\message\msg_chS_box.arc - nativePC\arc\message\msg_chT_box.arc @@ -58,12 +74,11 @@ gone, but if you want to purge everything, this is the full list of files added - scripts\re0box.asi ## Build -This mod is written in Rust. The default target is i686-windows-pc-gnu because RE0 is a 32-bit game and I'm -cross-compiling from Linux. I imagine the MSVC toolchain would also work but I haven't tested it. As long as Rust and -the appropriate toolchain are installed, you should just be able to do a `cargo build`. The mod is currently distributed -as an ASI plugin using [Ultimate-ASI-Loader](https://github.com/ThirteenAG/Ultimate-ASI-Loader) as the loader. This -helps ensure compatibility with other DLL-based mods. Just rename re0box.dll to re0box.asi and put it in the scripts -folder. +This mod is written in Rust. RE0 is a 32-bit game, so you'll need a 32-bit target installed. Either i686-pc-windows-gnu +or i686-pc-windows-msvc will work. As long as Rust and an appropriate target are installed, you should just be able to +do `cargo build --target=`. The mod is currently distributed as an ASI plugin using +[Ultimate-ASI-Loader](https://github.com/ThirteenAG/Ultimate-ASI-Loader) as the loader. This helps ensure compatibility +with other DLL-based mods. Just rename re0box.dll to re0box.asi and put it in the scripts folder. Aside from the DLL itself, we also have to edit the game's message files so typewriters prompt to use the box. These are found in nativePC\arc\message. There's one file for each language the game supports, named in the format @@ -85,4 +100,4 @@ This mod was made by descawed. I used a number of existing tools in the making o - ThirteenAG for [Ultimate ASI Loader](https://github.com/ThirteenAG/Ultimate-ASI-Loader) - FluffyQuack for [ARCtool](https://residentevilmodding.boards.net/thread/481/) - onepiecefreak3 for [GMDConverter](https://github.com/onepiecefreak3/GMDConverter) -- ernestin1245 for the Spanish translation \ No newline at end of file +- ErnestJugend for the Spanish translation diff --git a/re0box.ini b/re0box.ini index e95ea5d..da6dc7a 100644 --- a/re0box.ini +++ b/re0box.ini @@ -3,3 +3,9 @@ Mod=1 ; whether you're allowed to use the "Leave" option to drop items. ignored if the mod is disabled. Leave=0 + +[Log] +; level of information to log. default is info. options are off, error, warn, info, debug, trace. +Level=info +; path to log file. if the path is not absolute, it will be relative to the game directory. +Path=re0box.log \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b4f2934 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,221 @@ +use std::ffi::c_void; +use std::fs::File; +use std::ops::BitAnd; +use std::panic; +use std::path::PathBuf; +use std::{cmp, mem}; + +use anyhow::Result; +use simplelog::{Config, LevelFilter, WriteLogger}; +use windows::core::PWSTR; +use windows::Win32::Foundation::{HMODULE, MAX_PATH}; +use windows::Win32::System::Diagnostics::Debug::{ + AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, CONTEXT_CONTROL_X86, + CONTEXT_DEBUG_REGISTERS_X86, CONTEXT_FLOATING_POINT_X86, CONTEXT_INTEGER_X86, + CONTEXT_SEGMENTS_X86, EXCEPTION_POINTERS, +}; +use windows::Win32::System::Kernel::ExceptionContinueSearch; +use windows::Win32::System::Memory::{ + VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, + PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, +}; +use windows::Win32::System::ProcessStatus::{ + EnumProcessModules, GetModuleBaseNameW, GetModuleInformation, MODULEINFO, +}; +use windows::Win32::System::Threading::GetCurrentProcess; + +const STACK_DUMP_WORDS_PER_LINE: usize = 4; +const STACK_DUMP_LINES: usize = 6; +const READABLE_PROTECT: [PAGE_PROTECTION_FLAGS; 4] = [ + PAGE_EXECUTE_READ, + PAGE_EXECUTE_READWRITE, + PAGE_READWRITE, + PAGE_READONLY, +]; +const MAX_MODULES: usize = 1000; + +unsafe extern "system" fn exception_handler(exc_info: *mut EXCEPTION_POINTERS) -> i32 { + if let Some(exc_info) = exc_info.as_ref() { + // exception details + let mut record_ptr = exc_info.ExceptionRecord; + while let Some(record) = record_ptr.as_ref() { + log::error!( + "Unhandled exception {:08X} at {:08X}. Parameters: {:?}", + record.ExceptionCode.0, + record.ExceptionAddress as usize, + &record.ExceptionInformation[..record.NumberParameters as usize] + ); + record_ptr = record.ExceptionRecord; + } + + // registers + let mut sp = None; + if let Some(context) = exc_info.ContextRecord.as_ref() { + if context.ContextFlags.bitand(CONTEXT_INTEGER_X86) == CONTEXT_INTEGER_X86 { + log::error!("\tedi = {:08X}\tesi = {:08X}", context.Edi, context.Esi); + log::error!("\tebx = {:08X}\tedx = {:08X}", context.Ebx, context.Edx); + log::error!("\tecx = {:08X}\teax = {:08X}", context.Ecx, context.Eax); + } + + if context.ContextFlags.bitand(CONTEXT_CONTROL_X86) == CONTEXT_CONTROL_X86 { + log::error!("\tebp = {:08X}\teip = {:08X}", context.Ebp, context.Eip); + log::error!( + "\tesp = {:08X}\teflags = {:08X}", + context.Esp, + context.EFlags + ); + log::error!("\tcs = {:04X}\tss = {:04X}", context.SegCs, context.SegSs); + sp = Some(context.Esp as usize); + } + + if context.ContextFlags.bitand(CONTEXT_SEGMENTS_X86) == CONTEXT_SEGMENTS_X86 { + log::error!("\tgs = {:04X}\tfs = {:04X}", context.SegGs, context.SegFs); + log::error!("\tes = {:04X}\tds = {:04X}", context.SegEs, context.SegDs); + } + + if context.ContextFlags.bitand(CONTEXT_FLOATING_POINT_X86) == CONTEXT_FLOATING_POINT_X86 + { + log::error!("\tfloat: {:?}", context.FloatSave); + } + + if context.ContextFlags.bitand(CONTEXT_DEBUG_REGISTERS_X86) + == CONTEXT_DEBUG_REGISTERS_X86 + { + log::error!("\tdr0 = {:08X}\tdr1 = {:08X}", context.Dr0, context.Dr1); + log::error!("\tdr2 = {:08X}\tdr3 = {:08X}", context.Dr2, context.Dr3); + log::error!("\tdr6 = {:08X}\tdr7 = {:08X}", context.Dr6, context.Dr7); + } + } + + // stack dump if it's valid + if let Some(mut ptr) = sp { + let mut info = MEMORY_BASIC_INFORMATION::default(); + let info_size = mem::size_of::(); + let mut region_end = ptr; + log::error!("Stack dump:"); + for _ in 0..STACK_DUMP_LINES { + let mut words = [0usize; STACK_DUMP_WORDS_PER_LINE]; + let mut exit = false; + let line_addr = ptr; + for word in &mut words { + let mut word_buf = [0u8; mem::size_of::()]; + let bytes_to_copy = cmp::min(region_end - ptr, word_buf.len()); + if bytes_to_copy > 0 { + (ptr as *const u8) + .copy_to_nonoverlapping(word_buf.as_mut_ptr(), bytes_to_copy); + } + ptr += bytes_to_copy; + if bytes_to_copy < word_buf.len() { + // we reached the end of the region; need to query the next region + let bytes_written = + VirtualQuery(Some(ptr as *const c_void), &mut info, info_size); + if bytes_written < info_size { + log::error!("{:08X}: VirtualQuery for stack info failed", ptr as usize); + exit = true; + break; + } else if info.State != MEM_COMMIT + || !READABLE_PROTECT + .iter() + .any(|p| info.Protect.bitand(*p) == *p) + { + log::error!("{:08X}: memory is not readable", ptr as usize); + exit = true; + break; + } + + region_end = info.AllocationBase as usize + info.RegionSize; + let remaining_bytes = word_buf.len() - bytes_to_copy; + (ptr as *const u8).copy_to_nonoverlapping( + word_buf[bytes_to_copy..].as_mut_ptr(), + remaining_bytes, + ); + ptr += remaining_bytes; + } + + *word = usize::from_le_bytes(word_buf); + } + + if exit { + break; + } + + let mut line = format!("\t{:08X}: ", line_addr); + for word in words { + line = format!("{} {:08X}", line, word); + } + log::error!("{}", line); + } + } else { + log::error!("Stack dump: stack pointer was not present"); + } + + // module list + let mut modules = [HMODULE::default(); MAX_MODULES]; + let mut size_needed = 0; + if !EnumProcessModules( + GetCurrentProcess(), + modules.as_mut_ptr(), + mem::size_of::<[HMODULE; MAX_MODULES]>() as u32, + &mut size_needed, + ) + .is_ok() + { + log::error!("Modules: could not enumerate modules"); + } else { + log::error!("Modules:"); + let num_modules = size_needed as usize / mem::size_of::(); + for module in modules.into_iter().take(num_modules) { + let mut name_buf = [0u16; MAX_PATH as usize]; + let chars_copied = GetModuleBaseNameW(GetCurrentProcess(), module, &mut name_buf); + let module_name = if chars_copied == 0 || chars_copied >= name_buf.len() as u32 { + String::from("") + } else { + PWSTR::from_raw(name_buf.as_mut_ptr()) + .to_string() + .unwrap_or_else(|_| String::from("")) + }; + + let mut mod_info = MODULEINFO::default(); + let address_range = match GetModuleInformation( + GetCurrentProcess(), + module, + &mut mod_info, + mem::size_of::() as u32, + ) { + Ok(_) => format!( + "{:08X}-{:08X}", + mod_info.lpBaseOfDll as usize, + mod_info.lpBaseOfDll as usize + mod_info.SizeOfImage as usize + ), + Err(e) => format!("error: {:?}", e), + }; + + log::error!("\t{}\t{}", module_name, address_range); + } + } + } + + ExceptionContinueSearch.0 +} + +pub fn open_log(log_level: LevelFilter, log_path: PathBuf) -> Result<()> { + let log_file = File::create(log_path)?; + WriteLogger::init(log_level, Config::default(), log_file)?; + panic::set_hook(Box::new(|info| { + let msg = info.payload().downcast_ref::<&str>().unwrap_or(&"unknown"); + let (file, line) = info + .location() + .map_or(("unknown", 0), |l| (l.file(), l.line())); + log::error!("Panic in {} on line {}: {}", file, line, msg); + })); + unsafe { + AddVectoredExceptionHandler(0, Some(exception_handler)); + } + Ok(()) +} + +pub fn close_log() { + unsafe { + RemoveVectoredExceptionHandler(exception_handler as *const c_void); + } +} diff --git a/src/game.rs b/src/game.rs index 45185d0..3d3f9b3 100644 --- a/src/game.rs +++ b/src/game.rs @@ -24,6 +24,7 @@ pub const SCROLL_UP_CHECK: usize = 0x005E386A; pub const SCROLL_DOWN_CHECK: usize = 0x005E3935; pub const SCROLL_LEFT_CHECK: usize = 0x005E39F1; pub const SCROLL_RIGHT_CHECK: usize = 0x005E3AFD; +pub const SCROLL_RIGHT_TWO_CHECK: usize = 0x005E3B5A; pub const GET_PARTNER_CHARACTER: usize = 0x0066DEC0; pub const SUB_522A20: usize = 0x00522A20; pub const PTR_DCDF3C: usize = 0x00DCDF3C; diff --git a/src/inventory.rs b/src/inventory.rs index 40f8546..a54764f 100644 --- a/src/inventory.rs +++ b/src/inventory.rs @@ -1,10 +1,21 @@ use binrw::binrw; -const BAG_SIZE: usize = 6; +pub const BAG_SIZE: usize = 6; const SLOT_TWO: i32 = 180; +const TWO_SLOT_ITEMS: [i32; 9] = [ + 5, // hunting gun + 6, // shotgun + 7, // grenade launcher (grenade rounds) + 8, // grenade launcher (flame rounds) + 9, // grenade launcher (acid rounds) + 11, // sub-machine gun + 12, // invalid weapon with no name, icon, or model + 23, // rocket launcher + 104, // hookshot +]; #[binrw] -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq)] #[repr(C)] pub struct Item { id: i32, @@ -19,6 +30,14 @@ impl Item { pub const fn is_empty(&self) -> bool { self.id == 0 } + + pub const fn is_slot_two(&self) -> bool { + self.id == SLOT_TWO + } + + pub fn is_two_slot_item(&self) -> bool { + TWO_SLOT_ITEMS.contains(&self.id) + } } #[derive(Debug, Default)] @@ -49,11 +68,36 @@ impl Bag { pub fn is_organized(&self) -> bool { // if the second half of a two-slot item is in an even-numbered slot, we're not organized - !(self.items.iter().step_by(2).any(|i| i.id == SLOT_TWO) + !(self.items.iter().step_by(2).any(Item::is_slot_two) + // if the first half of a two-slot item is in an odd-numbered slot, we're not organized + || self.items.iter().skip(1).step_by(2).any(Item::is_two_slot_item) // if there's an empty slot followed by a non-empty slot, we're not organized || self.items.iter().skip_while(|i| !i.is_empty()).any(|i| !i.is_empty())) } + pub fn is_broken(&self) -> bool { + for (i, item) in self.items.iter().enumerate() { + // if there's a two-slot item not followed by SLOT_TWO, or a SLOT_TWO preceded by an + // item that's not two slots, the view is in a broken state + let is_two_slot_item = item.is_two_slot_item(); + let slot_two_follows = self.items.get(i + 1).map_or(false, Item::is_slot_two); + if is_two_slot_item != slot_two_follows { + log::trace!( + "View is broken at {}: i is_two_slot_item = {}, i + 1 is_slot_two = {}", + i, + is_two_slot_item, + slot_two_follows + ); + return true; + } + } + false + } + + pub fn is_valid(&self) -> bool { + self.is_organized() && !self.is_broken() + } + pub fn can_exchange_double(&self, index: usize) -> bool { let num_empty: usize = self .items @@ -65,15 +109,11 @@ impl Bag { // a two-slot item num_empty >= 2 || (num_empty == 1 && !self.items[index].is_empty()) - || self - .items - .get(index + 1) - .map(|i| i.id == SLOT_TWO) - .unwrap_or(false) + || self.items.get(index + 1).map_or(false, Item::is_slot_two) } pub fn is_slot_two(&self, index: usize) -> bool { - self.items[index].id == SLOT_TWO + self.items.get(index).map_or(false, Item::is_slot_two) } } @@ -95,6 +135,89 @@ impl ItemBox { } } + fn fix_misaligned(&mut self, mut check_start: usize) { + // if any items are misaligned, we need to find the range of two-slot items at odd indexes, + // then move them back one slot and move the item that was before them to the end. + loop { + let mut iter = self + .items + .iter() + .enumerate() + .skip(check_start & !1) + .step_by(2); + if let Some((bad_index, _)) = iter.find(|(_, i)| i.is_slot_two()) { + // the first half of the item is in the previous slot, so back up 2 to find the + // preceding item + if bad_index < 2 { + log::warn!("Half an item at the beginning of the box. Removing."); + self.items.remove(0); + continue; + } + + log::warn!( + "Misaligned two-slot item at index {}. Correcting.", + bad_index - 1 + ); + let range_start = bad_index - 2; + + let range_end = match iter.find(|(_, i)| !i.is_slot_two()) { + Some((i, _)) => i - 1, // back up one because we're iterating by 2 + None => self.items.len(), + }; + + // this shouldn't happen, but if somehow we ended up with an empty slot in the + // middle of the box, just delete it + if self.items[range_start].is_empty() { + self.items.remove(range_start); + } else { + self.items[range_start..range_end].rotate_left(1); + } + + check_start = range_end; + } else { + break; + } + } + } + + pub fn organize(&mut self) { + log::debug!("Organizing box"); + let mut new_items = Vec::with_capacity(self.items.capacity()); + + // remove all empty slots and fix any broken two-slot items + let mut last_item: Option<&Item> = None; + for (i, item) in self.items.iter().enumerate() { + let (last_item_id, expect_slot_two) = + last_item.map_or((0, false), |i| (i.id, i.is_two_slot_item())); + last_item = Some(item); + + if item.is_slot_two() != expect_slot_two { + if expect_slot_two { + log::warn!("Found two-slot item {} at index {} with no second slot. Inserting slot two.", last_item_id, i - 1); + new_items.push(Item { + id: SLOT_TWO, + count: 1, + }); + } else { + log::warn!("Found orphaned slot-two at index {}. Removing.", i); + continue; + } + } + + if item.is_empty() { + continue; + } + + new_items.push(item.clone()); + } + + // align any misaligned two-slot items + self.items = new_items; + self.fix_misaligned(0); + + log::trace!("Box organized"); + } + fn update_view(&mut self) { let num_items = self.items.len(); let num_items_ahead = num_items - self.index; @@ -110,63 +233,19 @@ impl ItemBox { pub fn update_from_view(&mut self) { // we want to wait until the game has finished organizing the view before we update - if !self.view.is_organized() { + if !self.view.is_valid() { + log::debug!("Skipping update_from_view because the view has not yet been organized"); return; } let view_end = self.index + BAG_SIZE; let view_slice = &mut self.items[self.index..view_end]; view_slice.clone_from_slice(&self.view.items); - // if any items were removed from the view, we should shift up the contents of the box to - // to fill the empty space. the game organizes the view for us when things are moved around, - // so any empty spaces should always be at the end. - let num_empty: usize = view_slice - .iter() - .map(|i| if i.is_empty() { 1 } else { 0 }) - .sum(); - if num_empty > 0 && !self.items.get(view_end).map_or(true, Item::is_empty) { - let remove_start = view_end - num_empty; - self.items.drain(remove_start..view_end); - // now we need to check and see if any two-slot items have ended up at an odd index. - // if they have, we need to find the range of two-slot items at odd indexes, then move - // them back one slot and move the item that was before them to the end. - let mut check_start = remove_start; - loop { - let mut iter = self - .items - .iter() - .enumerate() - .skip(check_start & !1) - .step_by(2); - if let Some((bad_index, _)) = iter.find(|(_, i)| i.id == SLOT_TWO) { - // the first half of the item is in the previous slot, so back up 2 to find the - // preceding item - if bad_index < 2 { - panic!( - "Box contents are screwed up: half an item at the beginning of the box" - ); - } - let range_start = bad_index - 2; - - let range_end = match iter.find(|(_, i)| i.id != SLOT_TWO) { - Some((i, _)) => i - 1, // back up one because we're iterating by 2 - None => self.items.len(), - }; - - // this shouldn't happen, but if somehow we ended up with an empty slot in the - // middle of the box, just delete it - if self.items[range_start].is_empty() { - self.items.remove(range_start); - } else { - self.items[range_start..range_end].rotate_left(1); - } - - check_start = range_end; - } else { - break; - } - } - self.update_view(); + // re-organize the box to account for any gaps or oddities in the view + self.organize(); + self.update_view(); + if !self.view.is_valid() { + log::warn!("View is in an invalid state after updating: {:?}", self); } } @@ -181,6 +260,12 @@ impl ItemBox { self.items.swap(box_index - 1, box_index + 1); } self.update_view(); + if self.view.is_broken() { + log::warn!( + "View is in a broken state after making room for two-slot item: {:?}", + self + ); + } } } @@ -189,6 +274,9 @@ impl ItemBox { self.is_open = true; self.index = 0; self.update_view(); + if !self.view.is_valid() { + log::warn!("View is in an invalid state after opening: {:?}", self); + } } } @@ -214,6 +302,9 @@ impl ItemBox { if self.index != new_index { self.index = new_index; self.update_view(); + if !self.view.is_valid() { + log::warn!("View is in an invalid state after scrolling: {:?}", self); + } true } else { false @@ -241,3 +332,197 @@ impl ItemBox { self.update_view(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn organize_missing_second_half() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 6, count: 1 }, // shotgun, two-slot item + Item { id: 55, count: 7 }, + Item { id: 32, count: 15 }, + ]); + item_box.organize(); + let new_contents = item_box.get_contents(); + let pos = new_contents.iter().position(|i| i.id == 6).unwrap(); + assert_eq!(pos & 1, 0); // must be in an even-numbered (left-hand) slot + assert!(new_contents[pos + 1].is_slot_two()); // must be followed by SLOT_TWO + assert_eq!(new_contents.iter().filter(|i| !i.is_empty()).count(), 4); // must have 4 items, counting the newly inserted SLOT_TWO as an item + } + + #[test] + fn organize_missing_first_half() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 55, count: 7 }, + Item { + id: SLOT_TWO, + count: 1, + }, // SLOT_TWO but the preceding item is not a two-slot item + Item { id: 32, count: 15 }, + Item { id: 3, count: 5 }, + ]); + item_box.organize(); + let new_contents = item_box.get_contents(); + assert!(!new_contents.iter().any(Item::is_slot_two)); + assert_eq!(new_contents.iter().filter(|i| !i.is_empty()).count(), 3); + } + + #[test] + fn organize_misaligned() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 55, count: 7 }, + Item { id: 5, count: 1 }, // two-slot item (hunting gun) at an odd-numbered index + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 32, count: 15 }, + Item { id: 3, count: 5 }, + ]); + item_box.organize(); + let new_contents = item_box.get_contents(); + let pos = new_contents.iter().position(|i| i.id == 5).unwrap(); + assert_eq!(pos & 1, 0); // must be in an even-numbered (left-hand) slot + assert!(new_contents[pos + 1].is_slot_two()); // must be followed by SLOT_TWO + assert_eq!(new_contents.iter().filter(|i| !i.is_empty()).count(), 5); // must have the same number of items, counting SLOT_TWO as an item + } + + #[test] + fn organize_gaps() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 55, count: 7 }, + Item { id: 0, count: 0 }, // empty slot between non-empty slots + Item { id: 32, count: 15 }, + Item { id: 3, count: 5 }, + ]); + item_box.organize(); + let new_contents = item_box.get_contents(); + assert_eq!(new_contents.iter().take_while(|i| !i.is_empty()).count(), 3); + // empty slot should be gone, any empty slots at the end notwithstanding + } + + #[test] + fn make_room_for_double() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 6, count: 1 }, + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 55, count: 7 }, + Item { id: 32, count: 15 }, + Item { id: 14, count: 3 }, + ]); + item_box.open(); + assert!(item_box.view.is_valid()); + // there are five items in the box (counting the SLOT_TWO item) so there should be five occupied + // slots in the view and one empty slot at the end + let view = item_box.view(); + assert_eq!(view.items.iter().filter(|i| i.is_empty()).count(), 1); + assert!(view.items[BAG_SIZE - 1].is_empty()); + item_box.make_room_for_double(BAG_SIZE - 1); + // there should now be two empty slots at the end of the view to make room for the two-slot item + let view = item_box.view(); + assert!(view.items[BAG_SIZE - 2].is_empty()); + assert!(view.items[BAG_SIZE - 1].is_empty()); + } + + #[test] + fn scroll() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 6, count: 1 }, + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 104, count: 1 }, + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 55, count: 7 }, + Item { id: 32, count: 15 }, + Item { id: 14, count: 3 }, + Item { id: 4, count: 7 }, + ]); + item_box.open(); + assert!(item_box.view().is_valid()); + assert_eq!(item_box.view().items[0].id, 6); + // not allowed to scroll before the beginning + item_box.scroll_view(-2); + assert_eq!(item_box.view().items[0].id, 6); + item_box.scroll_view(2); + assert_eq!(item_box.view().items[0].id, 104); + // we always scroll in increments of 2, so odd numbers should be rounded + item_box.scroll_view(1); + assert_eq!(item_box.view().items[0].id, 55); + // not allowed to scroll past the end + item_box.scroll_view(1000); + assert_eq!(item_box.view().items[0].id, 14); + // negative + item_box.scroll_view(-2); + assert_eq!(item_box.view().items[0].id, 55); + } + + #[test] + fn update_from_view() { + let mut item_box = ItemBox::new(); + item_box.set_contents(vec![ + Item { id: 6, count: 1 }, + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 104, count: 1 }, + Item { + id: SLOT_TWO, + count: 1, + }, + Item { id: 3, count: 7 }, + Item { id: 32, count: 15 }, + Item { id: 14, count: 3 }, + ]); + item_box.open(); + assert!(item_box.view().is_valid()); + item_box.view().items[4] = Item { id: 4, count: 9 }; + item_box.update_from_view(); + let contents = item_box.get_contents(); + assert!(!contents.iter().any(|i| i.id == 3)); + assert!(contents.iter().any(|i| i.id == 4)); + + item_box.view().items[BAG_SIZE - 1] = Item::empty(); + item_box.update_from_view(); + // 7 items - the 1 we removed == 6 + assert_eq!( + item_box + .get_contents() + .iter() + .take_while(|i| !i.is_empty()) + .count(), + 6 + ); + let view = item_box.view(); + // there should be no more empty slots in the view because we still have enough items to fill it + assert!(view.items.iter().all(|i| !i.is_empty())); + // the last item in the view should be the item that was shifted up from the end, 14 + assert_eq!(view.items[BAG_SIZE - 1].id, 14); + } + + #[test] + fn open_and_close() { + let mut item_box = ItemBox::new(); + assert!(!item_box.is_open()); // box should not start open + item_box.open(); + assert!(item_box.is_open()); // box should now be open + item_box.close(); + assert!(!item_box.is_open()); // box should no longer be open + } +} diff --git a/src/lib.rs b/src/lib.rs index d5cf690..7617409 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,22 @@ #![cfg(windows)] use std::ffi::c_void; -use std::path::Path; +use std::panic; +use std::path::{Path, PathBuf}; use std::str; use anyhow::Result; use configparser::ini::Ini; +use simplelog::LevelFilter; use windows::Win32::Foundation::{BOOL, HMODULE}; -use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH; +use windows::Win32::System::SystemServices::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH}; mod patch; use patch::*; +mod error; +use error::*; + mod game; use game::*; @@ -33,26 +38,26 @@ const MSG_FILES: [&[u8; 8]; 8] = [ // I tried the naked-function crate, but it failed to compile for me, complaining about "unknown // directive" .pushsection. maybe it has something to do with the fact that I'm cross-compiling. -static mut SCROLL_UP_TRAMPOLINE: [u8; 20] = [ +static mut SCROLL_UP_TRAMPOLINE: [u8; 18] = [ 0x60, // pushad - 0x6A, 0xFE, // push -2 0x57, // push edi 0xE8, 0, 0, 0, 0, // call - 0x83, 0xC4, 0x08, // add esp,8 + 0x83, 0xC4, 0x04, // add esp,4 0x61, // popad 0xBE, 0x9E, 0x4D, 0x5E, 0, // mov esi, 0x5e4d9e 0xFF, 0xE6, // jmp esi ]; -static mut SCROLL_DOWN_TRAMPOLINE: [u8; 20] = [ - 0x60, // pushad - 0x6A, 0x02, // push 2 +static mut SCROLL_DOWN_TRAMPOLINE: [u8; 27] = [ + 0x50, // push eax 0x57, // push edi 0xE8, 0, 0, 0, 0, // call 0x83, 0xC4, 0x08, // add esp,8 - 0x61, // popad - 0xBE, 0x9E, 0x4D, 0x5E, 0, // mov esi, 0x5e4d9e - 0xFF, 0xE6, // jmp esi + 0xBB, 0x3B, 0x39, 0x5E, 0x00, // mov ebx,0x5e393b + 0x83, 0xF8, 0x06, // cmp eax,6 + 0x7C, 0x05, // jl do_jump + 0xBB, 0x9E, 0x4D, 0x5E, 0x00, // mov ebx,0x5e4d9e + 0xFF, 0xE3, // do_jump: jmp ebx ]; static mut SCROLL_LEFT_TRAMPOLINE: [u8; 22] = [ @@ -68,18 +73,25 @@ static mut SCROLL_LEFT_TRAMPOLINE: [u8; 22] = [ 0xFF, 0xE2, // jmp edx ]; -static mut SCROLL_RIGHT_TRAMPOLINE: [u8; 25] = [ - 0x83, 0xF8, 0x06, // cmp eax,6 - 0x7C, 0x0D, // jl done - 0x51, // push ecx - 0x52, // push edx +static mut SCROLL_RIGHT_TRAMPOLINE: [u8; 22] = [ + 0x83, 0xF8, 0x05, // cmp eax,5 + 0x7C, 0x0A, // jl done + 0x50, // push eax 0x57, // push edi 0xE8, 0x00, 0x00, 0x00, 0x00, // call - 0x83, 0xC4, 0x04, // add esp,4 - 0x5A, // pop edx - 0x59, // pop ecx - 0xBA, 0xF9, 0x39, 0x5E, 0x00, // done: mov edx,0x5e39f9 - 0xFF, 0xE2, // jmp edx + 0x83, 0xC4, 0x08, // add esp,8 + 0xBB, 0x03, 0x3B, 0x5E, 0x00, // done: mov ebx,0x5e3b03 + 0xFF, 0xE3, // jmp ebx +]; + +static mut SCROLL_RIGHT_TWO_TRAMPOLINE: [u8; 28] = [ + 0xFF, 0xB7, 0xBC, 0x02, 0x00, 0x00, // push dword ptr [edi+0x2bc] + 0x57, // push edi + 0xE8, 0x00, 0x00, 0x00, 0x00, // call + 0x83, 0xC4, 0x08, // add esp,8 + 0x89, 0x87, 0xBC, 0x02, 0x00, 0x00, // mov dword ptr [edi+0x2bc],eax + 0xB8, 0x64, 0x3B, 0x5E, 0x00, // mov eax,0x5e3b64 + 0xFF, 0xE0, // jmp eax ]; static mut PARTNER_BAG_ORG_TRAMPOLINE: [u8; 26] = [ @@ -285,17 +297,20 @@ static mut BOX: ItemBox = ItemBox::new(); static mut GAME: Game = Game::new(); unsafe extern "C" fn new_game() { + log::debug!("new_game"); // reset the box when starting a new game BOX.set_contents(vec![]); } unsafe extern "fastcall" fn should_skip_shaft_check(partner: *const c_void) -> bool { + log::trace!("should_skip_shaft_check"); // partner should never be null at this point of the code unless the box is open, but we'll // check both anyway just to be safe BOX.is_open() || partner.is_null() } unsafe extern "C" fn load_msg_file(lang: *const u8) -> *const u8 { + log::trace!("load_msg_file"); let lang_slice = std::slice::from_raw_parts(lang, 3); if let Some(override_file) = MSG_FILES.iter().find(|f| f.starts_with(lang_slice)) { // make sure the file actually exists before we tell the game to load it @@ -315,20 +330,33 @@ unsafe extern "C" fn load_msg_file(lang: *const u8) -> *const u8 { } unsafe extern "C" fn save_slot(index: usize) { + log::debug!("save_slot {}", index); GAME.save_to_slot(BOX.get_contents(), index); } unsafe extern "stdcall" fn save_data(filename: *const u8, buf: *const u8, size: usize) -> bool { - GAME.save(std::slice::from_raw_parts(buf, size), filename) - .is_ok() + log::trace!("save_data"); + if let Err(e) = GAME.save(std::slice::from_raw_parts(buf, size), filename) { + log::error!("Failed to save: {:?}", e); + false + } else { + true + } } unsafe extern "C" fn load_slot(index: usize) { + log::debug!("load_slot {}", index); BOX.set_contents(GAME.load_from_slot(index)); + // fix the box if we somehow saved it in an invalid state + BOX.organize(); } unsafe extern "C" fn load_data(buf: *const u8, size: usize) -> usize { - GAME.load(std::slice::from_raw_parts(buf, size)).unwrap(); + log::trace!("load_data"); + if let Err(e) = GAME.load(std::slice::from_raw_parts(buf, size)) { + log::error!("Failed to load save data: {:?}", e); + panic!("Failed to load save data"); + } UNMODDED_SAVE_SIZE } @@ -338,6 +366,7 @@ unsafe extern "stdcall" fn make_room_for_double( unknown: *const c_void, item_size: usize, ) -> i32 { + log::trace!("make_room_for_double"); if BOX.is_open() { if item_size > 1 { let index = *(menu.offset(0x2bc) as *const usize); @@ -353,6 +382,7 @@ unsafe extern "stdcall" fn make_room_for_double( } unsafe extern "fastcall" fn show_partner_inventory(menu: *mut c_void) -> bool { + log::trace!("show_partner_inventory"); if BOX.is_open() { // flag that the partner inventory is displayed *(menu.offset(0x2ca) as *mut bool) = true; @@ -362,23 +392,30 @@ unsafe extern "fastcall" fn show_partner_inventory(menu: *mut c_void) -> bool { } unsafe fn close_box() { + log::debug!("close_box"); BOX.close(); + // fix the box if it somehow got into an invalid state + BOX.organize(); } unsafe extern "C" fn change_character(menu: *mut c_void) { + log::trace!("change_character"); if BOX.is_open() { GAME.update_exchange_state(menu); } } unsafe extern "C" fn menu_setup(menu: *mut c_void) { + log::trace!("menu_setup"); if BOX.is_open() { GAME.init_menu(menu); } } unsafe fn open_box() -> bool { + log::debug!("open_box"); if GAME.should_open_box { + log::debug!("Opening item box"); GAME.prepare_inventory(); BOX.open(); } @@ -387,6 +424,7 @@ unsafe fn open_box() -> bool { } unsafe extern "C" fn check_typewriter_choice(choice: i32) -> bool { + log::trace!("check_typewriter_choice"); // "no" is option 2 for both messages if choice == 2 { true @@ -400,10 +438,12 @@ unsafe extern "C" fn check_typewriter_choice(choice: i32) -> bool { } unsafe extern "C" fn track_typewriter_message(had_ink_ribbon: bool) { + log::trace!("track_typewriter_message {}", had_ink_ribbon); GAME.user_had_ink_ribbon = had_ink_ribbon; } unsafe extern "C" fn scroll_left(unknown: *const c_void) -> i32 { + log::trace!("scroll_left"); if BOX.is_open() && BOX.scroll_view(-2) { GAME.draw_bags(unknown); if BOX.view().is_slot_two(1) { @@ -412,21 +452,28 @@ unsafe extern "C" fn scroll_left(unknown: *const c_void) -> i32 { 1 } } else { - 5 // we're already at the top, so wrap around to the last cell in the view + (BAG_SIZE - 1) as i32 // we're already at the top, so wrap around to the last cell in the view } } -unsafe extern "C" fn scroll_right(unknown: *const c_void) -> i32 { - if BOX.is_open() && BOX.scroll_view(2) { +unsafe extern "C" fn scroll_right(unknown: *const c_void, new_index: i32) -> i32 { + log::trace!("scroll_right {}", new_index); + let bag_size = BAG_SIZE as i32; + if BOX.is_open() + && (new_index == bag_size + || (new_index == bag_size - 1 && BOX.view().is_slot_two(new_index as usize))) + && BOX.scroll_view(2) + { GAME.draw_bags(unknown); - 4 + bag_size - 2 } else { - 0 // we're already at the bottom, so wrap around to the first cell in the view + new_index % bag_size } } -unsafe extern "C" fn scroll(unknown: *const c_void, offset: isize) { - if BOX.is_open() && BOX.scroll_view(offset) { +unsafe extern "C" fn scroll_up(unknown: *const c_void) { + log::trace!("scroll_up"); + if BOX.is_open() && BOX.scroll_view(-2) { // by default the inventory display doesn't update at this point, so we have to do it ourselves GAME.draw_bags(unknown); // if we've ended up on the second slot of a two-slot item, back up one @@ -440,13 +487,35 @@ unsafe extern "C" fn scroll(unknown: *const c_void, offset: isize) { } } +unsafe extern "C" fn scroll_down(unknown: *const c_void, mut new_index: i32) -> i32 { + log::trace!("scroll_down {}", new_index); + if BOX.is_open() { + if new_index >= BAG_SIZE as i32 && BOX.scroll_view(2) { + // by default the inventory display doesn't update at this point, so we have to do it ourselves + GAME.draw_bags(unknown); + // the sound doesn't normally play when moving the cursor past the edges of the inventory, + // so we have to do that, too + GAME.play_sound(MOVE_SELECTION_SOUND); + new_index -= 2; + } + // if we've ended up on the second slot of a two-slot item, back up one + if BOX.view().is_slot_two(new_index as usize) { + return new_index - 1; + } + } + + new_index +} + unsafe fn update_box() { + log::trace!("update_box"); if BOX.is_open() { BOX.update_from_view(); } } unsafe extern "C" fn get_box_if_open() -> *mut Bag { + log::trace!("get_box_if_open"); if BOX.is_open() { BOX.view() } else { @@ -460,6 +529,9 @@ unsafe extern "C" fn get_box_if_open() -> *mut Bag { } unsafe extern "fastcall" fn get_partner_bag(unknown: *mut c_void) -> *mut Bag { + // this function is called a lot, even outside the inventory menu, so logging it just floods + // the log with useless info + // log::trace!("get_partner_bag"); if BOX.is_open() { return BOX.view(); } @@ -477,9 +549,216 @@ unsafe extern "fastcall" fn get_partner_bag(unknown: *mut c_void) -> *mut Bag { } } +unsafe fn initialize(is_enabled: bool, is_leave_allowed: bool) -> Result<()> { + log::info!("Initializing item box mod"); + + GAME.init(is_enabled); + + if is_enabled { + log::info!("Item box mod is enabled; installing all hooks"); + // when the game tries to display the partner's inventory, show the box instead if it's open + let bag_jump = jmp(GET_PARTNER_BAG, get_partner_bag as usize); + patch(GET_PARTNER_BAG, &bag_jump)?; + let org_jump = jmp( + GET_PARTNER_BAG_ORG, + PARTNER_BAG_ORG_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline(&mut PARTNER_BAG_ORG_TRAMPOLINE, 0, get_box_if_open as usize)?; + patch(GET_PARTNER_BAG_ORG, &org_jump)?; + + // override the msg file the game looks for so we don't have to replace the originals + let msg_jump1 = jmp(MSG_LOAD1, MSG_TRAMPOLINE1.as_ptr() as usize); + set_trampoline(&mut MSG_TRAMPOLINE1, 0, load_msg_file as usize)?; + patch(MSG_LOAD1, &msg_jump1)?; + + let msg_jump2 = jmp(MSG_LOAD2, MSG_TRAMPOLINE2.as_ptr() as usize); + set_trampoline(&mut MSG_TRAMPOLINE2, 0, load_msg_file as usize)?; + patch(MSG_LOAD2, &msg_jump2)?; + + let msg_jump3 = jmp(MSG_LOAD3, MSG_TRAMPOLINE3.as_ptr() as usize); + set_trampoline(&mut MSG_TRAMPOLINE3, 0, load_msg_file as usize)?; + patch(MSG_LOAD3, &msg_jump3)?; + + // when trying to scroll up past the top inventory row, scroll the box view + let scroll_up_jump = jl(SCROLL_UP_CHECK, SCROLL_UP_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut SCROLL_UP_TRAMPOLINE, 2, scroll_up as usize)?; + patch(SCROLL_UP_CHECK, &scroll_up_jump)?; + + // when trying to scroll down past the last inventory row, scroll the box view + let scroll_down_jump = jmp(SCROLL_DOWN_CHECK, SCROLL_DOWN_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut SCROLL_DOWN_TRAMPOLINE, 2, scroll_down as usize)?; + patch(SCROLL_DOWN_CHECK, &scroll_down_jump)?; + + // when trying to scroll left from the first inventory cell, scroll the box view + let scroll_left_jump = jmp(SCROLL_LEFT_CHECK, SCROLL_LEFT_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut SCROLL_LEFT_TRAMPOLINE, 5, scroll_left as usize)?; + patch(SCROLL_LEFT_CHECK, &scroll_left_jump)?; + + // when trying to scroll right from the last inventory cell, scroll the box view + let scroll_right_jump = jmp( + SCROLL_RIGHT_CHECK, + SCROLL_RIGHT_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline(&mut SCROLL_RIGHT_TRAMPOLINE, 7, scroll_right as usize)?; + patch(SCROLL_RIGHT_CHECK, &scroll_right_jump)?; + let scroll_right_two_jump = jmp( + SCROLL_RIGHT_TWO_CHECK, + SCROLL_RIGHT_TWO_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline(&mut SCROLL_RIGHT_TWO_TRAMPOLINE, 7, scroll_right as usize)?; + patch(SCROLL_RIGHT_TWO_CHECK, &scroll_right_two_jump)?; + + // after the view is organized, copy its contents back into the box + let organize_jump1 = jmp(ORGANIZE_END1, ORGANIZE_TRAMPOLINE.as_ptr() as usize); + let organize_jump2 = jmp(ORGANIZE_END2, ORGANIZE_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut ORGANIZE_TRAMPOLINE, 1, update_box as usize)?; + patch(ORGANIZE_END1, &organize_jump1)?; + patch(ORGANIZE_END2, &organize_jump2)?; + + if !is_leave_allowed { + log::info!("Disabling leave option"); + // disable leaving items since that would be OP when combined with the item box + patch(LEAVE_SOUND_ARG, &FAIL_SOUND.to_le_bytes())?; + patch(LEAVE_MENU_STATE, &[0xEB, 0x08])?; // short jump to skip the code that switches to the "leaving item" menu state + } + + // handle the extra options when activating the typewriter + let has_ink_jump = jmp(HAS_INK_RIBBON, HAS_INK_RIBBON_TRAMPOLINE.as_ptr() as usize); + set_trampoline( + &mut HAS_INK_RIBBON_TRAMPOLINE, + 3, + track_typewriter_message as usize, + )?; + patch(HAS_INK_RIBBON, &has_ink_jump)?; + + let no_ink_jump = jmp(NO_INK_RIBBON, NO_INK_RIBBON_TRAMPOLINE.as_ptr() as usize); + set_trampoline( + &mut NO_INK_RIBBON_TRAMPOLINE, + 3, + track_typewriter_message as usize, + )?; + patch(NO_INK_RIBBON, &no_ink_jump)?; + + let choice_jump = jmp( + TYPEWRITER_CHOICE_CHECK, + TYPEWRITER_CHOICE_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline( + &mut TYPEWRITER_CHOICE_TRAMPOLINE, + 6, + check_typewriter_choice as usize, + )?; + patch(TYPEWRITER_CHOICE_CHECK, &choice_jump)?; + + let box_jump = jmp(TYPEWRITER_PHASE_SET, OPEN_BOX_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut OPEN_BOX_TRAMPOLINE, 1, open_box as usize)?; + set_trampoline(&mut OPEN_BOX_TRAMPOLINE, 19, SET_ROOM_PHASE)?; + patch(TYPEWRITER_PHASE_SET, &box_jump)?; + + // make the menu show the box to start with instead of the partner control panel + let view_jump = jmp( + INVENTORY_OPEN_ANIMATION, + OPEN_ANIMATION_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline( + &mut OPEN_ANIMATION_TRAMPOLINE, + 1, + show_partner_inventory as usize, + )?; + set_trampoline(&mut OPEN_ANIMATION_TRAMPOLINE, 23, PLAY_MENU_ANIMATION)?; + patch(INVENTORY_OPEN_ANIMATION, &view_jump)?; + + // always enable exchanging when a character first opens the box + let init_jump = jmp( + INVENTORY_MENU_START, + INVENTORY_START_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline(&mut INVENTORY_START_TRAMPOLINE, 2, menu_setup as usize)?; + patch(INVENTORY_MENU_START, &init_jump)?; + + // handle enabling and disabling exchanging when the character changes + let character_jump = jmp( + INVENTORY_CHANGE_CHARACTER, + CHANGE_CHARACTER_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline( + &mut CHANGE_CHARACTER_TRAMPOLINE, + 2, + change_character as usize, + )?; + patch(INVENTORY_CHANGE_CHARACTER, &character_jump)?; + + // close the box after closing the inventory + let close_jump = jmp( + INVENTORY_MENU_CLOSE, + INVENTORY_CLOSE_TRAMPOLINE.as_ptr() as usize, + ); + set_trampoline(&mut INVENTORY_CLOSE_TRAMPOLINE, 1, close_box as usize)?; + patch(INVENTORY_MENU_CLOSE, &close_jump)?; + + // make room in the box if the player tries to swap a two-slot item into a full view + let double_jump = jmp(EXCHANGE_SIZE_CHECK, SIZE_CHECK_TRAMPOLINE.as_ptr() as usize); + set_trampoline( + &mut SIZE_CHECK_TRAMPOLINE, + 11, + make_room_for_double as usize, + )?; + patch(EXCHANGE_SIZE_CHECK, &double_jump)?; + + // skip the check preventing giving both shaft keys to the same character when the box + // is open. aside from being undesirable, it also crashes the game when using the box + // without having a partner character. + let shaft_jump = jmp(SHAFT_CHECK, SHAFT_CHECK_TRAMPOLINE.as_ptr() as usize); + set_trampoline( + &mut SHAFT_CHECK_TRAMPOLINE, + 1, + should_skip_shaft_check as usize, + )?; + patch(SHAFT_CHECK, &shaft_jump)?; + + // reset the box when starting a new game + let new_game_call = call(NEW_GAME, NEW_GAME_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut NEW_GAME_TRAMPOLINE, 1, new_game as usize)?; + patch(NEW_GAME, &new_game_call)?; + } else { + log::info!("Item box mod is disabled; installing only save/load hooks"); + } + + // even if the mod is disabled, we still install our load and save handlers to prevent + // the game from blowing away saved boxes, and also so we can clear the box on any save + // slots that are saved to while the mod is inactive + + // load data + let load_jump = jmp(POST_LOAD, LOAD_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut LOAD_TRAMPOLINE, 2, load_data as usize)?; + set_trampoline(&mut LOAD_TRAMPOLINE, 20, SUB_6FC610)?; + patch(POST_LOAD, &load_jump)?; + + // load slot + let ls_jump = jmp(LOAD_SLOT, LOAD_SLOT_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut LOAD_SLOT_TRAMPOLINE, 2, load_slot as usize)?; + patch(LOAD_SLOT, &ls_jump)?; + + // save data + let save_jump = jmp(STEAM_SAVE, SAVE_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut SAVE_TRAMPOLINE, 6, save_data as usize)?; + patch(STEAM_SAVE, &save_jump)?; + + // save slot + let ss_jump = jmp(SAVE_SLOT, SAVE_SLOT_TRAMPOLINE.as_ptr() as usize); + set_trampoline(&mut SAVE_SLOT_TRAMPOLINE, 2, save_slot as usize)?; + patch(SAVE_SLOT, &ss_jump)?; + + log::info!("Patching complete"); + + Ok(()) +} + fn main(reason: u32) -> Result<()> { if reason == DLL_PROCESS_ATTACH { - let config_path = unsafe { Game::get_game_dir() }.join("re0box.ini"); + let game_dir = unsafe { Game::get_game_dir() }; + + let config_path = game_dir.join("re0box.ini"); let mut config = Ini::new(); // we don't care if the config fails to load, we'll just use the defaults let _ = config.load(config_path); @@ -493,197 +772,31 @@ fn main(reason: u32) -> Result<()> { .ok() .flatten() .unwrap_or(false); + let log_level = config + .get("Log", "Level") + .map(|s| { + let s = s.to_lowercase(); + LevelFilter::iter().find(|l| l.as_str().to_lowercase() == s) + }) + .flatten() + .unwrap_or(LevelFilter::Info); + let mut log_file_path = config + .get("Log", "Path") + .map_or_else(|| PathBuf::from("re0box.log"), PathBuf::from); + + if !log_file_path.is_absolute() { + log_file_path = game_dir.join(log_file_path); + } - unsafe { - GAME.init(is_enabled); - - if is_enabled { - // when the game tries to display the partner's inventory, show the box instead if it's open - let bag_jump = jmp(GET_PARTNER_BAG, get_partner_bag as usize); - patch(GET_PARTNER_BAG, &bag_jump)?; - let org_jump = jmp( - GET_PARTNER_BAG_ORG, - PARTNER_BAG_ORG_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline(&mut PARTNER_BAG_ORG_TRAMPOLINE, 0, get_box_if_open as usize)?; - patch(GET_PARTNER_BAG_ORG, &org_jump)?; - - // override the msg file the game looks for so we don't have to replace the originals - let msg_jump1 = jmp(MSG_LOAD1, MSG_TRAMPOLINE1.as_ptr() as usize); - set_trampoline(&mut MSG_TRAMPOLINE1, 0, load_msg_file as usize)?; - patch(MSG_LOAD1, &msg_jump1)?; - - let msg_jump2 = jmp(MSG_LOAD2, MSG_TRAMPOLINE2.as_ptr() as usize); - set_trampoline(&mut MSG_TRAMPOLINE2, 0, load_msg_file as usize)?; - patch(MSG_LOAD2, &msg_jump2)?; - - let msg_jump3 = jmp(MSG_LOAD3, MSG_TRAMPOLINE3.as_ptr() as usize); - set_trampoline(&mut MSG_TRAMPOLINE3, 0, load_msg_file as usize)?; - patch(MSG_LOAD3, &msg_jump3)?; - - // when trying to scroll up past the top inventory row, scroll the box view - let scroll_up_jump = jl(SCROLL_UP_CHECK, SCROLL_UP_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut SCROLL_UP_TRAMPOLINE, 4, scroll as usize)?; - patch(SCROLL_UP_CHECK, &scroll_up_jump)?; - - // when trying to scroll down past the last inventory row, scroll the box view - let scroll_down_jump = - jge(SCROLL_DOWN_CHECK, SCROLL_DOWN_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut SCROLL_DOWN_TRAMPOLINE, 4, scroll as usize)?; - patch(SCROLL_DOWN_CHECK, &scroll_down_jump)?; - - // when trying to scroll left from the first inventory cell, scroll the box view - let scroll_left_jump = - jmp(SCROLL_LEFT_CHECK, SCROLL_LEFT_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut SCROLL_LEFT_TRAMPOLINE, 5, scroll_left as usize)?; - patch(SCROLL_LEFT_CHECK, &scroll_left_jump)?; - - // when trying to scroll right from the last inventory cell, scroll the box view - let scroll_right_jump = jmp( - SCROLL_RIGHT_CHECK, - SCROLL_RIGHT_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline(&mut SCROLL_RIGHT_TRAMPOLINE, 8, scroll_right as usize)?; - patch(SCROLL_RIGHT_CHECK, &scroll_right_jump)?; - - // after the view is organized, copy its contents back into the box - let organize_jump1 = jmp(ORGANIZE_END1, ORGANIZE_TRAMPOLINE.as_ptr() as usize); - let organize_jump2 = jmp(ORGANIZE_END2, ORGANIZE_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut ORGANIZE_TRAMPOLINE, 1, update_box as usize)?; - patch(ORGANIZE_END1, &organize_jump1)?; - patch(ORGANIZE_END2, &organize_jump2)?; - - if !is_leave_allowed { - // disable leaving items since that would be OP when combined with the item box - patch(LEAVE_SOUND_ARG, &FAIL_SOUND.to_le_bytes())?; - patch(LEAVE_MENU_STATE, &[0xEB, 0x08])?; // short jump to skip the code that switches to the "leaving item" menu state - } - - // handle the extra options when activating the typewriter - let has_ink_jump = jmp(HAS_INK_RIBBON, HAS_INK_RIBBON_TRAMPOLINE.as_ptr() as usize); - set_trampoline( - &mut HAS_INK_RIBBON_TRAMPOLINE, - 3, - track_typewriter_message as usize, - )?; - patch(HAS_INK_RIBBON, &has_ink_jump)?; - - let no_ink_jump = jmp(NO_INK_RIBBON, NO_INK_RIBBON_TRAMPOLINE.as_ptr() as usize); - set_trampoline( - &mut NO_INK_RIBBON_TRAMPOLINE, - 3, - track_typewriter_message as usize, - )?; - patch(NO_INK_RIBBON, &no_ink_jump)?; - - let choice_jump = jmp( - TYPEWRITER_CHOICE_CHECK, - TYPEWRITER_CHOICE_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline( - &mut TYPEWRITER_CHOICE_TRAMPOLINE, - 6, - check_typewriter_choice as usize, - )?; - patch(TYPEWRITER_CHOICE_CHECK, &choice_jump)?; - - let box_jump = jmp(TYPEWRITER_PHASE_SET, OPEN_BOX_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut OPEN_BOX_TRAMPOLINE, 1, open_box as usize)?; - set_trampoline(&mut OPEN_BOX_TRAMPOLINE, 19, SET_ROOM_PHASE)?; - patch(TYPEWRITER_PHASE_SET, &box_jump)?; - - // make the menu show the box to start with instead of the partner control panel - let view_jump = jmp( - INVENTORY_OPEN_ANIMATION, - OPEN_ANIMATION_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline( - &mut OPEN_ANIMATION_TRAMPOLINE, - 1, - show_partner_inventory as usize, - )?; - set_trampoline(&mut OPEN_ANIMATION_TRAMPOLINE, 23, PLAY_MENU_ANIMATION)?; - patch(INVENTORY_OPEN_ANIMATION, &view_jump)?; - - // always enable exchanging when a character first opens the box - let init_jump = jmp( - INVENTORY_MENU_START, - INVENTORY_START_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline(&mut INVENTORY_START_TRAMPOLINE, 2, menu_setup as usize)?; - patch(INVENTORY_MENU_START, &init_jump)?; - - // handle enabling and disabling exchanging when the character changes - let character_jump = jmp( - INVENTORY_CHANGE_CHARACTER, - CHANGE_CHARACTER_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline( - &mut CHANGE_CHARACTER_TRAMPOLINE, - 2, - change_character as usize, - )?; - patch(INVENTORY_CHANGE_CHARACTER, &character_jump)?; - - // close the box after closing the inventory - let close_jump = jmp( - INVENTORY_MENU_CLOSE, - INVENTORY_CLOSE_TRAMPOLINE.as_ptr() as usize, - ); - set_trampoline(&mut INVENTORY_CLOSE_TRAMPOLINE, 1, close_box as usize)?; - patch(INVENTORY_MENU_CLOSE, &close_jump)?; - - // make room in the box if the player tries to swap a two-slot item into a full view - let double_jump = jmp(EXCHANGE_SIZE_CHECK, SIZE_CHECK_TRAMPOLINE.as_ptr() as usize); - set_trampoline( - &mut SIZE_CHECK_TRAMPOLINE, - 11, - make_room_for_double as usize, - )?; - patch(EXCHANGE_SIZE_CHECK, &double_jump)?; - - // skip the check preventing giving both shaft keys to the same character when the box - // is open. aside from being undesirable, it also crashes the game when using the box - // without having a partner character. - let shaft_jump = jmp(SHAFT_CHECK, SHAFT_CHECK_TRAMPOLINE.as_ptr() as usize); - set_trampoline( - &mut SHAFT_CHECK_TRAMPOLINE, - 1, - should_skip_shaft_check as usize, - )?; - patch(SHAFT_CHECK, &shaft_jump)?; - - // reset the box when starting a new game - let new_game_call = call(NEW_GAME, NEW_GAME_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut NEW_GAME_TRAMPOLINE, 1, new_game as usize)?; - patch(NEW_GAME, &new_game_call)?; - } - - // even if the mod is disabled, we still install our load and save handlers to prevent - // the game from blowing away saved boxes, and also so we can clear the box on any save - // slots that are saved to while the mod is inactive - - // load data - let load_jump = jmp(POST_LOAD, LOAD_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut LOAD_TRAMPOLINE, 2, load_data as usize)?; - set_trampoline(&mut LOAD_TRAMPOLINE, 20, SUB_6FC610)?; - patch(POST_LOAD, &load_jump)?; - - // load slot - let ls_jump = jmp(LOAD_SLOT, LOAD_SLOT_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut LOAD_SLOT_TRAMPOLINE, 2, load_slot as usize)?; - patch(LOAD_SLOT, &ls_jump)?; - - // save data - let save_jump = jmp(STEAM_SAVE, SAVE_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut SAVE_TRAMPOLINE, 6, save_data as usize)?; - patch(STEAM_SAVE, &save_jump)?; - - // save slot - let ss_jump = jmp(SAVE_SLOT, SAVE_SLOT_TRAMPOLINE.as_ptr() as usize); - set_trampoline(&mut SAVE_SLOT_TRAMPOLINE, 2, save_slot as usize)?; - patch(SAVE_SLOT, &ss_jump)?; + // ignore the result because there's nothing we can do if opening the log file fails (except + // crash, which we don't want to do) + let _ = open_log(log_level, log_file_path); + if let Err(e) = unsafe { initialize(is_enabled, is_leave_allowed) } { + log::error!("Initialization failed: {:?}", e); + return Err(e); } + } else if reason == DLL_PROCESS_DETACH { + close_log(); } Ok(()) diff --git a/src/patch.rs b/src/patch.rs index ec567fe..ffbf1f0 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,3 +1,7 @@ +// I don't want to get warnings in here about unused instruction functions that I've used in the +// past and could want again in the future +#![allow(dead_code)] + use std::ffi::c_void; use windows::Win32::System::Memory::{ VirtualProtect, PAGE_EXECUTE_READWRITE, PAGE_PROTECTION_FLAGS, @@ -56,3 +60,37 @@ pub unsafe fn patch(addr: usize, bytes: &[u8]) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calc_addr_offset() { + assert_eq!(addr_offset(0x80000000, 0x80000010, 3), [13, 0, 0, 0]); + assert_eq!( + addr_offset(0x80000000, 0x7FFFFF10, 4), + [12, 0xff, 0xff, 0xff] + ); + } + + #[test] + fn call_bytes() { + assert_eq!(call(0x80000000, 0x80000010), [0xE8, 11, 0, 0, 0]); + } + + #[test] + fn jmp_bytes() { + assert_eq!(jmp(0x80000000, 0x80000010), [0xE9, 11, 0, 0, 0]); + } + + #[test] + fn jl_bytes() { + assert_eq!(jl(0x80000000, 0x800000F0), [0x0F, 0x8C, 0xEA, 0, 0, 0]); + } + + #[test] + fn jge_bytes() { + assert_eq!(jge(0x80000000, 0x800000E0), [0x0F, 0x8D, 0xDA, 0, 0, 0]); + } +}