326 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			326 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
| use log::warn;
 | |
| use std::collections::{HashMap, BTreeMap, BTreeSet};
 | |
| use std::fs::File;
 | |
| use std::io::{Read, Write, Cursor, Seek, SeekFrom};
 | |
| use std::path::PathBuf;
 | |
| use std::convert::TryInto;
 | |
| use async_std::sync::Arc;
 | |
| use thiserror::Error;
 | |
| use serde::{Serialize, Deserialize};
 | |
| use ages_prs::{LegacyPrsDecoder, LegacyPrsEncoder};
 | |
| use byteorder::{LittleEndian, ReadBytesExt};
 | |
| use libpso::util::array_to_utf16;
 | |
| use crate::ship::map::{MapArea, MapAreaError, MapObject, MapEnemy, enemy_data_from_stream, objects_from_stream};
 | |
| use crate::ship::room::{Episode, RoomMode};
 | |
| use crate::ship::map::area::{MapAreaLookup, MapAreaLookupBuilder};
 | |
| 
 | |
| 
 | |
| #[derive(Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
 | |
| pub struct QuestCategory {
 | |
|     index: usize,
 | |
|     pub name: String,
 | |
|     pub description: String,
 | |
| }
 | |
| 
 | |
| 
 | |
| #[derive(Debug, Serialize, Deserialize, Hash)]
 | |
| struct QuestListEntry {
 | |
|     bin: String,
 | |
|     dat: String,
 | |
| }
 | |
| 
 | |
| #[derive(Debug, Serialize, Deserialize, Hash)]
 | |
| struct QuestListCategory {
 | |
|     list_order: usize,
 | |
|     description: String,
 | |
|     quests: Vec<QuestListEntry>,
 | |
| }
 | |
| 
 | |
| #[derive(Debug, Serialize, Deserialize)]
 | |
| struct QuestListConfig {
 | |
|     questlist: HashMap<String, Vec<QuestListEntry>>,
 | |
| }
 | |
| 
 | |
| #[derive(Error, Debug)]
 | |
| #[error("")]
 | |
