diff --git a/README.md b/README.md index 5aa2e1d..a530a70 100644 --- a/README.md +++ b/README.md @@ -75,18 +75,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Detect a terminated target and reflect it in the UI.** The Injection panel keeps - showing "Attached" after the game exits. Add a **Terminated** state: the host - already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top - of that, hold the `OpenProcess` handle from injection (or re-open with - `PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` / - `WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to - Terminated, gray out / disable the per-subsystem controls and mirror toggles, and - show a clear banner; the Video and Audio panels should drop to idle (their hook - channels are stale) rather than freezing on the last live frame/state. Note a live - process isn't proof it's running — also flag a **stalled heartbeat** (no advance - for ~2 s while the process still exists) as a distinct "hung / not responding" - state, since games here can freeze without exiting. - **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid, but the UI still holds the stale one. In the Terminated state, remember the target's image name (the panel already keeps the selected exe name) and offer a diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 3498feb..6a0e706 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -69,6 +69,64 @@ InjectionPanel::InjectionPanel() refresh_processes(); } +InjectionPanel::~InjectionPanel() +{ + close_target_handle(); +} + +void InjectionPanel::close_target_handle() +{ + if (target_process_ != nullptr) + { + CloseHandle(target_process_); + target_process_ = nullptr; + } + target_state_ = TargetState::NotInjected; + dll_alive_ = false; + last_heartbeat_ = 0; + last_heartbeat_time_ = 0.0; +} + +void InjectionPanel::tick() +{ + update_liveness(); +} + +void InjectionPanel::update_liveness() +{ + if (!injected_) + { + target_state_ = TargetState::NotInjected; + return; + } + + // Process gone? The handle was opened with SYNCHRONIZE at inject time, so a + // signaled wait means it exited. This is authoritative even if the heartbeat + // happened to look alive a moment ago. + if (target_process_ != nullptr && WaitForSingleObject(target_process_, 0) == WAIT_OBJECT_0) + { + target_state_ = TargetState::Terminated; + dll_alive_ = false; + return; + } + + // Still running: alive vs hung from the hook heartbeat (advances ~4x/s). A live + // process whose heartbeat stalled for ~2 s is frozen, not gone -- a distinct state. + const std::uint32_t hb = server_.hook_status().heartbeat; + const double now = ImGui::GetTime(); + if (hb != last_heartbeat_) + { + last_heartbeat_ = hb; + last_heartbeat_time_ = now; + dll_alive_ = true; + } + else if (now - last_heartbeat_time_ > 2.0) + { + dll_alive_ = false; + } + target_state_ = dll_alive_ ? TargetState::Alive : TargetState::Hung; +} + void InjectionPanel::refresh_processes() { processes_ = list_processes(); @@ -102,6 +160,14 @@ void InjectionPanel::inject_selected() if (result.status == InjectStatus::Ok) { injected_ = true; + // Track liveness: a SYNCHRONIZE|QUERY handle lets us notice the game exiting, + // and seeding the heartbeat clock avoids a spurious "hung" before the first beat. + close_target_handle(); // drop any handle from a previous target + target_process_ = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, selected_pid_); + target_state_ = TargetState::Alive; + last_heartbeat_ = 0; + last_heartbeat_time_ = ImGui::GetTime(); + dll_alive_ = true; status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ")."; status_color_ = kGreen; } @@ -273,19 +339,6 @@ void InjectionPanel::draw_hook_status(bool debug_details) const HookStatusView status = server_.hook_status(); - // DLL liveness from the heartbeat (advances ~4x/s while the worker runs). - const double now = ImGui::GetTime(); - if (status.heartbeat != last_heartbeat_) - { - last_heartbeat_ = status.heartbeat; - last_heartbeat_time_ = now; - dll_alive_ = true; - } - else if (now - last_heartbeat_time_ > 1.5) - { - dll_alive_ = false; - } - ImGui::SeparatorText("Hook status"); if (!injected_) { @@ -293,17 +346,26 @@ void InjectionPanel::draw_hook_status(bool debug_details) return; } - if (dll_alive_) + switch (target_state_) { + case TargetState::Alive: ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(), status.heartbeat); - } - else - { - ImGui::TextColored(kRed, "Hook DLL not responding (no heartbeat)."); + break; + case TargetState::Hung: + ImGui::TextColored(kRed, "Target not responding -- heartbeat stalled (frozen?)."); + break; + case TargetState::Terminated: + ImGui::TextColored(kRed, "Target process has exited."); + break; + case TargetState::NotInjected: + break; } + // The game is gone or frozen -> its hooks can't act on toggles, so lock them. + ImGui::BeginDisabled(target_state_ != TargetState::Alive); draw_subsystem_controls(status); + ImGui::EndDisabled(); ImGui::TextDisabled("Controller poll rates are in the Controllers panel."); draw_hook_list(status); @@ -348,11 +410,23 @@ void InjectionPanel::draw(bool debug_details) if (server_.running()) { - ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid()); + if (target_state_ == TargetState::Terminated) + { + ImGui::TextColored(kRed, "Target (pid %lu) has terminated.", server_.target_pid()); + } + else if (target_state_ == TargetState::Hung) + { + ImGui::TextColored(kRed, "Target (pid %lu) is not responding.", server_.target_pid()); + } + else + { + ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid()); + } if (ImGui::Button("Disconnect")) { server_.stop(); injected_ = false; + close_target_handle(); status_ = "Stopped."; status_color_ = kGrey; } @@ -401,8 +475,9 @@ void InjectionPanel::draw(bool debug_details) ImGui::TextColored(status_color_, "%s", status_.c_str()); } - // Synthetic input only reaches the game if the XInput hook is installed. - ImGui::BeginDisabled(injected_ && !want_input_); + // Synthetic input only reaches the game if the XInput hook is installed and the + // target is actually alive to receive it. + ImGui::BeginDisabled(injected_ && (!want_input_ || target_state_ != TargetState::Alive)); ImGui::Checkbox("Forward synthetic test input", &test_input_); ImGui::EndDisabled(); if (test_input_) diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index e083551..93a025e 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -16,10 +16,28 @@ namespace coop { +// Liveness of the injected target, surfaced in the UI so a dead/hung game is obvious. +enum class TargetState +{ + NotInjected, // no hook loaded + Alive, // process running and the hook heartbeat is advancing + Hung, // process still exists but the heartbeat stalled (not responding) + Terminated, // process has exited +}; + class InjectionPanel { public: InjectionPanel(); + ~InjectionPanel(); + + InjectionPanel(const InjectionPanel&) = delete; + InjectionPanel& operator=(const InjectionPanel&) = delete; + + // Recompute target liveness (terminated / hung / alive). Call once per frame from + // the main loop, independent of panel visibility, so game_hwnd() and the mirror + // panels react to a dead target even while the Injection panel is hidden. + void tick(); // `debug_details` shows the verbose hook diagnostics (per-slot poll table, // focus-API counts, input-path detection); off shows a general summary. @@ -29,12 +47,24 @@ public: // test-input mode is on, a synthetic pattern is sent instead of `pads`. void publish(const std::array& pads); - // The injected game's main window, as reported by the hook (null if none). + // The injected game's main window, as reported by the hook (null if none). A + // terminated target's HWND is stale/invalid, so report none -- the capture and + // audio panels then drop to idle instead of chasing a dead window. [[nodiscard]] HWND game_hwnd() const { + if (target_state_ == TargetState::Terminated) + { + return nullptr; + } return reinterpret_cast(server_.hook_status().game_hwnd); } + // Current liveness of the injected target (for other panels / status). + [[nodiscard]] TargetState target_state() const + { + return target_state_; + } + // The hook's full diagnostics back-channel (other panels read the audio // render-stream counts from here). [[nodiscard]] HookStatusView hook_status() const @@ -78,6 +108,8 @@ public: private: void refresh_processes(); void inject_selected(); + void update_liveness(); // recompute target_state_ from process + heartbeat + void close_target_handle(); // close target_process_ and reset liveness state void draw_subsystem_controls(const HookStatusView& status); void draw_hook_list(const HookStatusView& status); void draw_hook_status(bool debug_details); @@ -105,6 +137,12 @@ private: std::uint32_t last_heartbeat_ = 0; double last_heartbeat_time_ = 0.0; bool dll_alive_ = false; + + // Target-process liveness: a SYNCHRONIZE|QUERY handle taken at inject time lets + // us notice the game exiting (WaitForSingleObject) vs merely hanging (heartbeat + // stalled while the process still exists). + HANDLE target_process_ = nullptr; + TargetState target_state_ = TargetState::NotInjected; }; } // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp index df578d9..5d02598 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -145,6 +145,7 @@ int run() #endif input->poll(); injection.publish(input->pads()); + injection.tick(); // refresh target liveness before the mirror panels read game_hwnd() const HWND game = injection.game_hwnd(); capture.set_target(game); audio.set_target(game);