From b8799eb7b0870c92969ea4296ee9df4a5232a9d9 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:30:58 +0200 Subject: [PATCH 1/5] Combatevents --- include/FrameClock.h | 9 ++ include/py_combat_events.h | 82 +++++++++++- src/FrameClock.cpp | 10 ++ src/Py4GW.cpp | 32 +++-- src/py_combat_events.cpp | 264 ++++++++++++++++++++++++++++++------- 5 files changed, 327 insertions(+), 70 deletions(-) create mode 100644 include/FrameClock.h create mode 100644 src/FrameClock.cpp diff --git a/include/FrameClock.h b/include/FrameClock.h new file mode 100644 index 0000000..f62e6d6 --- /dev/null +++ b/include/FrameClock.h @@ -0,0 +1,9 @@ +#pragma once +#include + +namespace frame_clock { + // Returns the per-frame monotonically-increasing timestamp set at the + // top of Py4GW::Draw() before SHMem publish. Use this instead of + // GetTickCount64() in any code that needs to bucket events by frame. + uint64_t GetFrameTimestamp(); +} diff --git a/include/py_combat_events.h b/include/py_combat_events.h index 22d5a7c..3b41e36 100644 --- a/include/py_combat_events.h +++ b/include/py_combat_events.h @@ -66,22 +66,53 @@ namespace py = pybind11; * * Field meanings vary by event_type: * - Skill events: agent_id=caster, value=skill_id, target_id=target - * - Damage events: agent_id=target, target_id=source, float_value=damage + * - Damage events: agent_id=target, target_id=source, float_value=damage% * - Effect events: agent_id=affected agent, value=effect_id - * - Knockdown: agent_id=knocked agent, float_value=duration + * - Knockdown: agent_id=knocked agent, float_value=duration_sec + * - CURRENT_HEALTH/CURRENT_ENERGY: agent_id=agent, + * float_value = current value as fraction of max (0.0-1.0) + * - Regen change: agent_id=agent, float_value=pips/sec signed (+gain/-degen) + * - REACHED_MAXHP: agent_id=agent, value=0 (signal: HP returned to full) + * + * agent_max_hp / agent_max_energy / target_max_hp / target_max_energy carry + * the snapshotted max stats for the involved agent(s) so Python can convert + * damage% or HP% to absolute values without re-reading the agent table. */ struct RawCombatEvent { - uint32_t timestamp; // GetTickCount() when event occurred + uint32_t timestamp; // Frame-aligned tick (frame_clock::GetFrameTimestamp) when event occurred uint32_t event_type; // GenericValueID or custom event type uint32_t agent_id; // Primary agent (caster/attacker/target depending on event) uint32_t value; // Skill ID, effect ID, or other uint value uint32_t target_id; // Secondary agent (target of skill/attack) float float_value; // Duration, damage amount, energy, etc. - RawCombatEvent() : timestamp(0), event_type(0), agent_id(0), value(0), target_id(0), float_value(0.0f) {} + // Enqueue-time max-HP / max-energy snapshots taken inside the packet + // handler while the agent is provably alive on the game thread. Lets + // Python convert fractions to absolute values without ever calling + // Agent.GetMaxHealth(id) on a possibly-freed agent. Default to 0 when + // lookup fails or no target. + uint32_t agent_max_hp; + uint32_t agent_max_energy; + uint32_t target_max_hp; + uint32_t target_max_energy; + + RawCombatEvent() + : timestamp(0), event_type(0), agent_id(0), value(0), target_id(0), float_value(0.0f), + agent_max_hp(0), agent_max_energy(0), + target_max_hp(0), target_max_energy(0) {} RawCombatEvent(uint32_t ts, uint32_t type, uint32_t agent, uint32_t val, uint32_t target, float fval) - : timestamp(ts), event_type(type), agent_id(agent), value(val), target_id(target), float_value(fval) {} + : timestamp(ts), event_type(type), agent_id(agent), value(val), target_id(target), float_value(fval), + agent_max_hp(0), agent_max_energy(0), + target_max_hp(0), target_max_energy(0) {} +}; + +// Snapshot of stable agent fields read inside a packet handler. +struct AgentSnapshot { + uint32_t max_hp; + uint32_t max_energy; + + AgentSnapshot() : max_hp(0), max_energy(0) {} }; // ============================================================================ @@ -168,6 +199,28 @@ enum class CombatEventType : uint32_t { HEALING = 33, // Healing or positive armor-ignoring gain // agent_id=target, target_id=source, float_value=heal% + // ---- Agent Stat Events ---- + // HP/energy fractions, regen-rate changes, and the REACHED_MAXHP signal. + // CURRENT_HEALTH/CURRENT_ENERGY/REGEN arrive via GenericFloat. + // REACHED_MAXHP arrives via GenericValue. + + CURRENT_HEALTH = 34, // Agent's current HP as fraction of max (GWCA: health) + // agent_id=agent, float_value=hp_fraction (0.0-1.0) + // Fires on visibility/engagement resync. + + CURRENT_ENERGY = 35, // Agent's current energy as fraction of max (value_id=33) + // agent_id=agent, float_value=energy_fraction (0.0-1.0) + // Fires on visibility/engagement resync. + + HEALTH_REGEN_CHANGE = 36, // HP regen rate change (GWCA: change_health_regen) + // float_value = pips/sec, signed (+gain, -degen) + + ENERGY_REGEN_CHANGE = 37, // Energy regen rate change (no GWCA label; value_id=43) + // float_value = pips/sec, signed (+gain, -drain) + + REACHED_MAXHP = 38, // Fires when agent HP returns to full (GWCA: max_hp_reached) + // agent_id=agent, value=0 - signal event + // ---- Effect Events (from GenericValue/GenericValueTarget) ---- EFFECT_APPLIED = 40, // Visual effect applied (internal effect_id, not skill_id!) @@ -210,8 +263,21 @@ enum class CombatEventType : uint32_t { SKILL_RECHARGE = 80, // Skill went on cooldown // agent_id=agent, value=skill_id, float_value=recharge time in ms - SKILL_RECHARGED = 81 // Skill came off cooldown + SKILL_RECHARGED = 81, // Skill came off cooldown // agent_id=agent, value=skill_id + + // ---- Agent Presence Events (from AgentAdd / AgentRemove packets) ---- + // Fire when an agent enters or leaves the client's tracking range. + // AgentRemove can also signal death or disconnect when AgentState.dead bit + // doesn't arrive separately - treat as "agent no longer tracked". + + AGENT_ADDED = 90, // Agent became visible / entered tracking range + // agent_id=agent, value=agent_type (bitmask: + // 0x30000000=Player, 0x20000000=NPC, 0x00000000=Signpost) + // target_id=allegiance_bits, float_value=speed + + AGENT_REMOVED = 91 // Agent left tracking range (out-of-range, died, or DC'd) + // agent_id=agent }; // Helper function to convert enum to uint32_t for backwards compatibility @@ -305,6 +371,8 @@ class CombatEventQueue { GW::HookEntry skill_activate_entry; // SkillActivate packets (early skill notification) GW::HookEntry skill_recharge_entry; // SkillRecharge packets (skill went on cooldown) GW::HookEntry skill_recharged_entry; // SkillRecharged packets (skill came off cooldown) + GW::HookEntry agent_add_entry; // AgentAdd packets (agent entered tracking range) + GW::HookEntry agent_remove_entry; // AgentRemove packets (agent left tracking range) // Thread-safe event queue mutable std::mutex queue_mutex; @@ -322,6 +390,8 @@ class CombatEventQueue { void OnGenericModifier(GW::Packet::StoC::GenericModifier* packet); void OnSkillRecharge(GW::Packet::StoC::SkillRecharge* packet); void OnSkillRecharged(GW::Packet::StoC::SkillRecharged* packet); + void OnAgentAdd(GW::Packet::StoC::AgentAdd* packet); + void OnAgentRemove(GW::Packet::StoC::AgentRemove* packet); /** * @brief Add event to queue (thread-safe). diff --git a/src/FrameClock.cpp b/src/FrameClock.cpp new file mode 100644 index 0000000..53de218 --- /dev/null +++ b/src/FrameClock.cpp @@ -0,0 +1,10 @@ +#include "FrameClock.h" +#include + +namespace frame_clock { + extern uint64_t g_frame_id_timestamp; // defined in Py4GW.cpp + + uint64_t GetFrameTimestamp() { + return g_frame_id_timestamp ? g_frame_id_timestamp : GetTickCount64(); + } +} diff --git a/src/Py4GW.cpp b/src/Py4GW.cpp index 5be9bb6..2771301 100644 --- a/src/Py4GW.cpp +++ b/src/Py4GW.cpp @@ -1,7 +1,9 @@ #include "Py4GW.h" #include "Headers.h" +#include "FrameClock.h" #include "py_dialog.h" +#include "name_obfuscation.h" #include //HeroAI* Py4GW::heroAI = nullptr; @@ -326,7 +328,7 @@ class profiler { }; static profiler py4gw_profiler; -static uint64_t frame_id_timestamp; +namespace frame_clock { uint64_t g_frame_id_timestamp; } static std::vector GetProfilerMetricNames() { return profiler::GetMetricNames(); @@ -654,7 +656,7 @@ bool ChangeWorkingDirectory(const std::string& new_directory) { } static uint64_t Get_Tick_Count64() { - return frame_id_timestamp ? frame_id_timestamp : GetTickCount64(); + return frame_clock::GetFrameTimestamp(); } HWND Py4GW::get_gw_window_handle() { return GW::MemoryMgr::GetGWWindowHandle(); @@ -923,7 +925,7 @@ void ExecutePythonScript_Update() if (update_function && !update_function.is_none()) { py4gw_profiler.start("Update.Console.Update"); CallPythonFunctionSafe(update_function, "update()", script_state); - py4gw_profiler.end(frame_id_timestamp, "Update.Console.Update"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Update.Console.Update"); //return; } @@ -937,7 +939,7 @@ void ExecutePythonScript_Draw() if (draw_function && !draw_function.is_none()) { py4gw_profiler.start("Draw.Console.Draw"); CallPythonFunctionSafe(draw_function, "draw()", script_state); - py4gw_profiler.end(frame_id_timestamp, "Draw.Console.Draw"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Draw.Console.Draw"); //return; } @@ -945,7 +947,7 @@ void ExecutePythonScript_Draw() if (main_function && !main_function.is_none()) { py4gw_profiler.start("Draw.Console.Main"); CallPythonFunctionSafe(main_function, "main()", script_state); - py4gw_profiler.end(frame_id_timestamp, "Draw.Console.Main"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Draw.Console.Main"); } } @@ -954,7 +956,7 @@ void ExecutePythonScript2_Update() if (update_function2 && !update_function2.is_none()) { py4gw_profiler.start("Update.WidgetManager.Update"); CallPythonFunctionSafe(update_function2, "update2()", script_state2); - py4gw_profiler.end(frame_id_timestamp, "Update.WidgetManager.Update"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Update.WidgetManager.Update"); } } @@ -963,14 +965,14 @@ void ExecutePythonScript2_Draw() if (draw_function2 && !draw_function2.is_none()) { py4gw_profiler.start("Draw.WidgetManager.Draw"); CallPythonFunctionSafe(draw_function2, "draw2()", script_state2); - py4gw_profiler.end(frame_id_timestamp, "Draw.WidgetManager.Draw"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Draw.WidgetManager.Draw"); //return; } if (main_function2 && !main_function2.is_none()) { py4gw_profiler.start("Draw.WidgetManager.Main"); CallPythonFunctionSafe(main_function2, "main2()", script_state2); - py4gw_profiler.end(frame_id_timestamp, "Draw.WidgetManager.Main"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Draw.WidgetManager.Main"); } } @@ -1360,7 +1362,7 @@ class PyCallback { } // ---- PROFILING STOP ---- - py4gw_profiler.end(frame_id_timestamp, full_prof_name.c_str()); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, full_prof_name.c_str()); } } @@ -1891,6 +1893,7 @@ bool Py4GW::Initialize() { py::initialize_interpreter(); InitializeMerchantCallbacks(); Dialog::Initialize(); + NameObfuscation::Instance().Init(); if (!g_runtime_shared_memory.IsValid()) { g_runtime_shared_memory.CreateRuntimeRegion(GetRuntimeSharedMemoryNameW()); @@ -1904,6 +1907,7 @@ bool Py4GW::Initialize() { void Py4GW::Terminate() { Dialog::Terminate(); + NameObfuscation::Instance().Shutdown(); g_runtime_shared_memory.Destroy(); GW::DisableHooks(); GW::Terminate(); @@ -1951,18 +1955,18 @@ void Py4GW::Update() if (script_state == ScriptState::Running && !script_content.empty()) { py4gw_profiler.start("Update.Console"); ExecutePythonScript_Update(); - py4gw_profiler.end(frame_id_timestamp, "Update.Console"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Update.Console"); } if (script_state2 == ScriptState::Running && !script_content2.empty()) { py4gw_profiler.start("Update.WidgetManager"); ExecutePythonScript2_Update(); - py4gw_profiler.end(frame_id_timestamp, "Update.WidgetManager"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Update.WidgetManager"); } } void Py4GW::Draw(IDirect3DDevice9* device) { - frame_id_timestamp = GetTickCount64(); + frame_clock::g_frame_id_timestamp = GetTickCount64(); if (g_runtime_shared_memory.IsValid()) { @@ -2177,7 +2181,7 @@ void Py4GW::Draw(IDirect3DDevice9* device) { mixed_deferred.action(); } - py4gw_profiler.end(frame_id_timestamp, "Draw.Deferred.Draw"); + py4gw_profiler.end(frame_clock::g_frame_id_timestamp, "Draw.Deferred.Draw"); mixed_deferred.active = false; } @@ -2358,7 +2362,7 @@ PYBIND11_EMBEDDED_MODULE(Py4GW, m) py::module_ console = m.def_submodule("Console", "Submodule for console logging"); py::module_ game = m.def_submodule("Game", "Submodule for game functions"); py::module_ ui = m.def_submodule("UI", "Submodule for schema-driven UI"); - + bind_Game(game); bind_Console(console); bind_UI(ui); diff --git a/src/py_combat_events.cpp b/src/py_combat_events.cpp index 8e264a3..0f7ec1b 100644 --- a/src/py_combat_events.cpp +++ b/src/py_combat_events.cpp @@ -34,9 +34,46 @@ */ #include "py_combat_events.h" +#include "FrameClock.h" + +#include +#include namespace py = pybind11; +// Snapshot a live agent's max_hp/max_energy inside a packet handler. Safe +// because StoC dispatch runs on the game thread with the agent table held +// stable. Returns zero-initialized snapshot if agent_id=0, agent not found, +// or not living. +static AgentSnapshot SnapshotAgentLiving(uint32_t agent_id) { + AgentSnapshot snap; + if (!agent_id) return snap; + GW::Agent* agent = GW::Agents::GetAgentByID(agent_id); + if (!agent) return snap; + if (!agent->GetIsLivingType()) return snap; + GW::AgentLiving* living = agent->GetAsAgentLiving(); + if (!living) return snap; + snap.max_hp = static_cast(living->max_hp); + snap.max_energy = static_cast(living->max_energy); + return snap; +} + +// Build a RawCombatEvent with agent_id and target_id snapshots populated. +// Centralises the snapshot logic so every PushEvent call site picks it up. +static RawCombatEvent MakeEvent(uint32_t ts, uint32_t type, uint32_t agent, + uint32_t val, uint32_t target, float fval) { + RawCombatEvent e(ts, type, agent, val, target, fval); + AgentSnapshot a_snap = SnapshotAgentLiving(agent); + e.agent_max_hp = a_snap.max_hp; + e.agent_max_energy = a_snap.max_energy; + if (target) { + AgentSnapshot t_snap = SnapshotAgentLiving(target); + e.target_max_hp = t_snap.max_hp; + e.target_max_energy = t_snap.max_energy; + } + return e; +} + // Backwards compatibility: create namespace with constexpr aliases to enum class values // This allows existing code using CombatEventTypes::FOO to continue working namespace CombatEventTypes { @@ -58,6 +95,11 @@ namespace CombatEventTypes { constexpr uint32_t CRITICAL = to_uint(CombatEventType::CRITICAL); constexpr uint32_t ARMOR_IGNORING = to_uint(CombatEventType::ARMOR_IGNORING); constexpr uint32_t HEALING = to_uint(CombatEventType::HEALING); + constexpr uint32_t CURRENT_HEALTH = to_uint(CombatEventType::CURRENT_HEALTH); + constexpr uint32_t CURRENT_ENERGY = to_uint(CombatEventType::CURRENT_ENERGY); + constexpr uint32_t HEALTH_REGEN_CHANGE = to_uint(CombatEventType::HEALTH_REGEN_CHANGE); + constexpr uint32_t ENERGY_REGEN_CHANGE = to_uint(CombatEventType::ENERGY_REGEN_CHANGE); + constexpr uint32_t REACHED_MAXHP = to_uint(CombatEventType::REACHED_MAXHP); constexpr uint32_t EFFECT_APPLIED = to_uint(CombatEventType::EFFECT_APPLIED); constexpr uint32_t EFFECT_REMOVED = to_uint(CombatEventType::EFFECT_REMOVED); constexpr uint32_t EFFECT_ON_TARGET = to_uint(CombatEventType::EFFECT_ON_TARGET); @@ -68,6 +110,8 @@ namespace CombatEventTypes { constexpr uint32_t SKILL_ACTIVATE_PACKET = to_uint(CombatEventType::SKILL_ACTIVATE_PACKET); constexpr uint32_t SKILL_RECHARGE = to_uint(CombatEventType::SKILL_RECHARGE); constexpr uint32_t SKILL_RECHARGED = to_uint(CombatEventType::SKILL_RECHARGED); + constexpr uint32_t AGENT_ADDED = to_uint(CombatEventType::AGENT_ADDED); + constexpr uint32_t AGENT_REMOVED = to_uint(CombatEventType::AGENT_REMOVED); } // ============================================================================ @@ -75,8 +119,6 @@ namespace CombatEventTypes { // ============================================================================ void CombatEventQueue::Initialize() { - is_initialized = true; //disabled for now, we can re-enable after more testing - if (is_initialized) return; // SkillActivate packet - gives us skill_id before GenericValue arrives @@ -108,7 +150,8 @@ void CombatEventQueue::Initialize() { ); // GenericFloat packet - events with float values - // Used for: cast time, knockdown duration, energy spent + // Used for: cast time, knockdown duration, energy spent, + // HP/energy "snapshots" (opaque - see note), and regen-rate changes GW::StoC::RegisterPacketCallback( &generic_float_entry, [this](GW::HookStatus*, GW::Packet::StoC::GenericFloat* packet) { @@ -145,6 +188,24 @@ void CombatEventQueue::Initialize() { } ); + // AgentAdd packet - agent entered the client's tracking range + // Contains: agent_id, agent_type bitmask, position, speed, allegiance_bits, etc. + GW::StoC::RegisterPacketCallback( + &agent_add_entry, + [this](GW::HookStatus*, GW::Packet::StoC::AgentAdd* packet) { + OnAgentAdd(packet); + } + ); + + // AgentRemove packet - agent left tracking range (out of sight, dead, or DC'd) + // Contains: agent_id + GW::StoC::RegisterPacketCallback( + &agent_remove_entry, + [this](GW::HookStatus*, GW::Packet::StoC::AgentRemove* packet) { + OnAgentRemove(packet); + } + ); + is_initialized = true; } @@ -158,6 +219,8 @@ void CombatEventQueue::Terminate() { GW::StoC::RemoveCallback(GW::Packet::StoC::GenericModifier::STATIC_HEADER, &generic_modifier_entry); GW::StoC::RemoveCallback(GW::Packet::StoC::SkillRecharge::STATIC_HEADER, &skill_recharge_entry); GW::StoC::RemoveCallback(GW::Packet::StoC::SkillRecharged::STATIC_HEADER, &skill_recharged_entry); + GW::StoC::RemoveCallback(GW::Packet::StoC::AgentAdd::STATIC_HEADER, &agent_add_entry); + GW::StoC::RemoveCallback(GW::Packet::StoC::AgentRemove::STATIC_HEADER, &agent_remove_entry); active_effects.clear(); is_initialized = false; @@ -217,8 +280,8 @@ void CombatEventQueue::OnSkillActivate(GW::Packet::StoC::SkillActivate* packet) active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_ACTIVATE_PACKET, + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_ACTIVATE_PACKET, packet->agent_id, packet->skill_id, 0, 0.0f)); } @@ -236,70 +299,71 @@ void CombatEventQueue::OnSkillActivate(GW::Packet::StoC::SkillActivate* packet) * - add_effect, remove_effect: Visual effects (internal IDs) * - skill_damage: Pre-notification that a skill will deal damage * - energygain: Energy gained + * - max_hp_reached: Agent HP returned to full (REACHED_MAXHP signal event) */ void CombatEventQueue::OnGenericValue(GW::Packet::StoC::GenericValue* packet) { if (!IsMapReady()) { active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); using namespace GW::Packet::StoC::GenericValueID; switch (packet->value_id) { case skill_activated: - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_ACTIVATED, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_ACTIVATED, packet->agent_id, packet->value, 0, 0.0f)); break; case attack_skill_activated: - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_SKILL_ACTIVATED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_SKILL_ACTIVATED, packet->agent_id, packet->value, 0, 0.0f)); break; case skill_stopped: - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_STOPPED, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_STOPPED, packet->agent_id, packet->value, 0, 0.0f)); break; case skill_finished: - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_FINISHED, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_FINISHED, packet->agent_id, packet->value, 0, 0.0f)); break; case attack_skill_finished: - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_SKILL_FINISHED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_SKILL_FINISHED, packet->agent_id, packet->value, 0, 0.0f)); break; case interrupted: - PushEvent(RawCombatEvent(now, CombatEventTypes::INTERRUPTED, + PushEvent(MakeEvent(now, CombatEventTypes::INTERRUPTED, packet->agent_id, packet->value, 0, 0.0f)); break; case instant_skill_activated: - PushEvent(RawCombatEvent(now, CombatEventTypes::INSTANT_SKILL_ACTIVATED, + PushEvent(MakeEvent(now, CombatEventTypes::INSTANT_SKILL_ACTIVATED, packet->agent_id, packet->value, 0, 0.0f)); break; case attack_skill_stopped: - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_SKILL_STOPPED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_SKILL_STOPPED, packet->agent_id, packet->value, 0, 0.0f)); break; case attack_stopped: - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_STOPPED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_STOPPED, packet->agent_id, packet->value, 0, 0.0f)); break; case melee_attack_finished: - PushEvent(RawCombatEvent(now, CombatEventTypes::MELEE_ATTACK_FINISHED, + PushEvent(MakeEvent(now, CombatEventTypes::MELEE_ATTACK_FINISHED, packet->agent_id, packet->value, 0, 0.0f)); break; case disabled: // Aftercast: value=1 means disabled (in aftercast), value=0 means can act - PushEvent(RawCombatEvent(now, CombatEventTypes::DISABLED, + PushEvent(MakeEvent(now, CombatEventTypes::DISABLED, packet->agent_id, packet->value, 0, 0.0f)); break; @@ -308,7 +372,7 @@ void CombatEventQueue::OnGenericValue(GW::Packet::StoC::GenericValue* packet) { auto& agent_effects = active_effects[packet->agent_id]; const bool already_active = agent_effects.find(packet->value) != agent_effects.end(); agent_effects.insert(packet->value); - PushEvent(RawCombatEvent( + PushEvent(MakeEvent( now, already_active ? CombatEventTypes::EFFECT_RENEWED : CombatEventTypes::EFFECT_APPLIED, packet->agent_id, @@ -325,20 +389,28 @@ void CombatEventQueue::OnGenericValue(GW::Packet::StoC::GenericValue* packet) { active_effects.erase(it); } } - PushEvent(RawCombatEvent(now, CombatEventTypes::EFFECT_REMOVED, + PushEvent(MakeEvent(now, CombatEventTypes::EFFECT_REMOVED, packet->agent_id, packet->value, 0, 0.0f)); break; case skill_damage: // Track which skill caused upcoming damage - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_DAMAGE, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_DAMAGE, packet->agent_id, packet->value, 0, 0.0f)); break; case energygain: - PushEvent(RawCombatEvent(now, CombatEventTypes::ENERGY_GAINED, + PushEvent(MakeEvent(now, CombatEventTypes::ENERGY_GAINED, packet->agent_id, 0, 0, static_cast(packet->value))); break; + + case max_hp_reached: + // Observed 2026-05-21: fires when an agent's HP returns to full, + // not when max HP itself changes (matches the past-tense GWCA name + // "max_hp_reached"). value/target are typically 0 - it's a signal event. + PushEvent(MakeEvent(now, CombatEventTypes::REACHED_MAXHP, + packet->agent_id, packet->value, 0, 0.0f)); + break; } } @@ -364,7 +436,7 @@ void CombatEventQueue::OnGenericValueTarget(GW::Packet::StoC::GenericValueTarget active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); using namespace GW::Packet::StoC::GenericValueID; @@ -375,19 +447,19 @@ void CombatEventQueue::OnGenericValueTarget(GW::Packet::StoC::GenericValueTarget switch (packet->Value_id) { case skill_activated: // Skill cast with target - agent_id=caster, target_id=target - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_ACTIVATED, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_ACTIVATED, actual_caster, packet->value, actual_target, 0.0f)); break; case attack_skill_activated: // Attack skill with target - agent_id=attacker, target_id=target - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_SKILL_ACTIVATED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_SKILL_ACTIVATED, actual_caster, packet->value, actual_target, 0.0f)); break; case attack_started: // Auto-attack started - agent_id=attacker, target_id=target - PushEvent(RawCombatEvent(now, CombatEventTypes::ATTACK_STARTED, + PushEvent(MakeEvent(now, CombatEventTypes::ATTACK_STARTED, actual_caster, 0, actual_target, 0.0f)); break; @@ -395,7 +467,7 @@ void CombatEventQueue::OnGenericValueTarget(GW::Packet::StoC::GenericValueTarget // Effect applied to target - uses NORMAL naming (not swapped!) // agent_id=caster, value=effect_id, target_id=target // Note: effect_id is NOT the skill_id - Python correlates to find skill_id - PushEvent(RawCombatEvent(now, CombatEventTypes::EFFECT_ON_TARGET, + PushEvent(MakeEvent(now, CombatEventTypes::EFFECT_ON_TARGET, packet->caster, packet->value, packet->target, 0.0f)); break; } @@ -408,33 +480,64 @@ void CombatEventQueue::OnGenericValueTarget(GW::Packet::StoC::GenericValueTarget * - knocked_down: Agent knocked down, float_value = duration in seconds * - casttime: Cast time modifier received, float_value = duration in seconds * - energy_spent: Energy consumed, float_value = energy as fraction of max + * - health (34), change_health_regen (44): HP snapshot + regen rate (GWCA-labeled) + * - energy (33), change_energy_regen (43): energy equivalents (no GWCA label) */ void CombatEventQueue::OnGenericFloat(GW::Packet::StoC::GenericFloat* packet) { if (!IsMapReady()) { active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); using namespace GW::Packet::StoC::GenericValueID; + // GWCA-unlabeled value_ids confirmed via WASM RE 2026-05-21 + constexpr uint32_t energy = 33; // AvCharNotifyStatInit(agent, ENERGY) + constexpr uint32_t change_energy_regen = 43; // AvCharNotifyStatRate(agent, ENERGY) + switch (packet->type) { case knocked_down: // Knockdown - float_value is duration in seconds - PushEvent(RawCombatEvent(now, CombatEventTypes::KNOCKED_DOWN, + PushEvent(MakeEvent(now, CombatEventTypes::KNOCKED_DOWN, packet->agent_id, 0, 0, packet->value)); break; case casttime: // Cast time - float_value is the cast duration in seconds - // This tells us how long the current cast will take - PushEvent(RawCombatEvent(now, CombatEventTypes::CASTTIME, + PushEvent(MakeEvent(now, CombatEventTypes::CASTTIME, packet->agent_id, 0, 0, packet->value)); break; case energy_spent: // Energy spent - float_value is fraction of max energy (0.0-1.0) - PushEvent(RawCombatEvent(now, CombatEventTypes::ENERGY_SPENT, + PushEvent(MakeEvent(now, CombatEventTypes::ENERGY_SPENT, + packet->agent_id, 0, 0, packet->value)); + break; + + case energy: + // Current energy as fraction of max (0.0-1.0). Fires on visibility + // / engagement resync. Authoritative for engaged agents. + PushEvent(MakeEvent(now, CombatEventTypes::CURRENT_ENERGY, + packet->agent_id, 0, 0, packet->value)); + break; + + case health: + // Current HP as fraction of max (0.0-1.0). Fires on visibility + // / engagement resync. Authoritative for engaged agents. + PushEvent(MakeEvent(now, CombatEventTypes::CURRENT_HEALTH, + packet->agent_id, 0, 0, packet->value)); + break; + + case change_energy_regen: + // Energy regen change - float_value is pips/sec (signed) + PushEvent(MakeEvent(now, CombatEventTypes::ENERGY_REGEN_CHANGE, + packet->agent_id, 0, 0, packet->value)); + break; + + case change_health_regen: + // HP regen change - float_value is pips/sec (signed) + PushEvent(MakeEvent(now, CombatEventTypes::HEALTH_REGEN_CHANGE, packet->agent_id, 0, 0, packet->value)); break; } @@ -443,23 +546,24 @@ void CombatEventQueue::OnGenericFloat(GW::Packet::StoC::GenericFloat* packet) { /** * @brief Handle GenericModifier packet. * - * This packet is for damage and healing events: - * - damage: Normal physical/elemental damage - * - critical: Critical hit damage (same as damage, just flagged) - * - armorignoring: Armor-ignoring damage (life steal, holy, etc.) - * Can be NEGATIVE for heals! - * - * The float value is damage as a FRACTION of target's max HP, not absolute. - * Python multiplies by Agent.GetMaxHealth(target_id) to get actual damage. + * FLOAT_TARGET channel - events with target + cause + signed HP delta: + * - damage: Normal physical/elemental damage (positive float) + * - critical: Critical hit damage (positive float) + * - armorignoring (value_id=55): GWCA-misnamed; actually SpellAdjust(HEALTH). + * Sign distinguishes heal-spell (positive -> HEALING event) from + * damage-spell (negative -> ARMOR_IGNORING event). Confirmed via WASM + * RE 2026-05-21 - case 0x37 -> AvCharNotifySpellAdjust(target, src, HP, fval). * - * Example: float_value = 0.15 on a target with 480 HP = 72 damage + * The float is damage as a FRACTION of target's max HP, not absolute. + * Python: actual = value * Agent.GetMaxHealth(target_id). + * Example: float_value = 0.15 on a target with 480 HP = 72 damage. */ void CombatEventQueue::OnGenericModifier(GW::Packet::StoC::GenericModifier* packet) { if (!IsMapReady()) { active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); using namespace GW::Packet::StoC::GenericValueID; @@ -471,19 +575,21 @@ void CombatEventQueue::OnGenericModifier(GW::Packet::StoC::GenericModifier* pack switch (packet->type) { case damage: // Normal damage - agent_id=target, target_id=source, float=damage% - PushEvent(RawCombatEvent(now, CombatEventTypes::DAMAGE, + PushEvent(MakeEvent(now, CombatEventTypes::DAMAGE, target_id, 0, source_id, value)); break; case critical: // Critical hit - same structure as damage - PushEvent(RawCombatEvent(now, CombatEventTypes::CRITICAL, + PushEvent(MakeEvent(now, CombatEventTypes::CRITICAL, target_id, 0, source_id, value)); break; case armorignoring: - // Positive values are healing/lifesteal gain; non-positive are armor-ignoring damage. - PushEvent(RawCombatEvent( + // Armor-ignoring HP change (verified in-game): spell damage, + // life-steal, degen, holy, chaos, sacrifice. Sign: +gain -> HEALING, + // -damage -> ARMOR_IGNORING. See OnGenericModifier docstring. + PushEvent(MakeEvent( now, value > 0.0f ? CombatEventTypes::HEALING : CombatEventTypes::ARMOR_IGNORING, target_id, @@ -515,9 +621,9 @@ void CombatEventQueue::OnSkillRecharge(GW::Packet::StoC::SkillRecharge* packet) active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); // agent_id=who, value=skill_id, float_value=recharge_ms - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_RECHARGE, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_RECHARGE, packet->agent_id, packet->skill_id, 0, static_cast(packet->recharge))); } @@ -541,12 +647,54 @@ void CombatEventQueue::OnSkillRecharged(GW::Packet::StoC::SkillRecharged* packet active_effects.clear(); return; } - uint32_t now = static_cast(GetTickCount64()); + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); // agent_id=who, value=skill_id - PushEvent(RawCombatEvent(now, CombatEventTypes::SKILL_RECHARGED, + PushEvent(MakeEvent(now, CombatEventTypes::SKILL_RECHARGED, packet->agent_id, packet->skill_id, 0, 0.0f)); } +/** + * @brief Handle AgentAdd packet. + * + * Fires when an agent enters the client's tracking range (visibility resync, + * map entry near other agents, spawn). Useful for initializing per-agent + * tracking state on first sight. + * + * value = agent_type bitmask: + * 0x30000000 = Player | PlayerNumber + * 0x20000000 = NPC | PlayerNumber + * 0x00000000 = Signpost + * target_id = allegiance_bits (raw); Python decodes via existing allegiance helpers + * float_value = base speed (default 288.0) + */ +void CombatEventQueue::OnAgentAdd(GW::Packet::StoC::AgentAdd* packet) { + if (!IsMapReady()) { + active_effects.clear(); + return; + } + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); + PushEvent(MakeEvent(now, CombatEventTypes::AGENT_ADDED, + packet->agent_id, packet->agent_type, packet->allegiance_bits, packet->speed)); +} + +/** + * @brief Handle AgentRemove packet. + * + * Fires when an agent leaves the client's tracking range. The packet itself + * doesn't distinguish "out of range" from "dead" from "disconnected" - all + * three look the same here. Cross-reference with AgentState.dead bit (when + * AgentState gets wired) for definitive death detection. + */ +void CombatEventQueue::OnAgentRemove(GW::Packet::StoC::AgentRemove* packet) { + if (!IsMapReady()) { + active_effects.clear(); + return; + } + uint32_t now = static_cast(frame_clock::GetFrameTimestamp()); + PushEvent(MakeEvent(now, CombatEventTypes::AGENT_REMOVED, + packet->agent_id, 0, 0, 0.0f)); +} + // ============================================================================ // Python Bindings // ============================================================================ @@ -606,6 +754,11 @@ Event Types: types.attr("CRITICAL") = CombatEventTypes::CRITICAL; types.attr("ARMOR_IGNORING") = CombatEventTypes::ARMOR_IGNORING; types.attr("HEALING") = CombatEventTypes::HEALING; + types.attr("CURRENT_HEALTH") = CombatEventTypes::CURRENT_HEALTH; + types.attr("CURRENT_ENERGY") = CombatEventTypes::CURRENT_ENERGY; + types.attr("HEALTH_REGEN_CHANGE") = CombatEventTypes::HEALTH_REGEN_CHANGE; + types.attr("ENERGY_REGEN_CHANGE") = CombatEventTypes::ENERGY_REGEN_CHANGE; + types.attr("REACHED_MAXHP") = CombatEventTypes::REACHED_MAXHP; types.attr("EFFECT_APPLIED") = CombatEventTypes::EFFECT_APPLIED; types.attr("EFFECT_REMOVED") = CombatEventTypes::EFFECT_REMOVED; types.attr("EFFECT_ON_TARGET") = CombatEventTypes::EFFECT_ON_TARGET; @@ -616,6 +769,8 @@ Event Types: types.attr("SKILL_ACTIVATE_PACKET") = CombatEventTypes::SKILL_ACTIVATE_PACKET; types.attr("SKILL_RECHARGE") = CombatEventTypes::SKILL_RECHARGE; types.attr("SKILL_RECHARGED") = CombatEventTypes::SKILL_RECHARGED; + types.attr("AGENT_ADDED") = CombatEventTypes::AGENT_ADDED; + types.attr("AGENT_REMOVED") = CombatEventTypes::AGENT_REMOVED; // RawCombatEvent struct py::class_(m, "PyRawCombatEvent") @@ -626,12 +781,21 @@ Event Types: .def_readonly("value", &RawCombatEvent::value) .def_readonly("target_id", &RawCombatEvent::target_id) .def_readonly("float_value", &RawCombatEvent::float_value) + // Enqueue-time max-HP/max-energy snapshots - use these instead of + // Agent.GetMaxHealth(id) / Agent.GetMaxEnergy(id) to avoid access + // violations when an agent burrows/despawns mid-frame. + .def_readonly("agent_max_hp", &RawCombatEvent::agent_max_hp) + .def_readonly("agent_max_energy", &RawCombatEvent::agent_max_energy) + .def_readonly("target_max_hp", &RawCombatEvent::target_max_hp) + .def_readonly("target_max_energy", &RawCombatEvent::target_max_energy) .def("__repr__", [](const RawCombatEvent& e) { return ""; + " float=" + std::to_string(e.float_value) + + " a_max_hp=" + std::to_string(e.agent_max_hp) + + " t_max_hp=" + std::to_string(e.target_max_hp) + ">"; }) .def("as_tuple", [](const RawCombatEvent& e) { return py::make_tuple(e.timestamp, e.event_type, e.agent_id, From 179b7337957da3c471acc1b4f12ac3ef65e7dfdd Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:38:18 +0200 Subject: [PATCH 2/5] name obfuscation --- include/name_obfuscation.h | 61 ++++++++++++++++++ src/py_name_obfuscation.cpp | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 include/name_obfuscation.h create mode 100644 src/py_name_obfuscation.cpp diff --git a/include/name_obfuscation.h b/include/name_obfuscation.h new file mode 100644 index 0000000..f077ce6 --- /dev/null +++ b/include/name_obfuscation.h @@ -0,0 +1,61 @@ +#pragma once +#ifndef NAME_OBFUSCATION_H +#define NAME_OBFUSCATION_H + +#include "Headers.h" + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// NameObfuscation +// +// Rewrites player names in incoming StoC packets BEFORE the game stores them, +// so all downstream rendering (nametags, party frame, target pane) shows the +// fake name. Also blanks the guild tag while active. +// +// Lifecycle: +// Init() -- registers StoC packet callbacks once on DLL init +// Shutdown() -- removes callbacks and clears the alias table +// --------------------------------------------------------------------------- + +class NameObfuscation { +public: + static NameObfuscation& Instance() { + static NameObfuscation instance; + return instance; + } + + void Init(); + void Shutdown(); + + void Enable() { enabled_ = true; } + void Disable() { enabled_ = false; } + bool IsEnabled() const { return enabled_; } + + void SetAlias(const std::wstring& real_name, const std::wstring& fake_name); + void RemoveAlias(const std::wstring& real_name); + void ClearAliases(); + size_t AliasCount(); + + // packet-hook side + bool LookupAlias(const wchar_t* real_name, std::wstring& fake_out); + +private: + NameObfuscation() = default; + ~NameObfuscation() = default; + NameObfuscation(const NameObfuscation&) = delete; + NameObfuscation& operator=(const NameObfuscation&) = delete; + + std::atomic enabled_{false}; + std::mutex mutex_; + std::unordered_map aliases_; + + GW::HookEntry player_join_hook_{}; + GW::HookEntry guild_general_hook_{}; + bool hooks_registered_ = false; +}; + +#endif // NAME_OBFUSCATION_H diff --git a/src/py_name_obfuscation.cpp b/src/py_name_obfuscation.cpp new file mode 100644 index 0000000..da9d7b8 --- /dev/null +++ b/src/py_name_obfuscation.cpp @@ -0,0 +1,123 @@ +#include "name_obfuscation.h" + +#include + +namespace py = pybind11; + +// --------------------------------------------------------------------------- +// Static StoC callbacks +// --------------------------------------------------------------------------- + +static void OnPlayerJoinInstance(GW::HookStatus*, GW::Packet::StoC::PlayerJoinInstance* pak) { + if (!pak || !pak->player_name[0]) return; + + auto& svc = NameObfuscation::Instance(); + if (!svc.IsEnabled()) return; + + std::wstring fake; + if (svc.LookupAlias(pak->player_name, fake)) { + // pak->player_name is wchar_t[32]; copy with bounds check. + const size_t max_chars = 31; + size_t to_copy = fake.size(); + if (to_copy > max_chars) to_copy = max_chars; + std::memcpy(pak->player_name, fake.data(), to_copy * sizeof(wchar_t)); + pak->player_name[to_copy] = L'\0'; + } +} + +static void OnGuildGeneral(GW::HookStatus*, GW::Packet::StoC::GuildGeneral* pak) { + if (!pak) return; + auto& svc = NameObfuscation::Instance(); + if (!svc.IsEnabled()) return; + if (svc.AliasCount() == 0) return; + pak->tag[0] = L'\0'; +} + +// --------------------------------------------------------------------------- +// NameObfuscation impl +// --------------------------------------------------------------------------- + +void NameObfuscation::Init() { + if (hooks_registered_) return; + GW::StoC::RegisterPacketCallback( + &player_join_hook_, + [](GW::HookStatus* s, GW::Packet::StoC::PlayerJoinInstance* p) { OnPlayerJoinInstance(s, p); }, + -0x8000); + + GW::StoC::RegisterPacketCallback( + &guild_general_hook_, + [](GW::HookStatus* s, GW::Packet::StoC::GuildGeneral* p) { OnGuildGeneral(s, p); }, + -0x8000); + + hooks_registered_ = true; +} + +void NameObfuscation::Shutdown() { + if (hooks_registered_) { + GW::StoC::RemoveCallbacks(&player_join_hook_); + GW::StoC::RemoveCallbacks(&guild_general_hook_); + hooks_registered_ = false; + } + enabled_ = false; + ClearAliases(); +} + +void NameObfuscation::SetAlias(const std::wstring& real_name, const std::wstring& fake_name) { + std::lock_guard lock(mutex_); + aliases_[real_name] = fake_name; +} + +void NameObfuscation::RemoveAlias(const std::wstring& real_name) { + std::lock_guard lock(mutex_); + aliases_.erase(real_name); +} + +void NameObfuscation::ClearAliases() { + std::lock_guard lock(mutex_); + aliases_.clear(); +} + +size_t NameObfuscation::AliasCount() { + std::lock_guard lock(mutex_); + return aliases_.size(); +} + +bool NameObfuscation::LookupAlias(const wchar_t* real_name, std::wstring& fake_out) { + if (!real_name) return false; + std::lock_guard lock(mutex_); + auto it = aliases_.find(real_name); + if (it == aliases_.end()) return false; + fake_out = it->second; + return true; +} + +// --------------------------------------------------------------------------- +// pybind11 module: PyNameObfuscation +// --------------------------------------------------------------------------- + +PYBIND11_EMBEDDED_MODULE(PyNameObfuscation, m) { + m.doc() = "Player name obfuscation: rewrites incoming PlayerJoinInstance " + "and blanks guild tags so the UI shows fake names."; + + m.def("enable", []() { NameObfuscation::Instance().Enable(); }, + "Turn the name rewrite on. Takes effect on the next PlayerJoinInstance packet."); + m.def("disable", []() { NameObfuscation::Instance().Disable(); }, + "Turn the name rewrite off. Names already cached by the game persist until re-zone."); + m.def("is_enabled", []() { return NameObfuscation::Instance().IsEnabled(); }); + + m.def("set_alias", + [](const std::wstring& real, const std::wstring& fake) { + NameObfuscation::Instance().SetAlias(real, fake); + }, + py::arg("real_name"), py::arg("fake_name"), + "Register or update a real -> fake player-name mapping."); + + m.def("remove_alias", + [](const std::wstring& real) { NameObfuscation::Instance().RemoveAlias(real); }, + py::arg("real_name")); + + m.def("clear", []() { NameObfuscation::Instance().ClearAliases(); }, + "Drop every alias."); + + m.def("alias_count", []() { return (uint32_t)NameObfuscation::Instance().AliasCount(); }); +} From eb19fff8efca461a0cc27cf1abd95ace3f04ece0 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:32:32 +0200 Subject: [PATCH 3/5] Update ItemMgr.cpp --- vendor/gwca/Source/ItemMgr.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vendor/gwca/Source/ItemMgr.cpp b/vendor/gwca/Source/ItemMgr.cpp index b970a7c..61824eb 100644 --- a/vendor/gwca/Source/ItemMgr.cpp +++ b/vendor/gwca/Source/ItemMgr.cpp @@ -282,8 +282,12 @@ namespace { IdentifyItem_Func = (IdentifyItem_pt)Scanner::ToFunctionStart(Scanner::FindAssertion("ItCliApi.cpp", "context->itemTable.Get(srcItemId)", 0, 0)); Logger::AssertAddress("IdentifyItem_Func", (uintptr_t)IdentifyItem_Func, "Item Module"); - address = Scanner::Find("\x83\xc4\x40\x6a\x00\x6a\x19", "xxxxxxx", -0x4e); - DropItem_Func = (DropItem_pt)Scanner::FunctionFromNearCall(address); + // CharMsgSendOrderDrop(item_id, quantity): builds CtoS drop packet {0x2C, item_id, quantity}. + // Old pattern "\x83\xc4\x40\x6a\x00\x6a\x19" -0x4e rotted to 2 matches; + address = Scanner::Find( + "\x8B\x45\x08\x89\x45\xF4\x8B\x45\x0C\x89\x45\xF8\x8D\x45\xF0\x50\x6A\x0C\xC7\x45\xF0\x2C\x00\x00\x00", + "xxxxxxxxxxxxxxxxxxxxxxxxx"); + DropItem_Func = (DropItem_pt)Scanner::ToFunctionStart(address); //commented address is from exe 28-nov-2025 //address = Scanner::Find("\x83\x78\x08\x0a\x75\x10", "xxxxxx", 0xe); From b757d2951dbbb0ed9193b3004a782a35d70598dd Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:38:15 +0200 Subject: [PATCH 4/5] Crash handler --- docs/crash_handling.md | 193 +++++++++++++++++ include/Breadcrumbs.h | 95 +++++++++ include/CrashHandler.h | 76 +++++++ src/CrashHandler.cpp | 460 +++++++++++++++++++++++++++++++++++++++++ src/Py4GW.cpp | 46 ++++- src/dllmain.cpp | 8 + 6 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 docs/crash_handling.md create mode 100644 include/Breadcrumbs.h create mode 100644 include/CrashHandler.h create mode 100644 src/CrashHandler.cpp diff --git a/docs/crash_handling.md b/docs/crash_handling.md new file mode 100644 index 0000000..9729f31 --- /dev/null +++ b/docs/crash_handling.md @@ -0,0 +1,193 @@ +# Py4GW Crash Handler + +Forensic crash capture for Py4GW. When Guild Wars or `Py4GW.dll` faults, it leaves a +per-client artifact set that answers: **what native instruction crashed**, **what GW itself +said about it**, and **which Python script was last active**. + +Status: **implemented, built, deployed, and verified live.** One enhancement is pending — see +[Pending: Python breadcrumb](#pending-python-breadcrumb). + +Source: `include/CrashHandler.h`, `src/CrashHandler.cpp`, `include/Breadcrumbs.h` (native); +`Py4GWCoreLib/py4gwcorelib_src/CrashLog.py` (Python). Bindings: `Py4GW.crashlog` / `Py4GW.debug` +in `src/Py4GW.cpp`. + +--- + +## Architecture + +Four capture paths feed one crash sink (`CrashHandler::OnException`), which writes a minidump + +sidecars under a single re-entrancy guard, then lets the process die. + +| Path | Trigger | Mechanism | +|---|---|---| +| **A — SEH** | any unhandled native fault (AV, illegal instr, stack overflow) | `SetUnhandledExceptionFilter` | +| **B — GWCA panic** | a GWCA `FatalAssert` (e.g. a `Scanner::Find` pattern rotted after a GW update) | `GW::RegisterPanicHandler`; self-dumps via `RtlCaptureContext` (GWCA `abort()`s, so it can't rely on Path A) | +| **C — GW engine** | GW's own engine hits a fatal (assertion, packet/state error) — **captures the exact crashing GW function** | detours GW's internal crash-message builder | +| **D — Python** | uncaught Python exception | `sys.excepthook` + `threading.excepthook` write a full traceback | + +The native side cannot read the live Python stack (the GIL/interpreter may be inconsistent at +fault time), so Python context comes from two places: a **pre-captured last-frame breadcrumb** +(for native faults) and the **excepthook tracebacks** (for Python-level exceptions). + +### Install / teardown (`src/dllmain.cpp`) +- `CrashHandler::Instance().Initialize()` right after `InitializeGWCA()` succeeds (`dllmain.cpp:71`) + — Paths B/C need GWCA live; the SEH filter and the process-exception-policy change happen here. +- `CrashHandler::Instance().Terminate()` before `Py4GW::Instance().Terminate()` (`dllmain.cpp:131`) + — disables/removes the Path C hook, restores the SEH filter + exception policy. + +### Process exception policy +`Initialize()` clears `PROCESS_CALLBACK_FILTER_ENABLED` (via dynamically-resolved +`Get/SetProcessUserModeExceptionPolicy`) and saves the prior value to restore at teardown. Without +this, exceptions raised inside kernel-mode callbacks (window procs / D3D present) are silently +swallowed and never reach the SEH filter — essential for a GUI overlay injected into a game. + +--- + +## Path C — GW's internal crash-message builder (verified) + +This is the path that names the exact crashing GW function. When GW's engine hits a fatal, it +builds a crash report (assertion text, registers, loaded-module list, and a stack trace) into an +internal debug-info buffer. Py4GW resolves that builder **at runtime by a stable format-string +anchor** and detours it, capturing GW's own report + a CONTEXT whose `Eip` is the faulting +instruction. + +Verified against the live client (`Gw.exe`, 2026-06-02 build) — the target function and ABI: + +| Fact | Value | +|---|---| +| Resolution | `GW::Scanner::FindUseOfString("%p %08x %08x %08x %08x ")` -> `ToFunctionStart(use, 0xfff)` | +| Target (this build) | `0x00488fe0` | +| Calling convention | `__cdecl`, **7 args** `(debug_info*, u32, u32, u32, CONTEXT* ctx, u32, u32)` | +| Debug-info text offset | `+0x20c` (length-counted; bound `< 0x80000`) | +| `CONTEXT.Eip` | `0xB8` | +| Report-section delimiter | `*-->
<--*` | + +The detour forwards all 7 args to the trampoline, lets GW fill its report, captures it, synthesizes +an `EXCEPTION_RECORD` (`EXCEPTION_BREAKPOINT`, address = `ctx->Eip`), and dumps. The `0x20c` read is +guarded by `__try/__except` and gated on the scanner hit; on a scan miss it **logs and continues** +so Paths A/B stay functional. The address is re-resolved per process, so **re-anchor (not +re-address) after a GW patch** — the convention and offsets are stable; only the address moves. + +Boot-time smoke test: `Py4GW_injection_log.txt` logs `[CrashHandler] Path C attached (GW +crash-message hook).` when the anchor resolves. + +--- + +## Crash-time safety invariants + +The SEH writer path runs while the process is dying, so it is: +- **allocation-free** — static buffers only, no `new`/STL allocation; +- **lock-free** — no `std::mutex`; the breadcrumb ring is a lock-free MPSC with release/acquire seq; +- **Python-API-free** — never calls any `Py*` function; +- **non-recursive** — one `InterlockedCompareExchange` guard; a fault during dumping terminates + rather than recursing; `MiniDumpWriteDump` is wrapped in `__try/__except`; +- **x86 only** — `static_assert(sizeof(void*) == 4)` guards the `CONTEXT.Eip` assumption. + +The C++ `Logger` (heap + mutex) is used only at install/teardown, never on the crash path. The +crash path appends one line to `Py4GW_injection_log.txt` via a raw `CreateFileW(FILE_APPEND_DATA)`. + +--- + +## Output + +Per-client. The crash directory is `/crashes/` — the process working directory captured +at `Initialize()`, which (before Py4GW's first-frame `ChangeWorkingDirectory`) is the GW client's own +folder, so each multibox instance gets an isolated `crashes/` next to its own +`Py4GW_injection_log.txt`. The Python side fetches the same path via `Py4GW.crashlog.get_crash_dir()`. + +Per crash, sharing a `py4gw----` stem: + +| File | Contents | +|---|---| +| `....dmp` | minidump, flags `0x1041` (`DataSegs \| IndirectlyReferencedMemory \| ThreadInfo`), exception info + a `CommentStreamA` summary | +| `....json` | sidecar (below) | +| `...-gwtext.txt` | GW's **full** crash report verbatim (Path C only) | +| `...--pytrace.txt` | full Python `traceback.format_exc()` (Python-uncaught only) | + +Plus one `CRASH ...` line appended to `Py4GW_injection_log.txt`. + +### Sidecar JSON +```json +{ + "version": "0.0.0-dev", + "source": "gw_engine", // seh | wndproc | gwca_assert | gw_engine + "crash_class": "breakpoint", // derived from the real ExceptionCode + "exception_code": "0x80000003", + "fault_address": "0x00EA7A9B", + "faulting_tid": 48864, + "dump_file": "py4gw-20260614-011912-13084-48864.dmp", + "gw_text_file": "py4gw-20260614-011912-13084-48864-gwtext.txt", + "python_last_frame": { "file": "", "line": 0, "func": "" }, + "assert": "", + "gw_text": "*--> Crash <--*\r\nAssertion: ...\r\nP:\\Code\\Gw\\...(NNN)\r\n...", + "breadcrumbs": ["gw_text: *--> Crash <--* ..."] +} +``` +All interpolated strings are JSON-escaped. `gw_text` is a ~1 KB preview; the full report is in +`gw_text_file`. + +### Live-verified example +A real crash captured at `...\Guild Wars alt 7\crashes\` (Path C): +`source: gw_engine`, and `gw_text`: +`Assertion: !(manualAgentId && !ManagerFindAgent(manualAgentId)) P:\Code\Gw\AgentView\AvSelect.cpp(780)` +— i.e. a change-target on a stale/despawned agent id. The handler named the exact GW function, file, +and line. + +--- + +## Python side (`CrashLog.py`) + +`install_crash_hooks()` (called once from `Py4GWCoreLib/__init__.py` after the stdout/stderr redirect): +- installs `sys.excepthook` + `threading.excepthook` -> full `traceback.format_exc()` to a + `-pytrace.txt` for uncaught exceptions on the render thread and worker threads; +- exposes `set_breadcrumb(widget, phase)` which snapshots the current Python frame into the native + TLS slot (via `Py4GW.crashlog.set_last_frame`) so a native crash can name the last Python frame. + +`faulthandler` is **off by default**: Py4GW routinely raises+swallows native exceptions, and +`faulthandler`'s vectored handler logs every one process-wide (huge file + overhead). Pass +`install_crash_hooks(enable_faulthandler=True)` only for quiet single-client interpreter-fault +debugging. + +--- + +## Bindings (`Py4GW.crashlog` / `Py4GW.debug`) +- `Py4GW.crashlog.set_last_frame(file, line, func)` — record the last Python frame (TLS). +- `Py4GW.crashlog.breadcrumb(text)` — append to the crash ring. +- `Py4GW.crashlog.get_crash_dir()` — the native crash dir (so Python writes beside the dump). +- `Py4GW.debug.crash()` — deliberate access violation to exercise the handler. + **Gated behind `PY4GW_DEBUG_BUILD`** — remove that define in `CMakeLists.txt` for production. + +--- + +## Build / deploy + +- `CMakeLists.txt` links `dbghelp` and defines `PY4GW_DEBUG_BUILD` (testing). `src/*.cpp` is + glob-included; new sources need a CMake re-configure. +- Build: `cmake --build build --config RelWithDebInfo --target Py4GW` (VS 2022, Win32, Python 3.13-32). +- Deploy: copy `bin/RelWithDebInfo/Py4GW.dll` into the runtime tree (the DLL is locked while clients + run — close them first). Running clients keep their old code in memory until relaunched. +- Test: a fresh client -> `Py4GW.debug.crash()` -> expect one dump in that client's own `crashes/` with + `source:"seh"`, `exception_code:"0xC0000005"`. + +--- + +## Pending: Python breadcrumb + +`python_last_frame` is currently empty — the breadcrumb infrastructure exists but nothing calls +`set_last_frame`/`set_breadcrumb` yet. Two call sites are needed (per-faulting-thread TLS): +1. **Game-thread enqueue runner** (`src/Py4GW.cpp`, the `GW::GameThread::Enqueue` lambda) — record + the enqueued callback's `__code__` before running it. Covers deferred-call crashes like the + AvSelect example. (C++ — needs a rebuild.) +2. **WidgetManager per-widget dispatch** (`Py4GWCoreLib/py4gwcorelib_src/WidgetManager.py`) — call + `CrashLog.set_breadcrumb(widget, phase)` before each widget callback. Covers crashes during a + widget's draw on the render thread. (Python only — no rebuild.) + +--- + +## Known limitations +- The `0x20c` text offset and the Path C target address are build-specific; re-anchor on GW patches. +- Pre-existing `__except (EXCEPTION_EXECUTE_HANDLER)` swallow blocks (`GwDatTextureManager`, + `py_dialog`, `dllmain` `SafeWndProc`) pre-empt the SEH filter by design; a missing dump there does + not imply no fault. +- `WndProcFilter` exists but is intentionally **not wired** — GW's window proc raises survivable + exceptions routinely, so its `__except` is left as a silent swallow. diff --git a/include/Breadcrumbs.h b/include/Breadcrumbs.h new file mode 100644 index 0000000..7cded52 --- /dev/null +++ b/include/Breadcrumbs.h @@ -0,0 +1,95 @@ +#pragma once +// Crash breadcrumbs: per-thread last-Python-frame POD + lock-free MPSC ring. +// Read by the SEH filter, so EVERYTHING here is allocation-free, lock-free, and Python-API-free. +// +// Why a breadcrumb instead of a live Python frame walk: +// At native-crash time the GIL/interpreter may be inconsistent (the faulting callback +// held the GIL, Py4GW.cpp:1146). We therefore CANNOT call any Py* API from the filter. +// Instead the Python side snapshots its current frame into per-thread static storage +// (under the GIL, on the executing thread) during normal execution; the filter reads +// only that static copy on the FAULTING thread. + +#include +#include +#include +#include +#include + +namespace bc { + +// ---- last Python frame (per-thread) ---------------------------------------- +// Fixed-size POD; strings are memcpy'd at capture time (NOT PyUnicode_AsUTF8 ptrs, +// whose lifetime/heap is exactly what a crash corrupts). +struct LastPyFrame { + char file[260]; + char func[128]; + int line; +}; + +// One slot per thread. Written by whichever thread runs the Python callback (game thread AND +// update thread); read by the filter on the faulting thread. POD `{}` => constant zero-init, +// so there is NO thread_local dynamic-init guard for the crash-time reader to trip over. +inline thread_local LastPyFrame t_last_py_frame{}; + +// Capture: called from the pybind binding with the GIL held. Copies into TLS (no heap). +inline void set_last_py_frame(const char* file, int line, const char* func) { + LastPyFrame& f = t_last_py_frame; + if (file) { strncpy_s(f.file, file, _TRUNCATE); } else { f.file[0] = 0; } + if (func) { strncpy_s(f.func, func, _TRUNCATE); } else { f.func[0] = 0; } + f.line = line; +} + +// Read: called from the SEH filter. Returns a by-value POD copy of THIS thread's slot. +inline LastPyFrame read_last_py_frame() { return t_last_py_frame; } + +// ---- lock-free MPSC breadcrumb ring ---------------------------------------- +// Fixed ring of fixed-size messages. MULTIPLE producers (game + update threads call +// breadcrumb()); single consumer (the SEH filter draining at crash time). No locks. +// +// Publish protocol (fixes the missing release/acquire the review flagged): reserve a +// monotonic index, write the message, then RELEASE-store a per-slot seq == index+1. The +// consumer ACQUIRE-loads next, then ACQUIRE-loads each slot's seq and only reads a slot +// whose seq matches the index it expects -> never reads a half-written or recycled slot. +// Residual: two producers landing on the same physical slot (indices kRingSize apart) at the +// same instant can tear one message; tolerated (best-effort, last-few-crumbs-only). +constexpr uint32_t kRingSize = 64; // power of two for cheap masking +constexpr uint32_t kMsgLen = 160; + +struct Slot { + std::atomic seq; // 0 = empty; otherwise (producer_index + 1) + char msg[kMsgLen]; +}; + +struct Ring { + Slot slots[kRingSize]; + std::atomic next; // next index to hand out (monotonic) +}; + +inline Ring g_ring{}; + +// Producer: append "tag: text" (truncated). Call from normal execution context only. +inline void breadcrumb_copy(const char* tag, const char* text) { + uint32_t idx = g_ring.next.fetch_add(1, std::memory_order_relaxed); + Slot& s = g_ring.slots[idx & (kRingSize - 1)]; + _snprintf_s(s.msg, kMsgLen, _TRUNCATE, "%s: %s", tag ? tag : "?", text ? text : ""); + s.seq.store(idx + 1, std::memory_order_release); // publish AFTER the message is written +} + +// Convenience one-arg breadcrumb. +inline void breadcrumb(const char* text) { breadcrumb_copy("bc", text); } + +// Consumer (SEH filter). Visit the most-recent up-to-N entries oldest->newest, skipping any +// slot a producer has since recycled or not finished writing. +template +inline void drain_recent(uint32_t max_entries, Fn&& visit) { + uint32_t end = g_ring.next.load(std::memory_order_acquire); // one past the newest index + uint32_t count = (end < max_entries) ? end : max_entries; + for (uint32_t k = count; k > 0; --k) { + uint32_t idx = end - k; // oldest of the window first + Slot& s = g_ring.slots[idx & (kRingSize - 1)]; + if (s.seq.load(std::memory_order_acquire) == idx + 1) // slot still holds THIS entry + visit(s.msg); + } +} + +} // namespace bc diff --git a/include/CrashHandler.h b/include/CrashHandler.h new file mode 100644 index 0000000..7e0efc0 --- /dev/null +++ b/include/CrashHandler.h @@ -0,0 +1,76 @@ +#pragma once +// Py4GW crash handler: native minidump (Paths A/B/C) + Python last-frame breadcrumb. +// +// Crash-time contract (design-safety invariants enforced in the .cpp): +// - The SEH writer path is allocation-free, lock-free, and Python-API-free. +// - A single re-entrancy guard serializes Paths A/B/C. +// - Path B self-dumps via RtlCaptureContext (GWCA FatalAssert -> abort() may not reach Path A). +// - Path C detours GW's own internal crash-message builder. Verified on the live build +// (Gw.exe 2026-06-02, 0x00488fe0): target is __cdecl, 7 args, debug-info text @ +0x20c, +// arg5 = CONTEXT*, Eip @ 0xB8. Resolved at runtime by string anchor (re-anchors per patch). +// - x86 only (CONTEXT.Eip). static_assert guards the assumption. +// - Python full stack is NOT available here; we emit a pre-captured last-frame breadcrumb +// only. Full tracebacks come from the Python excepthooks. + +#include +#include +#include + +// Build-time version stamp. No version macro exists in the project; override via +// target_compile_definitions(Py4GW PRIVATE PY4GW_VERSION="x.y.z"). +#ifndef PY4GW_VERSION +#define PY4GW_VERSION "0.0.0-dev" +#endif + +class CrashHandler { +public: + static CrashHandler& Instance(); + + // Install Paths A/B (+ C if scanner hits). Idempotent. Call AFTER GW::Initialize() + // succeeds (dllmain.cpp:70) and BEFORE AttachRenderHook (dllmain.cpp:88). + void Initialize(); + + // Disable+remove Path C, restore SEH filter + exception policy, null panic handler. + // Call at dllmain.cpp:131 BEFORE Py4GW::Terminate() (tears down GW Scanner/Hook). Idempotent. + void Terminate(); + + // Shared crash sink. recoverable=false (fatal A/B/C) latches the guard; recoverable=true + // (WndProc) writes a dump then resets the guard so the game can resume. Returns true if + // it wrote (acquired the guard), false if another fault is already being handled. + bool OnException(EXCEPTION_POINTERS* info, const char* source, bool recoverable); + + // Optional recoverable-fault sink. NOT currently wired: SafeWndProc's __except is left as a + // silent swallow on purpose (GW raises survivable WndProc exceptions routinely). Kept for + // future use -- writes a dump then returns EXCEPTION_EXECUTE_HANDLER to fall through. + LONG WndProcFilter(EXCEPTION_POINTERS* info); + + // Resolved crash directory as UTF-8. Safe at NON-crash time only (e.g. the Python + // binding at startup). Empty until Initialize() has run. Lets the Python half write its + // *-pytrace.txt next to the .dmp/.json so one crash = one correlated set. + std::string CrashDirUtf8() const; + +private: + CrashHandler() = default; + CrashHandler(const CrashHandler&) = delete; + CrashHandler& operator=(const CrashHandler&) = delete; + + static LONG WINAPI TopLevelFilter(EXCEPTION_POINTERS* info); // Path A + static void GwcaPanic(void* ctx, const char* expr, const char* file, // Path B (5-arg) + unsigned int line, const char* func); + // Path C detour over GW's internal crash-message builder (verified __cdecl, 7-arg signature). + static uintptr_t __cdecl AppendStackDetour(void* debug_info, uint32_t a2, uint32_t a3, + uint32_t a4, CONTEXT* ctx, uint32_t a6, + uint32_t a7); + + void InstallPathA(); + void InstallPathB(); + void InstallPathC(); + void ClearCallbackFilterPolicy(); // clear PROCESS_CALLBACK_FILTER_ENABLED (bit 0) + void RestoreCallbackFilterPolicy(); // restore the saved policy at teardown + + // Crash-time-safe writers (no heap, no locks, no Py API). Paths are pre-built stems. + void WriteSidecar(EXCEPTION_POINTERS* info, const wchar_t* json_path, + const wchar_t* dmp_name, const wchar_t* gwtext_name, const char* source); + void WriteDump(EXCEPTION_POINTERS* info, const wchar_t* dmp_path, const char* comment); + bool EnsureCrashDir(); // GetCurrentDirectoryW base + "\\crashes"; init-time only +}; diff --git a/src/CrashHandler.cpp b/src/CrashHandler.cpp new file mode 100644 index 0000000..9a102a0 --- /dev/null +++ b/src/CrashHandler.cpp @@ -0,0 +1,460 @@ +// Py4GW crash handler implementation. +// Paths: A = SetUnhandledExceptionFilter, B = GWCA panic handler, C = GW crash-message detour. +// +// Design-safety invariants enforced here: +// * Crash-time writers use a pre-opened path + static buffers + raw WriteFile. No +// std::mutex, no heap, no MessageBox, no Py* calls. MiniDumpWriteDump is __try-guarded +// (it HeapAllocs internally and can fault on a heap-corruption crash). +// * A single re-entrancy guard (s_handling) prevents recursive dumps; fatal paths latch it +// (process ends), the WndProc path resets it and is rate-capped. +// * Path B self-dumps via RtlCaptureContext; it never relies on abort() reaching Path A. +// * Path C uses the VERIFIED 7-arg __cdecl signature and forwards ALL args to the trampoline. +// * The crash dir is resolved ONCE at Initialize() (non-crash time) from the process CWD, +// so it sits next to Py4GW_injection_log.txt and the SEH path never touches a std::string. + +#include "CrashHandler.h" +#include "Breadcrumbs.h" + +#include // MiniDumpWriteDump; needs target_link_libraries(... dbghelp) +#include // _vsnprintf_s / _snwprintf_s into static buffers only +#include + +#include // GW::RegisterPanicHandler +#include // GW::Scanner::FindUseOfString / ToFunctionStart +#include // GW::HookBase::CreateHook / EnableHooks / RemoveHook + +// The Logger (GWCA-declared, project-implemented) is heap+mutex based; only safe to call +// OUTSIDE the crash path (install / teardown). Never from the SEH filter. +#include + +// Project global: directory containing Py4GW.dll. Used ONLY as an init-time fallback. +extern std::string dllDirectory; + +namespace { + +// ---- re-entrancy guard ------------------------------------------------------ +volatile LONG s_handling = 0; // 0 = idle, 1 = a fault is being handled +volatile LONG s_wndproc_dumps = 0; // rate-cap for the recoverable WndProc path +constexpr LONG kMaxWndProcDumps = 5; + +// ---- install/teardown state ------------------------------------------------- +bool s_installed = false; +LPTOP_LEVEL_EXCEPTION_FILTER s_prev_filter = nullptr; +uintptr_t s_append_stack_fn = 0; // resolved GW function (Path C) +void* s_append_stack_orig = nullptr; // Path C trampoline +DWORD s_prev_policy = 0; // saved PROCESS_CALLBACK_FILTER policy +bool s_policy_changed = false; + +// ---- pre-resolved crash dir (wide + utf8) ---------------------------------- +wchar_t s_crash_dir[MAX_PATH] = {0}; // \crashes (next to Py4GW_injection_log.txt) +bool s_crash_dir_ready = false; +std::string s_crash_dir_utf8; + +// ---- crash text channels (static; filled before the dump) ------------------- +char s_assert_text[512] = {0}; // Path B assertion text +char s_gw_text[32768] = {0}; // Path C: GW's full crash report (Crash/System/DllList/Stack) + +// Map a Windows exception code to a stable label for the sidecar. +const char* exception_label(DWORD code) { + switch (code) { + case EXCEPTION_ACCESS_VIOLATION: return "access_violation"; + case EXCEPTION_STACK_OVERFLOW: return "stack_overflow"; + case EXCEPTION_ILLEGAL_INSTRUCTION: return "illegal_instruction"; + case EXCEPTION_INT_DIVIDE_BY_ZERO: return "int_divide_by_zero"; + case EXCEPTION_PRIV_INSTRUCTION: return "priv_instruction"; + case EXCEPTION_IN_PAGE_ERROR: return "in_page_error"; + case 0xC0000409: return "stack_buffer_overrun"; // FAST_FAIL + case 0xE06D7363: return "cpp_exception"; + case 0x80000003: return "breakpoint"; + case 0xE0000001: return "gwca_assert"; + default: return "exception"; + } +} + +// Append-into-bounded-buffer helper. On truncation it stops cleanly (no overrun, no -1 math). +struct JBuf { char* p; char* const e; }; +void jappend(JBuf& b, const char* fmt, ...) { + if (b.p >= b.e) return; + va_list ap; va_start(ap, fmt); + int n = _vsnprintf_s(b.p, static_cast(b.e - b.p), _TRUNCATE, fmt, ap); + va_end(ap); + b.p = (n >= 0) ? b.p + n : b.e; // n<0 => truncated => stop (n==0 is a valid write) +} + +// Escape a UTF-8 string into dst for JSON embedding (\\, ", control chars). Static, no heap. +void json_escape(char* dst, size_t cap, const char* src) { + if (cap == 0) return; + size_t j = 0; + for (size_t i = 0; src && src[i] && j + 2 < cap; ++i) { + unsigned char c = static_cast(src[i]); + if (c == '\\' || c == '"') { dst[j++] = '\\'; dst[j++] = c; } + else if (c == '\n') { dst[j++] = '\\'; dst[j++] = 'n'; } + else if (c == '\r') { dst[j++] = '\\'; dst[j++] = 'r'; } + else if (c == '\t') { dst[j++] = '\\'; dst[j++] = 't'; } + else if (c >= 0x20) { dst[j++] = c; } + // other control chars are dropped + } + dst[j] = 0; +} + +// Recursive CreateDirectory for a wide path (no error if it exists). Init-time only. +void make_dir_tree(const wchar_t* path) { + wchar_t tmp[MAX_PATH]; + wcsncpy_s(tmp, path, _TRUNCATE); + for (wchar_t* p = tmp + 3; *p; ++p) { // skip drive "C:\" + if (*p == L'\\') { *p = 0; CreateDirectoryW(tmp, nullptr); *p = L'\\'; } + } + CreateDirectoryW(tmp, nullptr); +} + +// Build "\py4gw-YYYYMMDD-HHMMSS-pid-tid" (no extension). Crash-safe (no heap). +void build_stem(wchar_t* out, size_t cap) { + SYSTEMTIME st; GetLocalTime(&st); + _snwprintf_s(out, cap, _TRUNCATE, + L"%s\\py4gw-%04u%02u%02u-%02u%02u%02u-%lu-%lu", + s_crash_dir, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, + GetCurrentProcessId(), GetCurrentThreadId()); +} + +// Append one CRASH line to Py4GW_injection_log.txt (relative -> CWD, same place the Logger +// writes). Crash-safe: CreateFileW(FILE_APPEND_DATA) + WriteFile, no heap, shared access. +void append_injection_log(const char* line) { + HANDLE h = CreateFileW(L"Py4GW_injection_log.txt", FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + DWORD w = 0; WriteFile(h, line, static_cast(strlen(line)), &w, nullptr); + CloseHandle(h); +} + +// Write GW's full crash report (s_gw_text) to a human-readable sidecar. Crash-safe: raw WriteFile, +// no heap/escaping (the JSON keeps only a preview, this file keeps the whole thing verbatim). +void write_gwtext(const wchar_t* path, const char* text) { + HANDLE h = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + DWORD w = 0; WriteFile(h, text, static_cast(strlen(text)), &w, nullptr); + CloseHandle(h); +} + +} // namespace + +CrashHandler& CrashHandler::Instance() { + static CrashHandler inst; + return inst; +} + +std::string CrashHandler::CrashDirUtf8() const { return s_crash_dir_utf8; } + +bool CrashHandler::EnsureCrashDir() { + if (s_crash_dir_ready) return true; + wchar_t base[MAX_PATH] = {0}; + // Match the Logger's PER-CLIENT behavior. The injection log is written via a RELATIVE path, + // so it lands in the process CWD. Initialize() runs during DLL init -- BEFORE Py4GW's + // first-frame ChangeWorkingDirectory(dllDirectory) (Py4GW.cpp:1987) -- so the CWD here is + // still the GW client's OWN directory. Capturing it now puts crashes/ inside each client's + // directory, next to that client's Py4GW_injection_log.txt. (The native get_crash_dir() + // binding hands this same per-client path to the Python side so all artifacts stay together, + // even though Python runs after the CWD flips to the shared DLL folder.) + DWORD n = GetCurrentDirectoryW(MAX_PATH, base); + if (n == 0 || n >= MAX_PATH) { // fallback: the Py4GW.dll directory + int m = MultiByteToWideChar(CP_UTF8, 0, dllDirectory.c_str(), -1, base, MAX_PATH); + if (m <= 0 || base[0] == 0) return false; + } + _snwprintf_s(s_crash_dir, MAX_PATH, _TRUNCATE, L"%s\\crashes", base); + make_dir_tree(s_crash_dir); + s_crash_dir_ready = true; + char u8[MAX_PATH * 2]; + if (WideCharToMultiByte(CP_UTF8, 0, s_crash_dir, -1, u8, sizeof(u8), nullptr, nullptr) > 0) + s_crash_dir_utf8 = u8; + return true; +} + +// --------------------------------------------------------------------------- +// Install / teardown (runs at DLL init/term, NOT crash time -> Logger ok here) +// --------------------------------------------------------------------------- + +void CrashHandler::Initialize() { + if (s_installed) return; // idempotent: Initialize() runs more than once + s_installed = true; + EnsureCrashDir(); // pre-cache wide + utf8 dir at NON-crash time + ClearCallbackFilterPolicy(); + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + InstallPathA(); + InstallPathB(); + InstallPathC(); // best-effort; logs and continues on miss + Logger::Instance().LogInfo("[CrashHandler] installed (A=SEH, B=panic, C=scanner)."); +} + +void CrashHandler::Terminate() { + if (!s_installed) return; + s_installed = false; + if (s_append_stack_fn) { // Path C: disable before remove (MinHook rule) + GW::HookBase::DisableHooks(reinterpret_cast(s_append_stack_fn)); + GW::HookBase::RemoveHook(reinterpret_cast(s_append_stack_fn)); + s_append_stack_fn = 0; + s_append_stack_orig = nullptr; // null trampoline after removal (no stale calls) + } + SetUnhandledExceptionFilter(s_prev_filter); // restore Path A + GW::RegisterPanicHandler(nullptr, nullptr); // null Path B + RestoreCallbackFilterPolicy(); + InterlockedExchange(&s_handling, 0); // reset guards so a re-Initialize() is clean + InterlockedExchange(&s_wndproc_dumps, 0); + Logger::Instance().LogInfo("[CrashHandler] torn down."); +} + +void CrashHandler::ClearCallbackFilterPolicy() { + // Clear PROCESS_CALLBACK_FILTER_ENABLED (bit 0) so WoW64 kernel-callback faults (WndProc, + // D3D Present) propagate to the user-mode filter. Resolved dynamically; saved for restore. + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + using GetFn = BOOL(WINAPI*)(LPDWORD); + using SetFn = BOOL(WINAPI*)(DWORD); + auto get = reinterpret_cast(GetProcAddress(k32, "GetProcessUserModeExceptionPolicy")); + auto set = reinterpret_cast(GetProcAddress(k32, "SetProcessUserModeExceptionPolicy")); + if (!get || !set) return; + DWORD policy = 0; + if (!get(&policy)) return; + s_prev_policy = policy; + if (set(policy & 0xFFFFFFFEu)) s_policy_changed = true; +} + +void CrashHandler::RestoreCallbackFilterPolicy() { + if (!s_policy_changed) return; + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + using SetFn = BOOL(WINAPI*)(DWORD); + auto set = reinterpret_cast(GetProcAddress(k32, "SetProcessUserModeExceptionPolicy")); + if (set) set(s_prev_policy); + s_policy_changed = false; +} + +void CrashHandler::InstallPathA() { + s_prev_filter = SetUnhandledExceptionFilter(&CrashHandler::TopLevelFilter); +} + +void CrashHandler::InstallPathB() { + GW::RegisterPanicHandler(&CrashHandler::GwcaPanic, nullptr); +} + +void CrashHandler::InstallPathC() { + // Resolve GW's crash-message builder by its (stable) format-string anchor, then detour it. + // VERIFIED live: anchor lives inside the target; ToFunctionStart lands on its prologue. + uintptr_t use = GW::Scanner::FindUseOfString("%p %08x %08x %08x %08x "); + if (!use) { Logger::Instance().LogWarning("[CrashHandler] Path C anchor miss; A/B only."); return; } + s_append_stack_fn = GW::Scanner::ToFunctionStart(use, 0xfff); + if (!s_append_stack_fn) { Logger::Instance().LogWarning("[CrashHandler] Path C prologue miss."); return; } + int status = GW::HookBase::CreateHook( + reinterpret_cast(&s_append_stack_fn), + reinterpret_cast(&CrashHandler::AppendStackDetour), + &s_append_stack_orig); + if (status != 0 || !s_append_stack_orig) { + Logger::Instance().LogWarning("[CrashHandler] Path C CreateHook failed."); + s_append_stack_fn = 0; s_append_stack_orig = nullptr; return; + } + GW::HookBase::EnableHooks(reinterpret_cast(s_append_stack_fn)); + Logger::Instance().LogInfo("[CrashHandler] Path C attached (GW crash-message hook)."); +} + +// --------------------------------------------------------------------------- +// Path A / WndProc filters +// --------------------------------------------------------------------------- + +LONG WINAPI CrashHandler::TopLevelFilter(EXCEPTION_POINTERS* info) { + Instance().OnException(info, "seh", /*recoverable=*/false); + return EXCEPTION_EXECUTE_HANDLER; +} + +LONG CrashHandler::WndProcFilter(EXCEPTION_POINTERS* info) { + // Recoverable: historically these faults were swallowed and GW survived via CallWindowProc. + // Dump (rate-capped so a per-frame fault can't spam dumps), then fall through as before. + if (InterlockedIncrement(&s_wndproc_dumps) <= kMaxWndProcDumps) + OnException(info, "wndproc", /*recoverable=*/true); + return EXCEPTION_EXECUTE_HANDLER; +} + +// --------------------------------------------------------------------------- +// Path B: GWCA panic handler (5-arg). Self-dumps; abort() may never reach Path A. +// --------------------------------------------------------------------------- + +void CrashHandler::GwcaPanic(void* /*ctx*/, const char* expr, const char* file, + unsigned int line, const char* func) { + static_assert(sizeof(void*) == 4, "Py4GW crash handler is x86 (CONTEXT.Eip) only"); + _snprintf_s(s_assert_text, sizeof(s_assert_text), _TRUNCATE, + "GWCA_ASSERT(%s) at %s:%u in %s", expr ? expr : "?", + file ? file : "?", line, func ? func : "?"); + CONTEXT ctx; RtlCaptureContext(&ctx); // accurate panic-site context (not a throw site) + EXCEPTION_RECORD rec = {0}; + rec.ExceptionCode = 0xE0000001; // synthetic: GWCA assertion + rec.ExceptionFlags = EXCEPTION_NONCONTINUABLE; + rec.ExceptionAddress = reinterpret_cast(ctx.Eip); + EXCEPTION_POINTERS info = { &rec, &ctx }; + Instance().OnException(&info, "gwca_assert", /*recoverable=*/false); + // returns to FatalAssert -> abort(); the dump is already written. +} + +// --------------------------------------------------------------------------- +// Path C: detour GW's internal crash-message builder (verified __cdecl, 7 args). +// Forward ALL args to the trampoline, capture GW's text, then self-dump from its CONTEXT. +// --------------------------------------------------------------------------- + +uintptr_t __cdecl CrashHandler::AppendStackDetour(void* debug_info, uint32_t a2, uint32_t a3, + uint32_t a4, CONTEXT* ctx, uint32_t a6, + uint32_t a7) { + static_assert(sizeof(void*) == 4, "Py4GW crash handler is x86 (CONTEXT.Eip) only"); + using Fn = uintptr_t(__cdecl*)(void*, uint32_t, uint32_t, uint32_t, CONTEXT*, uint32_t, uint32_t); + // Defensive: the detour only runs while the hook is enabled (orig is set then, nulled only + // after RemoveHook), but guard against a teardown race rather than call through null. + if (!s_append_stack_orig) return 0; + // Let GW fill its own crash text first (caller cleans the stack; we forward all 7 args). + uintptr_t ret = reinterpret_cast(s_append_stack_orig)(debug_info, a2, a3, a4, ctx, a6, a7); + + // Capture GW's text (VERIFIED: text region at buf+0x20c, len-counted). Guard the read. + __try { + const char* gw = reinterpret_cast( + reinterpret_cast(debug_info) + 0x20c); + if (gw && *gw) { + strncpy_s(s_gw_text, gw, _TRUNCATE); + bc::breadcrumb_copy("gw_text", s_gw_text); + } + } __except (EXCEPTION_EXECUTE_HANDLER) { /* enrichment only; ignore */ } + + // GW is fatally crashing: synthesize an EXCEPTION from its CONTEXT and dump it. + if (ctx) { + EXCEPTION_RECORD rec = {0}; + rec.ExceptionCode = 0x80000003; // EXCEPTION_BREAKPOINT + rec.ExceptionFlags = EXCEPTION_NONCONTINUABLE; + rec.ExceptionAddress = reinterpret_cast(ctx->Eip); + EXCEPTION_POINTERS info = { &rec, ctx }; + Instance().OnException(&info, "gw_engine", /*recoverable=*/false); + } + return ret; +} + +// --------------------------------------------------------------------------- +// Shared crash sink + crash-time-safe writers +// --------------------------------------------------------------------------- + +bool CrashHandler::OnException(EXCEPTION_POINTERS* info, const char* source, bool recoverable) { + if (InterlockedCompareExchange(&s_handling, 1, 0) != 0) { + // A fault is already being handled (e.g. our writer itself faulted). Don't recurse. + if (!recoverable) TerminateProcess(GetCurrentProcess(), 1); + return false; + } + if (s_crash_dir_ready) { + wchar_t stem[MAX_PATH], dmp[MAX_PATH], json[MAX_PATH]; + build_stem(stem, MAX_PATH); + _snwprintf_s(dmp, MAX_PATH, _TRUNCATE, L"%s.dmp", stem); + _snwprintf_s(json, MAX_PATH, _TRUNCATE, L"%s.json", stem); + const wchar_t* slash = wcsrchr(dmp, L'\\'); + const wchar_t* dmp_name = slash ? slash + 1 : dmp; + + // Path C populates GW's full crash report -> give it its own -gwtext.txt sidecar. + wchar_t gwt[MAX_PATH] = {0}; + const wchar_t* gwt_name = L""; + if (s_gw_text[0]) { + _snwprintf_s(gwt, MAX_PATH, _TRUNCATE, L"%s-gwtext.txt", stem); + const wchar_t* gslash = wcsrchr(gwt, L'\\'); + gwt_name = gslash ? gslash + 1 : gwt; + } + + bc::LastPyFrame f = bc::read_last_py_frame(); // POD copy, no Py API + char comment[768]; + _snprintf_s(comment, sizeof(comment), _TRUNCATE, + "Py4GW %s | %s | py:%s:%d %s | %s", + PY4GW_VERSION, source, f.file[0] ? f.file : "?", f.line, + f.func[0] ? f.func : "?", s_assert_text[0] ? s_assert_text : ""); + + WriteSidecar(info, json, dmp_name, gwt_name, source); // JSON first: survives a dump failure + WriteDump(info, dmp, comment); + if (gwt[0]) write_gwtext(gwt, s_gw_text); // full GW report, verbatim + + char dmp_name_u8[MAX_PATH]; // narrow copy: avoid locale-dependent %S in printf + if (WideCharToMultiByte(CP_UTF8, 0, dmp_name, -1, dmp_name_u8, sizeof(dmp_name_u8), nullptr, nullptr) <= 0) + dmp_name_u8[0] = 0; + char log_line[320]; + DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0; + _snprintf_s(log_line, sizeof(log_line), _TRUNCATE, + "CRASH %s 0x%08lX py:%s:%d -> see crashes\\%s\r\n", + source, static_cast(code), f.file[0] ? f.file : "?", f.line, dmp_name_u8); + append_injection_log(log_line); + } + if (recoverable) InterlockedExchange(&s_handling, 0); // allow future WndProc dumps + return true; +} + +void CrashHandler::WriteSidecar(EXCEPTION_POINTERS* info, const wchar_t* json_path, + const wchar_t* dmp_name, const wchar_t* gwtext_name, + const char* source) { + HANDLE file = CreateFileW(json_path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return; + + bc::LastPyFrame f = bc::read_last_py_frame(); + DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0; + uintptr_t addr = (info && info->ExceptionRecord) + ? reinterpret_cast(info->ExceptionRecord->ExceptionAddress) : 0; + + char efile[300], efunc[160], eassert[600], egw[1100], edmp[160]; + json_escape(efile, sizeof(efile), f.file); + json_escape(efunc, sizeof(efunc), f.func); + json_escape(eassert, sizeof(eassert), s_assert_text); + json_escape(egw, sizeof(egw), s_gw_text); // preview only; full report -> gw_text_file + char dmp_u8[MAX_PATH]; + if (WideCharToMultiByte(CP_UTF8, 0, dmp_name, -1, dmp_u8, sizeof(dmp_u8), nullptr, nullptr) <= 0) + dmp_u8[0] = 0; + json_escape(edmp, sizeof(edmp), dmp_u8); + char egwt[MAX_PATH] = {0}; // -gwtext.txt name (empty unless Path C) + if (gwtext_name && gwtext_name[0]) { + char gwt_u8[MAX_PATH]; + if (WideCharToMultiByte(CP_UTF8, 0, gwtext_name, -1, gwt_u8, sizeof(gwt_u8), nullptr, nullptr) <= 0) + gwt_u8[0] = 0; + json_escape(egwt, sizeof(egwt), gwt_u8); + } + + char buf[8192]; // generous: worst-case gw_text (~1.1K) + 16 escaped breadcrumbs (~4K) must fit + JBuf b { buf, buf + sizeof(buf) }; + jappend(b, "{\"version\":\"%s\",\"source\":\"%s\",\"crash_class\":\"%s\",", + PY4GW_VERSION, source, exception_label(code)); + jappend(b, "\"exception_code\":\"0x%08lX\",\"fault_address\":\"0x%08lX\",\"faulting_tid\":%lu,", + static_cast(code), static_cast(addr), GetCurrentThreadId()); + jappend(b, "\"dump_file\":\"%s\",", edmp); + if (egwt[0]) jappend(b, "\"gw_text_file\":\"%s\",", egwt); + jappend(b, "\"python_last_frame\":{\"file\":\"%s\",\"line\":%d,\"func\":\"%s\"},", + efile, f.line, efunc); + jappend(b, "\"assert\":\"%s\",\"gw_text\":\"%s\",", eassert, egw); + jappend(b, "\"breadcrumbs\":["); + bool first = true; + bc::drain_recent(16, [&](const char* m) { + char em[256]; json_escape(em, sizeof(em), m); + jappend(b, "%s\"%s\"", first ? "" : ",", em); + first = false; + }); + jappend(b, "]}\n"); + + DWORD written = 0; + WriteFile(file, buf, static_cast(b.p - buf), &written, nullptr); + CloseHandle(file); +} + +void CrashHandler::WriteDump(EXCEPTION_POINTERS* info, const wchar_t* dmp_path, const char* comment) { + HANDLE file = CreateFileW(dmp_path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return; + MINIDUMP_EXCEPTION_INFORMATION mei = {0}; + mei.ThreadId = GetCurrentThreadId(); + mei.ExceptionPointers = info; + mei.ClientPointers = FALSE; + MINIDUMP_USER_STREAM us = {0}; + us.Type = CommentStreamA; // == 10 (MINIDUMP_STREAM_TYPE in dbghelp.h) + us.BufferSize = static_cast(strlen(comment) + 1); + us.Buffer = const_cast(comment); + MINIDUMP_USER_STREAM_INFORMATION usi = { 1, &us }; + const MINIDUMP_TYPE flags = static_cast(0x1041); // DataSegs|IndirectRefd|ThreadInfo + __try { + // dbghelp HeapAllocs internally; guard against a fault on heap-corruption crashes. + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, flags, + info ? &mei : nullptr, &usi, nullptr); + } __except (EXCEPTION_EXECUTE_HANDLER) { /* partial/absent dump; sidecar already written */ } + CloseHandle(file); +} diff --git a/src/Py4GW.cpp b/src/Py4GW.cpp index bd920da..7aaee1d 100644 --- a/src/Py4GW.cpp +++ b/src/Py4GW.cpp @@ -4,6 +4,8 @@ #include "FrameClock.h" #include "py_dialog.h" #include "name_obfuscation.h" +#include "CrashHandler.h" +#include "Breadcrumbs.h" #include //HeroAI* Py4GW::heroAI = nullptr; @@ -2019,7 +2021,6 @@ void Py4GW::Draw(IDirect3DDevice9* device) { if ((GW::Map::GetMapInfo()->flags & 0x40001) != 0) return; } - } if (show_console || is_map_loading) { @@ -2355,6 +2356,44 @@ void bind_Ping(py::module_& m) } +// crashlog submodule: breadcrumb bridge used by py4gwcorelib_src/CrashLog.py. +static void bind_CrashLog(py::module_& m) +{ + // Snapshot the last Python frame into per-thread TLS (GIL held by the caller). + m.def("set_last_frame", + [](const std::string& file, int line, const std::string& func) { + bc::set_last_py_frame(file.c_str(), line, func.c_str()); + }, + "Record the last Python frame for the native crash handler", + py::arg("file"), py::arg("line"), py::arg("func")); + + // Coarse one-line breadcrumb into the lock-free ring. + m.def("breadcrumb", + [](const std::string& text) { bc::breadcrumb(text.c_str()); }, + "Append a coarse breadcrumb to the crash ring", + py::arg("text")); + + // Native crash dir (/crashes) so CrashLog.py writes *-pytrace.txt beside the .dmp/.json. + m.def("get_crash_dir", + []() { return CrashHandler::Instance().CrashDirUtf8(); }, + "Return the native crash directory (UTF-8)"); +} + +// Optional debug submodule: deliberate fault to test Path A. Gated to debug builds. +static void bind_Debug(py::module_& m) +{ +#ifdef PY4GW_DEBUG_BUILD + m.def("crash", + []() { + volatile int* p = nullptr; + *p = 1; // intentional access violation -> Path A writes a dump + }, + "TEST ONLY: trigger a native access violation to exercise the crash handler"); +#else + (void)m; +#endif +} + PYBIND11_EMBEDDED_MODULE(Py4GW, m) { m.doc() = "Py4GW, Python Enabler Library for GuildWars"; // Optional module docstring @@ -2371,6 +2410,11 @@ PYBIND11_EMBEDDED_MODULE(Py4GW, m) bind_ScriptControl(console); bind_Profiler(console); bind_Ping(m); + + py::module_ crashlog = m.def_submodule("crashlog", "Crash breadcrumb bridge"); + bind_CrashLog(crashlog); + py::module_ debug = m.def_submodule("debug", "Debug / crash testing"); + bind_Debug(debug); } diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 102ac76..c15ba41 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -4,6 +4,7 @@ #include "Py4GW.h" #include "Headers.h" +#include "CrashHandler.h" #include "WinUser.h" #include "hidusage.h" #include @@ -68,6 +69,9 @@ bool DLLMain::Initialize() { Logger::Instance().LogError("[DLLMain] Failed to initialize GWCA"); return false; } + + // Install the crash handler now that GWCA is live (Paths B/C need it). Idempotent. + CrashHandler::Instance().Initialize(); // Get Guild Wars window handle if (!initialized) Logger::Instance().LogInfo("[DLLMain] Attempting to get GW window handle..."); @@ -128,6 +132,7 @@ void DLLMain::Terminate() { GW::GameThread::RemoveGameThreadCallback(&Update_Entry); dat_texture_update_attached = false; Logger::Instance().LogInfo("Terminating DLL..."); + CrashHandler::Instance().Terminate(); Py4GW::Instance().Terminate(); // Clean up ImGui @@ -490,6 +495,9 @@ LRESULT CALLBACK DLLMain::SafeWndProc(const HWND hWnd, const UINT Message, const return WndProc(hWnd, Message, wParam, lParam); } __except (EXCEPTION_EXECUTE_HANDLER) { + // Routine swallow (NOT a crash sink): GW's WndProc raises survivable software + // exceptions; dumping them produced useless code-0 dumps. Real crashes are caught by + // Path A (SetUnhandledExceptionFilter). Left as the original silent fallback. return CallWindowProc(OldWndProc, hWnd, Message, wParam, lParam); } } From f7b6b3826f5cdef000a23e9e27e3d35020ed02db Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:15:27 +0200 Subject: [PATCH 5/5] Sync files locally with Apo repo. --- include/FrameClock.h | 9 --- include/name_obfuscation.h | 61 ------------------ src/FrameClock.cpp | 10 --- src/py_name_obfuscation.cpp | 123 ------------------------------------ 4 files changed, 203 deletions(-) delete mode 100644 include/FrameClock.h delete mode 100644 include/name_obfuscation.h delete mode 100644 src/FrameClock.cpp delete mode 100644 src/py_name_obfuscation.cpp diff --git a/include/FrameClock.h b/include/FrameClock.h deleted file mode 100644 index f62e6d6..0000000 --- a/include/FrameClock.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once -#include - -namespace frame_clock { - // Returns the per-frame monotonically-increasing timestamp set at the - // top of Py4GW::Draw() before SHMem publish. Use this instead of - // GetTickCount64() in any code that needs to bucket events by frame. - uint64_t GetFrameTimestamp(); -} diff --git a/include/name_obfuscation.h b/include/name_obfuscation.h deleted file mode 100644 index f077ce6..0000000 --- a/include/name_obfuscation.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#ifndef NAME_OBFUSCATION_H -#define NAME_OBFUSCATION_H - -#include "Headers.h" - -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -// NameObfuscation -// -// Rewrites player names in incoming StoC packets BEFORE the game stores them, -// so all downstream rendering (nametags, party frame, target pane) shows the -// fake name. Also blanks the guild tag while active. -// -// Lifecycle: -// Init() -- registers StoC packet callbacks once on DLL init -// Shutdown() -- removes callbacks and clears the alias table -// --------------------------------------------------------------------------- - -class NameObfuscation { -public: - static NameObfuscation& Instance() { - static NameObfuscation instance; - return instance; - } - - void Init(); - void Shutdown(); - - void Enable() { enabled_ = true; } - void Disable() { enabled_ = false; } - bool IsEnabled() const { return enabled_; } - - void SetAlias(const std::wstring& real_name, const std::wstring& fake_name); - void RemoveAlias(const std::wstring& real_name); - void ClearAliases(); - size_t AliasCount(); - - // packet-hook side - bool LookupAlias(const wchar_t* real_name, std::wstring& fake_out); - -private: - NameObfuscation() = default; - ~NameObfuscation() = default; - NameObfuscation(const NameObfuscation&) = delete; - NameObfuscation& operator=(const NameObfuscation&) = delete; - - std::atomic enabled_{false}; - std::mutex mutex_; - std::unordered_map aliases_; - - GW::HookEntry player_join_hook_{}; - GW::HookEntry guild_general_hook_{}; - bool hooks_registered_ = false; -}; - -#endif // NAME_OBFUSCATION_H diff --git a/src/FrameClock.cpp b/src/FrameClock.cpp deleted file mode 100644 index 53de218..0000000 --- a/src/FrameClock.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include "FrameClock.h" -#include - -namespace frame_clock { - extern uint64_t g_frame_id_timestamp; // defined in Py4GW.cpp - - uint64_t GetFrameTimestamp() { - return g_frame_id_timestamp ? g_frame_id_timestamp : GetTickCount64(); - } -} diff --git a/src/py_name_obfuscation.cpp b/src/py_name_obfuscation.cpp deleted file mode 100644 index da9d7b8..0000000 --- a/src/py_name_obfuscation.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "name_obfuscation.h" - -#include - -namespace py = pybind11; - -// --------------------------------------------------------------------------- -// Static StoC callbacks -// --------------------------------------------------------------------------- - -static void OnPlayerJoinInstance(GW::HookStatus*, GW::Packet::StoC::PlayerJoinInstance* pak) { - if (!pak || !pak->player_name[0]) return; - - auto& svc = NameObfuscation::Instance(); - if (!svc.IsEnabled()) return; - - std::wstring fake; - if (svc.LookupAlias(pak->player_name, fake)) { - // pak->player_name is wchar_t[32]; copy with bounds check. - const size_t max_chars = 31; - size_t to_copy = fake.size(); - if (to_copy > max_chars) to_copy = max_chars; - std::memcpy(pak->player_name, fake.data(), to_copy * sizeof(wchar_t)); - pak->player_name[to_copy] = L'\0'; - } -} - -static void OnGuildGeneral(GW::HookStatus*, GW::Packet::StoC::GuildGeneral* pak) { - if (!pak) return; - auto& svc = NameObfuscation::Instance(); - if (!svc.IsEnabled()) return; - if (svc.AliasCount() == 0) return; - pak->tag[0] = L'\0'; -} - -// --------------------------------------------------------------------------- -// NameObfuscation impl -// --------------------------------------------------------------------------- - -void NameObfuscation::Init() { - if (hooks_registered_) return; - GW::StoC::RegisterPacketCallback( - &player_join_hook_, - [](GW::HookStatus* s, GW::Packet::StoC::PlayerJoinInstance* p) { OnPlayerJoinInstance(s, p); }, - -0x8000); - - GW::StoC::RegisterPacketCallback( - &guild_general_hook_, - [](GW::HookStatus* s, GW::Packet::StoC::GuildGeneral* p) { OnGuildGeneral(s, p); }, - -0x8000); - - hooks_registered_ = true; -} - -void NameObfuscation::Shutdown() { - if (hooks_registered_) { - GW::StoC::RemoveCallbacks(&player_join_hook_); - GW::StoC::RemoveCallbacks(&guild_general_hook_); - hooks_registered_ = false; - } - enabled_ = false; - ClearAliases(); -} - -void NameObfuscation::SetAlias(const std::wstring& real_name, const std::wstring& fake_name) { - std::lock_guard lock(mutex_); - aliases_[real_name] = fake_name; -} - -void NameObfuscation::RemoveAlias(const std::wstring& real_name) { - std::lock_guard lock(mutex_); - aliases_.erase(real_name); -} - -void NameObfuscation::ClearAliases() { - std::lock_guard lock(mutex_); - aliases_.clear(); -} - -size_t NameObfuscation::AliasCount() { - std::lock_guard lock(mutex_); - return aliases_.size(); -} - -bool NameObfuscation::LookupAlias(const wchar_t* real_name, std::wstring& fake_out) { - if (!real_name) return false; - std::lock_guard lock(mutex_); - auto it = aliases_.find(real_name); - if (it == aliases_.end()) return false; - fake_out = it->second; - return true; -} - -// --------------------------------------------------------------------------- -// pybind11 module: PyNameObfuscation -// --------------------------------------------------------------------------- - -PYBIND11_EMBEDDED_MODULE(PyNameObfuscation, m) { - m.doc() = "Player name obfuscation: rewrites incoming PlayerJoinInstance " - "and blanks guild tags so the UI shows fake names."; - - m.def("enable", []() { NameObfuscation::Instance().Enable(); }, - "Turn the name rewrite on. Takes effect on the next PlayerJoinInstance packet."); - m.def("disable", []() { NameObfuscation::Instance().Disable(); }, - "Turn the name rewrite off. Names already cached by the game persist until re-zone."); - m.def("is_enabled", []() { return NameObfuscation::Instance().IsEnabled(); }); - - m.def("set_alias", - [](const std::wstring& real, const std::wstring& fake) { - NameObfuscation::Instance().SetAlias(real, fake); - }, - py::arg("real_name"), py::arg("fake_name"), - "Register or update a real -> fake player-name mapping."); - - m.def("remove_alias", - [](const std::wstring& real) { NameObfuscation::Instance().RemoveAlias(real); }, - py::arg("real_name")); - - m.def("clear", []() { NameObfuscation::Instance().ClearAliases(); }, - "Drop every alias."); - - m.def("alias_count", []() { return (uint32_t)NameObfuscation::Instance().AliasCount(); }); -}