diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index d04627d..9e85f00 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -200,6 +200,12 @@ pub struct OnContinueSBAChainEvent { pub actor_index: u32, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ItemGiveEvent { + pub item_id: u32, + pub count: i32, +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub enum Message { OnAreaEnter(AreaEnterEvent), @@ -210,4 +216,5 @@ pub enum Message { OnPerformSBA(OnPerformSBAEvent), OnContinueSBAChain(OnContinueSBAChainEvent), PlayerLoadEvent(PlayerLoadEvent), + ItemGiveEvent(ItemGiveEvent), } diff --git a/src-hook/src/hooks/item.rs b/src-hook/src/hooks/item.rs new file mode 100644 index 0000000..4c67650 --- /dev/null +++ b/src-hook/src/hooks/item.rs @@ -0,0 +1,61 @@ +use anyhow::{anyhow, Result}; + +use protocol::{ItemGiveEvent, Message}; +use retour::static_detour; + +use crate::{event, process::Process}; + +type OnGiveItemFunc = unsafe extern "system" fn(*const usize, u32, i32, u8) -> usize; + +static_detour! { + static OnGiveItem: unsafe extern "system" fn(*const usize, u32, i32, u8) -> usize; +} + +const ON_GIVE_ITEM_SIG: &str = "E8 $ { ' } 8B 17 4C 89 E1 E8"; + +#[derive(Clone)] +pub struct OnItemGiveHook { + tx: event::Tx, +} + +impl OnItemGiveHook { + pub fn new(tx: event::Tx) -> Self { + OnItemGiveHook { tx } + } + + pub fn setup(&self, process: &Process) -> Result<()> { + let cloned_self = self.clone(); + + if let Ok(on_item_give) = process.search_address(ON_GIVE_ITEM_SIG) { + #[cfg(feature = "console")] + println!("Found on item give"); + + unsafe { + let func: OnGiveItemFunc = std::mem::transmute(on_item_give); + OnGiveItem + .initialize(func, move |a1, a2, a3, a4| cloned_self.run(a1, a2, a3, a4))?; + OnGiveItem.enable()?; + } + } else { + return Err(anyhow!("Could not find on_item_give")); + } + + Ok(()) + } + + fn run(&self, a1: *const usize, item_id: u32, count: i32, flag: u8) -> usize { + #[cfg(feature = "console")] + println!( + "on item give, item_id={:x}, count={}, flag={}", + item_id, count, flag + ); + + let ret = unsafe { OnGiveItem.call(a1, item_id, count, flag) }; + + let _ = self + .tx + .send(Message::ItemGiveEvent(ItemGiveEvent { item_id, count })); + + ret + } +} diff --git a/src-hook/src/hooks/mod.rs b/src-hook/src/hooks/mod.rs index ba7074a..f0c8f66 100644 --- a/src-hook/src/hooks/mod.rs +++ b/src-hook/src/hooks/mod.rs @@ -5,6 +5,7 @@ use crate::{event, process::Process}; use self::{ area::OnAreaEnterHook, damage::{OnProcessDamageHook, OnProcessDotHook}, + item::OnItemGiveHook, player::OnLoadPlayerHook, quest::{OnLoadQuestHook, OnQuestCompleteHook}, sba::{ @@ -17,6 +18,7 @@ mod area; mod damage; mod ffi; mod globals; +mod item; mod player; mod quest; mod sba; @@ -47,6 +49,9 @@ pub fn setup_hooks(tx: event::Tx) -> Result<()> { OnCheckSBACollisionHook::new(tx.clone()).setup(&process)?; OnContinueSBAChainHook::new(tx.clone()).setup(&process)?; + /* Item Drops */ + OnItemGiveHook::new(tx.clone()).setup(&process)?; + Ok(()) } diff --git a/src-tauri/lang/en/ui.json b/src-tauri/lang/en/ui.json index 10a79c2..9f20ff8 100644 --- a/src-tauri/lang/en/ui.json +++ b/src-tauri/lang/en/ui.json @@ -86,7 +86,8 @@ "sba-chart": "Skybound Arts Gauge", "quest-status": "Status", "overview": "Overview", - "equipment": "Equipment" + "equipment": "Equipment", + "item-drops": "Item Drops" } }, "characters": { diff --git a/src-tauri/src/db/logs.rs b/src-tauri/src/db/logs.rs index a431b45..5199771 100644 --- a/src-tauri/src/db/logs.rs +++ b/src-tauri/src/db/logs.rs @@ -174,7 +174,7 @@ pub fn get_logs( }) .collect::>>(); - return Ok(rows.unwrap_or(vec![])); + Ok(rows.unwrap_or(vec![])) } pub fn get_logs_count( @@ -214,5 +214,5 @@ pub fn get_logs_count( let row: i32 = stmt.query_row(&*params, |r| r.get(0))?; - return Ok(row); + Ok(row) } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f33345c..4782cf7 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -36,6 +36,15 @@ pub fn setup_db() -> Result<()> { M::up("ALTER TABLE logs ADD COLUMN quest_id INTEGER"), M::up("ALTER TABLE logs ADD COLUMN quest_elapsed_time INTEGER"), M::up("ALTER TABLE logs ADD COLUMN quest_completed BOOLEAN"), + M::up( + r#"CREATE TABLE IF NOT EXISTS item_drops ( + id INTEGER PRIMARY KEY, + log_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + item_count INTEGER NOT NULL, + time INTEGER NOT NULL + )"#, + ), ]); info!("Database found, running migrations.."); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8187cc4..316fc8b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -403,7 +403,7 @@ fn connect_and_run_parser(app: AppHandle) { while let Some(Ok(msg)) = reader.next().await { // Handle EOF when the game closes. - if msg.len() == 0 { + if msg.is_empty() { break; } @@ -439,6 +439,9 @@ fn connect_and_run_parser(app: AppHandle) { protocol::Message::OnContinueSBAChain(event) => { state.on_continue_sba_chain(event) } + protocol::Message::ItemGiveEvent(event) => { + state.on_item_give_event(event); + } } } } diff --git a/src-tauri/src/parser/v1/mod.rs b/src-tauri/src/parser/v1/mod.rs index f705112..6174328 100644 --- a/src-tauri/src/parser/v1/mod.rs +++ b/src-tauri/src/parser/v1/mod.rs @@ -3,8 +3,9 @@ use std::{collections::HashMap, io::BufReader}; use anyhow::Result; use chrono::Utc; use protocol::{ - AreaEnterEvent, DamageEvent, Message, OnAttemptSBAEvent, OnContinueSBAChainEvent, - OnPerformSBAEvent, OnUpdateSBAEvent, PlayerLoadEvent, QuestCompleteEvent, + AreaEnterEvent, DamageEvent, ItemGiveEvent, Message, OnAttemptSBAEvent, + OnContinueSBAChainEvent, OnPerformSBAEvent, OnUpdateSBAEvent, PlayerLoadEvent, + QuestCompleteEvent, }; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; @@ -203,6 +204,13 @@ impl EnemyState { } } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemDrop { + item_id: u32, + count: i32, +} + /// The necessary details of an encounter that can be used to recreate the state at any point in time. #[derive(Debug, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -210,6 +218,7 @@ pub struct Encounter { pub player_data: [Option; 4], pub quest_id: Option, pub quest_timer: Option, + #[serde(default)] pub quest_completed: bool, @@ -293,6 +302,8 @@ pub struct DerivedEncounterState { pub party: HashMap, /// Derived target stats, damage done to each target. targets: HashMap, + /// Derived item drops + pub item_drops: Vec, } impl Default for DerivedEncounterState { @@ -305,6 +316,7 @@ impl Default for DerivedEncounterState { status: ParserStatus::Waiting, party: HashMap::new(), targets: HashMap::new(), + item_drops: Vec::new(), } } } @@ -371,6 +383,26 @@ impl DerivedEncounterState { player.update_dps(now, self.start_time); } } + + fn process_item_event(&mut self, event: &ItemGiveEvent) { + // @TODO(false): Negative item counts are counted as item usage, can track potion usage. + if event.count > 0 { + // Find and update the item drop count if it exists, otherwise add a new item drop. + let existing_item = self + .item_drops + .iter_mut() + .find(|item| item.item_id == event.item_id); + + if let Some(existing_item) = existing_item { + existing_item.count += event.count; + } else { + self.item_drops.push(ItemDrop { + item_id: event.item_id, + count: event.count, + }); + } + } + } } /// The parser for the encounter. @@ -445,6 +477,9 @@ impl Parser { Message::DamageEvent(event) => { self.derived_state.process_damage_event(*timestamp, event); } + Message::ItemGiveEvent(event) => { + self.derived_state.process_item_event(event); + } _ => { self.derived_state.end_time = *timestamp; } @@ -468,6 +503,9 @@ impl Parser { self.derived_state.process_damage_event(*timestamp, event); } } + Message::ItemGiveEvent(event) => { + self.derived_state.process_item_event(event); + } _ => { self.derived_state.end_time = *timestamp; } @@ -487,7 +525,11 @@ impl Parser { let mut last_event_timestamp = start_time; - for (timestamp, event) in self.encounter.event_log() { + for (timestamp, event) in self + .encounter + .event_log() + .filter(|(_, event)| !matches!(event, Message::ItemGiveEvent(_))) + { let last_index = ((last_event_timestamp - start_time) / interval) as usize; let index = ((timestamp - start_time) / interval) as usize; @@ -570,29 +612,6 @@ impl Parser { self.encounter.quest_id = Some(event.quest_id); self.encounter.quest_timer = Some(event.elapsed_time_in_secs); self.encounter.quest_completed = true; - - if self.status == ParserStatus::InProgress { - self.update_status(ParserStatus::Stopped); - - if self.has_damage() { - match self.save_encounter_to_db() { - Ok(id) => { - if let Some(window) = &self.window_handle { - let _ = window.emit("encounter-saved", id); - } - } - Err(e) => { - if let Some(window) = &self.window_handle { - let _ = window.emit("encounter-saved-error", e.to_string()); - } - } - } - } - - if let Some(window) = &self.window_handle { - let _ = window.emit("encounter-update", &self.derived_state); - } - } } // Called when a damage event is received from the game. @@ -855,11 +874,27 @@ impl Parser { let id = conn.last_insert_rowid(); + for item_drop in self.derived_state.item_drops.iter() { + conn.execute( + r#"INSERT INTO item_drops (log_id, item_id, item_count, time) VALUES (?, ?, ?, ?)"#, + params![id, item_drop.item_id, item_drop.count, start_datetime.timestamp_millis()], + )?; + } + return Ok(Some(id)); } Ok(None) } + + pub fn on_item_give_event(&mut self, event: ItemGiveEvent) { + self.encounter.push_event( + Utc::now().timestamp_millis(), + Message::ItemGiveEvent(event.clone()), + ); + + self.derived_state.process_item_event(&event); + } } /// Converts a v0 parser into a v1 parser, but does not reparse the encounter. diff --git a/src-tauri/src/parser/v1/player_state.rs b/src-tauri/src/parser/v1/player_state.rs index 1a7f743..c219cb3 100644 --- a/src-tauri/src/parser/v1/player_state.rs +++ b/src-tauri/src/parser/v1/player_state.rs @@ -49,8 +49,10 @@ impl PlayerState { { self.last_known_pet_skill = Some(event.action_id); } + const PET_NORMAL: ActionType = ActionType::Normal(FerrySkillId::PetNormal as u32); - let action = if is_ferry_pet_normal { + + if is_ferry_pet_normal { // Note technically the pet portion of Onslaught will count as a Pet normal, but I think that's fine since // it does exactly as much as a pet normal. Could consider adding Onslaught (pet) as a separate category PET_NORMAL @@ -61,8 +63,7 @@ impl PlayerState { } } else { event.action_id - }; - return action; + } } pub fn update_from_damage_event(&mut self, event: &DamageEvent) { diff --git a/src/pages/logs/View.tsx b/src/pages/logs/View.tsx index 0fc76f8..5b1a83c 100644 --- a/src/pages/logs/View.tsx +++ b/src/pages/logs/View.tsx @@ -391,7 +391,7 @@ export const ViewPage = () => { - + {questId && ( @@ -446,7 +446,7 @@ export const ViewPage = () => { - + @@ -457,6 +457,9 @@ export const ViewPage = () => { {t("ui.logs.equipment")} + + {t("ui.logs.item-drops")} + @@ -723,6 +726,26 @@ export const ViewPage = () => { + + + + + + {t("ui.logs.item-drops")} + + + + {encounter.itemDrops.map((itemDrop) => ( + + + {translateItemId(itemDrop.itemId)} (x{itemDrop.count}) + + + ))} + +
+
+
diff --git a/src/pages/useMeter.ts b/src/pages/useMeter.ts index 1f7a111..4d2daa9 100644 --- a/src/pages/useMeter.ts +++ b/src/pages/useMeter.ts @@ -22,6 +22,7 @@ const DEFAULT_ENCOUNTER_STATE: EncounterState = { endTime: 1, party: {}, targets: {}, + itemDrops: [], status: "Waiting", }; diff --git a/src/stores/useEncounterStore.ts b/src/stores/useEncounterStore.ts index ee9c669..6beeb2f 100644 --- a/src/stores/useEncounterStore.ts +++ b/src/stores/useEncounterStore.ts @@ -49,6 +49,8 @@ export const useEncounterStore = create((set) => ({ loadFromResponse: (response: EncounterStateResponse) => { const filteredPlayers = response.players.filter((player) => player !== null); + console.log(response.encounterState); + set({ encounterState: response.encounterState, dpsChart: response.dpsChart, diff --git a/src/types.ts b/src/types.ts index 94b23eb..790025e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,11 @@ export type EnemyState = { export type EncounterStatus = "Waiting" | "InProgress" | "Stopped"; +export type ItemDrop = { + itemId: number; + count: number; +}; + export type EncounterState = { /** Total damage dealt in the whole encounter */ totalDamage: number; @@ -124,6 +129,8 @@ export type EncounterState = { status: EncounterStatus; /** Targets for this encounter */ targets: Record; + /** Item drops for this encounter */ + itemDrops: ItemDrop[]; }; export type EncounterUpdateEvent = {