#![warn(
anonymous_parameters,
nonstandard_style,
single_use_lifetimes,
trivial_casts,
trivial_numeric_casts,
unreachable_pub,
unused_extern_crates,
unused_qualifications,
variant_size_differences
)]
use bit::BitIndex;
use serde::{Deserialize, Serialize};
use std::fmt;
use attributes::Attributes;
use character::Character;
use npcs::Placeholder as NPCs;
use quests::Quests;
use skills::SkillPoints;
use waypoints::Waypoints;
pub mod attributes;
pub mod character;
pub mod format;
pub mod items;
pub mod npcs;
pub mod quests;
pub mod skills;
pub mod utils;
pub mod validation;
pub mod waypoints;
const CHECKSUM_START: usize = 12;
const CHECKSUM_END: usize = 16;
use crate::format::FormatId;
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)]
struct SaveMeta {
#[serde(default)]
pub format: FormatId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ExpansionType {
Classic,
#[default]
Expansion,
RotW,
}
impl ExpansionType {
pub fn label(self) -> &'static str {
match self {
ExpansionType::Classic => "Classic",
ExpansionType::Expansion => "Expansion",
ExpansionType::RotW => "RotW",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GameEdition {
D2RLegacy,
RotW,
}
impl GameEdition {
pub fn label(self) -> &'static str {
match self {
GameEdition::D2RLegacy => "D2R Legacy",
GameEdition::RotW => "RotW",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum IssueSeverity {
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum IssueKind {
TruncatedSection,
InvalidSignature,
UnsupportedVersion,
InvalidValue,
InconsistentLayout,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParseIssue {
pub severity: IssueSeverity,
pub kind: IssueKind,
pub section: Option<String>,
pub message: String,
pub offset: Option<usize>,
pub expected: Option<usize>,
pub found: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParseHardError {
pub message: String,
}
impl fmt::Display for ParseHardError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Parse hard error: {}", self.message)
}
}
impl std::error::Error for ParseHardError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedSave {
pub save: Save,
pub detected_format: FormatId,
pub decoded_layout: FormatId,
pub edition_hint: Option<GameEdition>,
pub issues: Vec<ParseIssue>,
pub header_checksum: Option<u32>,
pub computed_checksum: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SaveSummary {
pub version: Option<u32>,
pub format: Option<FormatId>,
pub edition: Option<GameEdition>,
pub expansion_type: Option<ExpansionType>,
pub name: Option<String>,
pub class: Option<Class>,
pub level: Option<u8>,
pub title: Option<String>,
pub issues: Vec<ParseIssue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Strictness {
Strict,
#[default]
Lax,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CompatibilityChecks {
#[default]
Enforce,
Ignore,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompatibilityCode {
WarlockRequiresRotW,
WarlockRequiresRotWExpansion,
RotWExpansionRequiresRotWEdition,
ExpansionClassRequiresExpansionMode,
UnknownClassRequiresKnownTarget,
MercenaryHireStateToggleUnsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompatibilityIssue {
pub code: CompatibilityCode,
pub blocking: bool,
pub message: String,
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
pub struct Save {
version: u32,
#[serde(default)]
expansion_type: ExpansionType,
pub character: Character,
pub quests: Quests,
pub waypoints: Waypoints,
pub npcs: npcs::Placeholder,
pub attributes: Attributes,
pub skills: SkillPoints,
pub items: items::Placeholder,
#[serde(default)]
meta: SaveMeta,
}
impl Default for Save {
fn default() -> Self {
let mut character = Character::default_class(Class::Amazon);
character.last_played = 0;
Save {
version: FormatId::V99.version(),
expansion_type: ExpansionType::Expansion,
character,
quests: Quests::default(),
waypoints: Waypoints::default(),
npcs: NPCs::default(),
attributes: Attributes::new_save_defaults(),
skills: SkillPoints::default(),
items: items::Placeholder::default(),
meta: SaveMeta { format: FormatId::V99 },
}
}
}
impl fmt::Display for Save {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut final_string = format!("Save:\nVersion: {0}\n", self.version);
final_string.push_str(&format!("Expansion Type: {}\n", self.expansion_type.label()));
final_string.push_str(&format!("Character:\n{0}\n", self.character));
final_string.push_str(&format!("Quests:\n{0}\n", self.quests));
final_string.push_str(&format!("Waypoints:\n{0}\n", self.waypoints));
final_string.push_str(&format!("Attributes:\n{0}\n", self.attributes));
final_string.push_str(&format!("Skills:\n{0}\n", self.skills));
write!(f, "{0}", final_string)
}
}
impl Save {
fn apply_expansion_type_for_format(&mut self, format: FormatId, expansion_type: ExpansionType) {
self.expansion_type = expansion_type;
if format == FormatId::V99 {
self.character
.set_legacy_expansion_flag(!matches!(expansion_type, ExpansionType::Classic));
} else if matches!(format, FormatId::V105 | FormatId::Unknown(_)) {
character::v105::set_expansion_type(&mut self.character, expansion_type);
}
}
pub fn new(format: FormatId, class: Class) -> Save {
let mut character = Character::default_class(class);
character.last_played = 0;
character.raw_section = Vec::new();
let expansion_type = match format.edition() {
Some(GameEdition::RotW) => ExpansionType::RotW,
Some(GameEdition::D2RLegacy) | None => ExpansionType::Expansion,
};
character.set_legacy_expansion_flag(!matches!(expansion_type, ExpansionType::Classic));
Save {
version: format.version(),
expansion_type,
character,
quests: Quests::default(),
waypoints: Waypoints::default(),
npcs: NPCs::default(),
attributes: Attributes::new_save_defaults(),
skills: SkillPoints::default(),
items: items::Placeholder::default(),
meta: SaveMeta { format },
}
}
pub fn format(&self) -> FormatId {
self.meta.format
}
pub fn version(&self) -> u32 {
self.version
}
pub fn set_format(&mut self, format: FormatId) {
self.meta.format = format;
self.version = format.version();
}
pub fn game_edition(&self) -> Option<GameEdition> {
self.format().edition()
}
pub fn set_level(&mut self, level: u8) {
self.character.set_level(level);
self.attributes.set_level(level);
}
pub fn expansion_type(&self) -> ExpansionType {
self.expansion_type
}
pub fn set_expansion_type(&mut self, expansion_type: ExpansionType) {
self.apply_expansion_type_for_format(self.format(), expansion_type);
}
pub(crate) fn set_expansion_type_for_format(
&mut self,
format: FormatId,
expansion_type: ExpansionType,
) {
self.apply_expansion_type_for_format(format, expansion_type);
}
pub fn parse(byte_slice: &[u8], strictness: Strictness) -> Result<ParsedSave, ParseHardError> {
format::decode(byte_slice, strictness)
}
pub fn summarize(
byte_slice: &[u8],
strictness: Strictness,
) -> Result<SaveSummary, ParseHardError> {
format::summarize(byte_slice, strictness)
}
pub fn encode_for(
&self,
format: FormatId,
compatibility_checks: CompatibilityChecks,
) -> Result<Vec<u8>, EncodeError> {
format::encode(self, format, compatibility_checks)
}
pub fn check_compatibility(&self, target: FormatId) -> Vec<CompatibilityIssue> {
format::compatibility_issues(self, target)
}
pub fn title_d2r(&self) -> Option<&'static str> {
self.character.title_d2r(self.expansion_type())
}
pub fn validate(&self) -> validation::ValidationReport {
validation::build_validation_report(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncodeError {
message: String,
}
impl EncodeError {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
impl fmt::Display for EncodeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Encoding error: {}", self.message)
}
}
impl std::error::Error for EncodeError {}
#[derive(PartialEq, Eq, Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum Difficulty {
#[default]
Normal,
Nightmare,
Hell,
}
impl fmt::Display for Difficulty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Difficulty::Normal => write!(f, "Normal"),
Difficulty::Nightmare => write!(f, "Nightmare"),
Difficulty::Hell => write!(f, "Hell"),
}
}
}
#[derive(PartialEq, Eq, Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum Act {
#[default]
Act1,
Act2,
Act3,
Act4,
Act5,
}
impl fmt::Display for Act {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Act::Act1 => write!(f, "Act I"),
Act::Act2 => write!(f, "Act II"),
Act::Act3 => write!(f, "Act III"),
Act::Act4 => write!(f, "Act IV"),
Act::Act5 => write!(f, "Act V"),
}
}
}
impl TryFrom<u8> for Act {
type Error = ParseHardError;
fn try_from(byte: u8) -> Result<Act, ParseHardError> {
let mut relevant_bits: u8 = 0;
relevant_bits.set_bit_range(0..3, byte.bit_range(0..3));
match relevant_bits {
0x00 => Ok(Act::Act1),
0x01 => Ok(Act::Act2),
0x02 => Ok(Act::Act3),
0x03 => Ok(Act::Act4),
0x04 => Ok(Act::Act5),
_ => Err(ParseHardError { message: format!("Found invalid act: {0:?}.", byte) }),
}
}
}
impl From<Act> for u8 {
fn from(act: Act) -> u8 {
match act {
Act::Act1 => 0x00,
Act::Act2 => 0x01,
Act::Act3 => 0x02,
Act::Act4 => 0x03,
Act::Act5 => 0x04,
}
}
}
#[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize)]
pub enum Class {
Amazon,
Sorceress,
Necromancer,
Paladin,
Barbarian,
Druid,
Assassin,
Warlock,
Unknown(u8),
}
impl Class {
pub const fn from_id(class_id: u8) -> Self {
match class_id {
0x00 => Class::Amazon,
0x01 => Class::Sorceress,
0x02 => Class::Necromancer,
0x03 => Class::Paladin,
0x04 => Class::Barbarian,
0x05 => Class::Druid,
0x06 => Class::Assassin,
0x07 => Class::Warlock,
_ => Class::Unknown(class_id),
}
}
pub const fn id(self) -> u8 {
match self {
Class::Amazon => 0x00,
Class::Sorceress => 0x01,
Class::Necromancer => 0x02,
Class::Paladin => 0x03,
Class::Barbarian => 0x04,
Class::Druid => 0x05,
Class::Assassin => 0x06,
Class::Warlock => 0x07,
Class::Unknown(class_id) => class_id,
}
}
}
impl From<u8> for Class {
fn from(class_id: u8) -> Self {
Class::from_id(class_id)
}
}
impl fmt::Display for Class {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let class: &'static str = match self {
Class::Amazon => "Amazon",
Class::Sorceress => "Sorceress",
Class::Necromancer => "Necromancer",
Class::Paladin => "Paladin",
Class::Barbarian => "Barbarian",
Class::Druid => "Druid",
Class::Assassin => "Assassin",
Class::Warlock => "Warlock",
Class::Unknown(_) => "Unknown",
};
match self {
Class::Unknown(class_id) => write!(f, "{0}({1})", class, class_id),
_ => write!(f, "{0}", class),
}
}
}
impl From<Class> for u8 {
fn from(class: Class) -> u8 {
class.id()
}
}
pub fn calc_checksum(bytes: &[u8]) -> i32 {
let mut checksum: i32 = 0;
for (i, byte) in bytes.iter().enumerate() {
let mut ch = *byte as i32;
if (CHECKSUM_START..CHECKSUM_END).contains(&i) {
ch = 0;
}
checksum = (checksum << 1) + ch + ((checksum < 0) as i32);
}
checksum
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_parse_save() {
let path: &Path = Path::new("assets/test/Joe.d2s");
let save_file: Vec<u8> = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) => panic!("File invalid: {e:?}"),
};
let _save = Save::parse(&save_file, Strictness::Lax).expect("save should parse");
}
#[test]
fn test_set_level_syncs_character_and_attributes() {
let mut save = Save::default();
save.set_level(75);
assert_eq!(save.character.level(), 75);
assert_eq!(save.attributes.level(), 75);
}
#[test]
fn new_save_sets_default_expansion_type_by_edition() {
let v99 = Save::new(FormatId::V99, Class::Amazon);
assert_eq!(v99.expansion_type(), ExpansionType::Expansion);
let v105 = Save::new(FormatId::V105, Class::Amazon);
assert_eq!(v105.expansion_type(), ExpansionType::RotW);
}
#[test]
fn game_edition_reflects_format() {
let mut save = Save::new(FormatId::V99, Class::Amazon);
assert_eq!(save.game_edition(), Some(GameEdition::D2RLegacy));
save.set_format(FormatId::V105);
assert_eq!(save.game_edition(), Some(GameEdition::RotW));
save.set_format(FormatId::Unknown(1234));
assert_eq!(save.game_edition(), None);
}
#[test]
fn save_title_uses_canonical_expansion_type() {
let mut save = Save::new(FormatId::V105, Class::Amazon);
save.set_expansion_type(ExpansionType::Classic);
save.character.set_legacy_expansion_flag(true);
save.character.progression = 4;
assert_eq!(save.title_d2r(), Some("Dame"));
}
#[test]
fn set_expansion_type_updates_v105_mode_marker_without_touching_status_bit() {
let mut save = Save::new(FormatId::V105, Class::Amazon);
save.character.set_legacy_expansion_flag(false);
save.set_expansion_type(ExpansionType::Classic);
assert_eq!(save.expansion_type(), ExpansionType::Classic);
assert!(!save.character.status().is_expansion());
assert_eq!(
character::v105::mode_marker(&save.character),
Some(character::v105::MODE_CLASSIC)
);
save.set_expansion_type(ExpansionType::Expansion);
assert_eq!(save.expansion_type(), ExpansionType::Expansion);
assert!(!save.character.status().is_expansion());
assert_eq!(
character::v105::mode_marker(&save.character),
Some(character::v105::MODE_EXPANSION)
);
save.set_expansion_type(ExpansionType::RotW);
assert_eq!(save.expansion_type(), ExpansionType::RotW);
assert!(!save.character.status().is_expansion());
assert_eq!(character::v105::mode_marker(&save.character), Some(character::v105::MODE_ROTW));
}
#[test]
fn set_expansion_type_updates_v99_status_bit() {
let mut save = Save::new(FormatId::V99, Class::Amazon);
save.character.set_legacy_expansion_flag(false);
save.set_expansion_type(ExpansionType::Expansion);
assert!(save.character.status().is_expansion());
save.set_expansion_type(ExpansionType::Classic);
assert!(!save.character.status().is_expansion());
}
}