| pub enum ParseDatError {
 | |
|     IoError(#[from] std::io::Error),
 | |
|     MapError(#[from] MapAreaError),
 | |
|     UnknownDatHeader(u32),
 | |
|     CouldNotDetermineEpisode,
 | |
|     InvalidMapAreaId(u16),
 | |
| }
 | |
| 
 | |
| const DAT_OBJECT_HEADER_ID: u32 = 1;
 | |
| const DAT_ENEMY_HEADER_ID: u32 = 2;
 | |
| const DAT_WAVE_HEADER_ID: u32 = 3;
 | |
| 
 | |
| enum DatBlock {
 | |
|     Object(Vec<Option<MapObject>>),
 | |
|     Enemy(Vec<Option<MapEnemy>>),
 | |
|     Wave,
 | |
| }
 | |
| 
 | |
| 
 | |
| fn read_dat_section_header<T: Read + Seek>(cursor: &mut T, episode: &Episode, map_areas: &MapAreaLookup) -> Result<DatBlock, ParseDatError> {
 | |
|     let header = cursor.read_u32::<LittleEndian>()?;
 | |
|     let _offset = cursor.read_u32::<LittleEndian>()?;
 | |
|     let area = cursor.read_u16::<LittleEndian>()?;
 | |
|     let _unknown1 = cursor.read_u16::<LittleEndian>()?;
 | |
|     let length = cursor.read_u32::<LittleEndian>()?;
 | |
| 
 | |
|     let map_area = map_areas.get_area_map(area).map_err(|_| ParseDatError::InvalidMapAreaId(area))?;
 | |
| 
 | |
|     match header {
 | |
|         DAT_OBJECT_HEADER_ID => {
 | |
|             let mut obj_data = vec![0u8; length as usize];
 | |
|             cursor.read_exact(&mut obj_data)?;
 | |
|             let mut obj_cursor = Cursor::new(obj_data);
 | |
| 
 | |
|             let objects = objects_from_stream(&mut obj_cursor, episode, &map_area);
 | |
|             Ok(DatBlock::Object(objects))
 | |
|         },
 | |
|         DAT_ENEMY_HEADER_ID => {
 | |
|             let mut enemy_data = vec![0u8; length as usize];
 | |
|             cursor.read_exact(&mut enemy_data)?;
 | |
|             let mut enemy_cursor = Cursor::new(enemy_data);
 | |
| 
 | |
|             let enemies = enemy_data_from_stream(&mut enemy_cursor, &map_area, episode);
 | |
| 
 | |
|             Ok(DatBlock::Enemy(enemies))
 | |
|         },
 | |
|         DAT_WAVE_HEADER_ID => {
 | |
|             cursor.seek(SeekFrom::Current(length as i64))?;
 | |
|             Ok(DatBlock::Wave)
 | |
|         },
 | |
|         _ => Err(ParseDatError::UnknownDatHeader(header))
 | |
|     }
 | |
| }
 | |
| 
 | |
| fn quest_episode(bin: &[u8]) -> Option<Episode> {
 | |
|     for bytes in bin.windows(3) {
 | |
|         // set_episode
 | |
|         if bytes[0] == 0xF8 && bytes[1] == 0xBC {
 | |
|             return Episode::from_quest(bytes[2]).ok()
 | |
|         }
 | |
|     }
 | |
|     None
 | |
| }
 | |
| 
 | |
| fn map_area_mappings(bin: &[u8]) -> MapAreaLookup {
 | |
|     let mut map_areas = MapAreaLookupBuilder::default();
 | |
|     for bytes in bin.windows(4) {
 | |
|         // BB_Map_Designate
 | |
|         if bytes[0] == 0xF9 && bytes[1] == 0x51 {
 | |
|             //return Some(Episode::from_quest(bytes[2]).ok()?)
 | |
|             let floor_value = bytes[2] as u16;
 | |
|             if let Some(map_area) = MapArea::from_bb_map_designate(bytes[3]) {
 | |
|                 map_areas = map_areas.add(floor_value, map_area);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     map_areas.build()
 | |
| }
 | |
| 
 | |
| #[allow(clippy::type_complexity)]
 | |
| fn parse_dat(dat: &[u8], episode: &Episode, map_areas: &MapAreaLookup) -> Result<(Vec<Option<MapEnemy>>, Vec<Option<MapObject>>), ParseDatError> {
 | |
|     let mut cursor = Cursor::new(dat);
 | |
| 
 | |
|     let header_iter = std::iter::from_fn(move || {
 | |
|         match read_dat_section_header(&mut cursor, episode, map_areas) {
 | |
|             Ok(dat_block) => Some(dat_block),
 | |
|             Err(err) => {
 | |
|                 warn!("unknown header in dat: {:?}", err);
 | |
|                 None
 | |
|             }
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     Ok(header_iter.fold((Vec::new(), Vec::new()), |(mut enemies, mut objects), dat_block| {
 | |
|         match dat_block {
 | |
|             DatBlock::Object(mut objs) => {
 | |
|                 objects.append(&mut objs)
 | |
|             },
 | |
|             DatBlock::Enemy(mut enemy) => {
 | |
|                 enemies.append(&mut enemy)
 | |
|             },
 | |
|             _ => {}
 | |
|         }
 | |
| 
 | |
|         (enemies, objects)
 | |
|     }))
 | |
| }
 | |
| 
 | |
| #[derive(Error, Debug)]
 | |
| pub enum QuestLoadError {
 | |
|     #[error("io error {0}")]
 | |
|     IoError(#[from] std::io::Error),
 | |
|     #[error("parse dat error {0}")]
 | |
|     ParseDatError(#[from] ParseDatError),
 | |
|     #[error("could not read metadata")]
 | |
|     CouldNotReadMetadata,
 | |
|     #[error("could not load config file")]
 | |
|     CouldNotLoadConfigFile,
 | |
| }
 | |
| 
 | |
| #[derive(Debug, Clone)]
 | |
| pub struct Quest {
 | |
|     pub name: String,
 | |
|     pub description: String,
 | |
|     pub full_description: String,
 | |
|     pub language: u16,
 | |
|     pub id: u16,
 | |
|     pub bin_blob: Arc<Vec<u8>>,
 | |
|     pub dat_blob: Arc<Vec<u8>>,
 | |
|     pub enemies: Vec<Option<MapEnemy>>, // TODO: Arc?
 | |
|     pub objects: Vec<Option<MapObject>>, // TODO: Arc?
 | |
|     pub map_areas: MapAreaLookup, // TODO: Arc?
 | |
| }
 | |
| 
 | |
| impl Quest {
 | |
|     fn from_bin_dat(bin: Vec<u8>, dat: Vec<u8>) -> Result<Quest, QuestLoadError> {
 | |
|         let id = u16::from_le_bytes(bin[16..18].try_into().map_err(|_| QuestLoadError::CouldNotReadMetadata)?);
 | |
|         let language = u16::from_le_bytes(bin[18..20].try_into().map_err(|_| QuestLoadError::CouldNotReadMetadata)?);
 | |
|         let name = array_to_utf16(&bin[24..88]);
 | |
|         let description = array_to_utf16(&bin[88..334]);
 | |
|         let full_description = array_to_utf16(&bin[334..920]);
 | |
| 
 | |
|         let episode = quest_episode(&bin).ok_or(ParseDatError::CouldNotDetermineEpisode)?;
 | |
|         let map_areas = map_area_mappings(&bin);
 | |
|         let (enemies, objects) = parse_dat(&dat, &episode, &map_areas)?;
 | |
| 
 | |
|         let mut prs_bin = LegacyPrsEncoder::new(Vec::new());
 | |
|         prs_bin.write_all(&bin)?;
 | |
|         let mut prs_dat = LegacyPrsEncoder::new(Vec::new());
 | |
|         prs_dat.write_all(&dat)?;
 | |
| 
 | |
|         Ok(Quest {
 | |
|             name,
 | |
|             description,
 | |
|             full_description,
 | |
|             id,
 | |
|             language,
 | |
|             bin_blob: Arc::new(prs_bin.into_inner().map_err(|_| QuestLoadError::CouldNotReadMetadata)?),
 | |
|             dat_blob: Arc::new(prs_dat.into_inner().map_err(|_| QuestLoadError::CouldNotReadMetadata)?),
 | |
|             enemies,
 | |
|             objects,
 | |
|             map_areas,
 | |
|         })
 | |
|     }
 | |
| }
 | |
| 
 | |
| // QuestCollection
 | |
| pub type QuestList = BTreeMap<QuestCategory, Vec<Quest>>;
 | |
| 
 | |
| pub fn load_quest(bin_path: PathBuf, dat_path: PathBuf, quest_path: PathBuf) -> Option<Quest> {
 | |
|     let dat_file = File::open(quest_path.join(dat_path.clone()))
 | |
|         .map_err(|err| {
 | |
|             warn!("could not load quest file {:?}: {:?}", dat_path, err)
 | |
|         }).ok()?;
 | |
|     let bin_file = File::open(quest_path.join(bin_path.clone()))
 | |
|         .map_err(|err| {
 | |
|             warn!("could not load quest file {:?}: {:?}", bin_path, err)
 | |
|         }).ok()?;
 | |
|     let mut dat_prs = LegacyPrsDecoder::new(dat_file);
 | |
|     let mut bin_prs = LegacyPrsDecoder::new(bin_file);
 | |
| 
 | |
|     let mut dat = Vec::new();
 | |
|     let mut bin = Vec::new();
 | |
|     dat_prs.read_to_end(&mut dat).ok()?;
 | |
|     bin_prs.read_to_end(&mut bin).ok()?;
 | |
| 
 | |
|     let quest = Quest::from_bin_dat(bin, dat).map_err(|err| {
 | |
|         warn!("could not parse quest file {:?}/{:?}: {:?}", bin_path, dat_path, err)
 | |
|     }).ok()?;
 | |
|     Some(quest)
 | |
| }
 | |
| 
 | |
| 
 | |
| pub fn load_quests_path(mut quest_path: PathBuf) -> Result<QuestList, QuestLoadError> {
 | |
|     let mut f = File::open(quest_path.clone()).map_err(|_| QuestLoadError::CouldNotLoadConfigFile)?;
 | |
|     let mut s = String::new();
 | |
|     f.read_to_string(&mut s)?;
 | |
|     quest_path.pop(); // remove quests.toml from the path
 | |
|     let mut used_quest_ids = BTreeSet::new();
 | |
|     let ql: BTreeMap<String, QuestListCategory> = toml::from_str(s.as_str()).map_err(|_| QuestLoadError::CouldNotLoadConfigFile)?;
 | |
| 
 | |
|     Ok(ql.into_iter().map(|(category, category_details)| {
 | |
|         (
 | |
|             QuestCategory {
 | |
|                 index: category_details.list_order,
 | |
|                 name: category,
 | |
|                 description: category_details.description,
 | |
|             },
 | |
|             category_details.quests
 | |
|                 .into_iter()
 | |
|                 .filter_map(|quest| {
 | |
|                     load_quest(quest.bin.into(), quest.dat.into(), quest_path.to_path_buf())
 | |
|                         .and_then(|quest | {
 | |
|                             if used_quest_ids.contains(&quest.id) {
 | |
|                                 warn!("quest id already exists: {}", quest.id);
 | |
|                                 return None;
 | |
|                             }
 | |
|                             used_quest_ids.insert(quest.id);
 | |
|                             Some(quest)
 | |
|                         })
 | |
|                 })
 | |
|                 .collect()
 | |
|         )
 | |
|     }).collect())
 | |
| }
 | |
| 
 | |
| pub fn load_standard_quests(mode: RoomMode) -> Result<QuestList, QuestLoadError> {
 | |
|     match mode {
 | |
|         RoomMode::Single {episode, difficulty } => {
 | |
|             load_quests_path(PathBuf::from_iter(["data", "quests", "bb", &episode.to_string(), "single", "quests.toml"]))
 | |
|         },
 | |
|         RoomMode::Multi {episode, difficulty } => {
 | |
|             load_quests_path(PathBuf::from_iter(["data", "quests", "bb", &episode.to_string(), "multi", "quests.toml"]))
 | |
|         },
 | |
|         _ => {
 | |
|             Ok(BTreeMap::new())
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| 
 | |
| pub fn load_government_quests(mode: RoomMode) -> Result<QuestList, QuestLoadError> {
 | |
|     match mode {
 | |
|         RoomMode::Single {episode, difficulty } => {
 | |
|             load_quests_path(PathBuf::from_iter(["data", "quests", "bb", &episode.to_string(), "government", "quests.toml"]))
 | |
|         },
 | |
|         RoomMode::Multi {episode, difficulty } => {
 | |
|             load_quests_path(PathBuf::from_iter(["data", "quests", "bb", &episode.to_string(), "government", "quests.toml"]))
 | |
|         },
 | |
|         _ => {
 | |
|             Ok(BTreeMap::new())
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| #[cfg(test)]
 | |
| mod tests {
 | |
|     use super::*;
 | |
| 
 | |
|     // the quest phantasmal world 4 uses the tower map twice, to do this it had to remap
 | |
|     // one of the other maps to be a second tower
 | |
|     #[test]
 | |
|     fn test_quest_with_remapped_floors() {
 | |
|         let pw4 = load_quest("q236-ext-bb.bin".into(), "q236-ext-bb.dat".into(), "data/quests/bb/ep2/multi".into()).unwrap();
 | |
|         let enemies_not_in_tower = pw4.enemies.iter()
 | |
|             .filter(|enemy| {
 | |
|                 enemy.is_some()
 | |
|             })
 | |
|             .filter(|enemy| {
 | |
|                 enemy.unwrap().map_area != MapArea::Tower
 | |
|             });
 | |
|         assert!(enemies_not_in_tower.count() == 0);
 | |
| 
 | |
|     }
 | |
| }
 |