Prechádzať zdrojové kódy

add inactivity timeout override

Zach Humes 3 týždňov pred
rodič
commit
4339282137

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -217,6 +217,9 @@
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
+    <ClCompile Include="src\client\inactivity\inactivity_settings_store.cpp" />
+    <ClCompile Include="src\client\hooks\inactivity\inactivity_override.cpp" />
+    <ClCompile Include="src\client\ui\inactivity\inactivity_panel.cpp" />
     <ClCompile Include="src\client\ui\player\player_panel.cpp" />
     <ClCompile Include="src\client\player\player_position.cpp" />
     <ClCompile Include="src\client\player\player_settings_store.cpp" />
@@ -786,6 +789,9 @@
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
+    <ClInclude Include="src\client\inactivity\inactivity_settings_store.h" />
+    <ClInclude Include="src\client\hooks\inactivity\inactivity_override.h" />
+    <ClInclude Include="src\client\ui\inactivity\inactivity_panel.h" />
     <ClInclude Include="src\client\ui\player\player_panel.h" />
     <ClInclude Include="src\client\player\player_position.h" />
     <ClInclude Include="src\client\player\player_settings_store.h" />

+ 3 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp

@@ -13,6 +13,7 @@
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../../../core/ui/scaling/dpi/ui_dpi_scaling.h"
 #include "../../../../core/ui/theme/sunrise_ui_theme.h"
+#include "../../inactivity/inactivity_override.h"
 #include "../input/input.h"
 #include "graphics_renderer_report.h"
 #include "state.h"
@@ -126,6 +127,8 @@ void render_frame_locked() noexcept {
     if (!fully_active_locked()) {
         return;
     }
+    // A steady tick with the game fully up, which is all the timeout hold needs.
+    hooks::inactivity::poll();
     if (core::ui::scaling::dpi::update(g_resources.window)) {
         // Style and text scale change together, before the backend sets up the frame.
         core::ui::theme::apply();

+ 252 - 0
Sunrise/src/client/hooks/inactivity/inactivity_override.cpp

@@ -0,0 +1,252 @@
+/**
+ * Inactivity timeout override.
+ *
+ * The Client keeps one timeout per activity lane and ends a session whose controller has been
+ * idle for longer. The lanes are not image data: they sit at a fixed offset inside a live object,
+ * and the pointer to that object is stored obfuscated, so the Client reaches it through a getter
+ * that decodes the pointer on each call. This module resolves that getter by signature and calls
+ * it the same way, which is the same shape the camera pose block is reached by.
+ *
+ * No lane the Client authors is written down anywhere. A block that is not the one this module
+ * last wrote is the Client's own, so reading before each hold both takes the value a lane is put
+ * back to and follows an activity change, which re-authors the whole block.
+ */
+
+#include "inactivity_override.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../inactivity/inactivity_settings_store.h"
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::inactivity {
+namespace {
+
+namespace settings = client::inactivity;
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * The activity config getter. Its body is the shared shape every obfuscated pointer getter has,
+ * so the load of its own global is what tells it apart: a RIP-relative displacement encodes a
+ * distance rather than an address, carries no position-dependent bytes, and is the only part of
+ * this prologue unique to this getter. Call and branch displacements stay wildcarded.
+ */
+constexpr std::string_view kConfigGetterText =
+    "40 53 48 83 EC 20 48 8B 1D 2B 10 1A 02 48 85 DB 0F 84 ? ? ? ? 48 89 5C 24 30 "
+    "E8 ? ? ? ? 33 C3";
+/** Compiled pattern bytes of the config getter signature. */
+constexpr auto kConfigGetter = signature<signature_length(kConfigGetterText)>(kConfigGetterText);
+
+/** Where the lanes start in the object the getter returns. */
+constexpr std::size_t kTimeoutBlockOffset = 0xAC;
+/** Milliseconds between re-applications, so an activity change cannot outlast the hold. */
+constexpr std::uint64_t kHoldIntervalMs = 2000;
+
+/** Fourteen consecutive milliseconds, in block order. */
+using Lanes = std::array<std::uint32_t, settings::kActivityCount>;
+/** Bytes of the block. */
+constexpr std::size_t kBlockBytes = sizeof(Lanes);
+
+/** Returns the activity config object. The pointer in its global is obfuscated, so we call it. */
+using ConfigGetter = std::byte*(__fastcall*)();
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+ConfigGetter g_getter{};
+/** The object the last call returned, kept only so the interface can show it. */
+std::uintptr_t g_object{};
+std::uint64_t g_nextHoldTick{};
+/** The block this module last wrote. Anything else in the object is the Client's own. */
+Lanes g_applied{};
+bool g_appliedValid{};
+/** The Client's own lanes for the activity in play. */
+Lanes g_captured{};
+bool g_capturedValid{};
+/** Set while a hold is in place, so releasing it writes the captured lanes exactly once. */
+bool g_holding{};
+
+/**
+ * Calls the getter without faulting. The body is obfuscated game code, and it runs before the
+ * Client has published its global on an early frame.
+ * @return The activity config object, or null.
+ */
+[[nodiscard]] std::byte* config_object() noexcept {
+    if (g_getter == nullptr) {
+        return nullptr;
+    }
+    __try {
+        return g_getter();
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return nullptr;
+    }
+}
+
+/**
+ * Reads the block out of the object.
+ * @param object Config object.
+ * @param values Receives the lanes.
+ * @return True when Windows copied all of them.
+ */
+[[nodiscard]] bool read_block(const std::byte* object, Lanes& values) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(GetCurrentProcess(),
+                             object + kTimeoutBlockOffset,
+                             values.data(),
+                             kBlockBytes,
+                             &read)
+               != FALSE
+           && read == kBlockBytes;
+}
+
+/**
+ * Writes one run of milliseconds into the object.
+ * @param object Config object.
+ * @param values Lanes in block order.
+ * @return True when Windows copied all of them.
+ */
+[[nodiscard]] bool write_block(std::byte* object, const Lanes& values) noexcept {
+    SIZE_T written = 0;
+    return WriteProcessMemory(GetCurrentProcess(),
+                              object + kTimeoutBlockOffset,
+                              values.data(),
+                              kBlockBytes,
+                              &written)
+               != FALSE
+           && written == kBlockBytes;
+}
+
+/**
+ * Takes the Client's own lanes, which are any lanes this module did not write.
+ * @param current Block just read out of the object.
+ */
+void capture_locked(const Lanes& current) noexcept {
+    // A zero lane is an object the Client has published but not authored yet.
+    const bool authored =
+        std::none_of(current.begin(), current.end(), [](std::uint32_t value) noexcept {
+            return value == 0;
+        });
+    if (!authored || (g_appliedValid && current == g_applied)) {
+        return;
+    }
+    g_captured = current;
+    g_capturedValid = true;
+}
+
+/**
+ * @param configured Current configuration.
+ * @return The lanes a hold puts in place.
+ */
+[[nodiscard]] Lanes held_lanes(const settings::Settings& configured) noexcept {
+    Lanes values = configured.custom ? configured.timeouts : settings::kDefaultTimeouts;
+    // Orbit is held at its longest whatever the grid or the file carries, because a timeout that
+    // fires there ends a session this Client cannot re-establish.
+    values[settings::kOrbitLane] = settings::kMaximumTimeoutMs;
+    return values;
+}
+
+/** Writes the captured lanes back and ends the hold. */
+void release_locked(std::byte* object) noexcept {
+    if (!g_holding || !g_capturedValid || !write_block(object, g_captured)) {
+        return;
+    }
+    g_holding = false;
+    g_appliedValid = false;
+}
+
+} // namespace
+
+/** Resolves the activity config getter. */
+bool install() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_getter != nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    }
+    std::byte* const match = scan_main_image_unique(kConfigGetter, "inactivity_config_getter");
+    if (match == nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=inactivity stage=install result=fail reason=target");
+        return false;
+    }
+    g_getter = reinterpret_cast<ConfigGetter>(match);
+    ReleaseSRWLockExclusive(&g_lock);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=inactivity stage=install result=ok");
+    return true;
+}
+
+/** Puts the Client's own lanes back and drops the resolved getter. */
+void uninstall() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (std::byte* const object = config_object(); object != nullptr) {
+        release_locked(object);
+    }
+    g_getter = nullptr;
+    g_object = 0;
+    g_nextHoldTick = 0;
+    g_applied = Lanes{};
+    g_appliedValid = false;
+    g_captured = Lanes{};
+    g_capturedValid = false;
+    g_holding = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Holds the configured milliseconds, or puts back the ones the Client authored. */
+void poll() noexcept {
+    const settings::Settings configured = settings::get();
+    AcquireSRWLockExclusive(&g_lock);
+    const std::uint64_t now = GetTickCount64();
+    if (g_getter == nullptr || now < g_nextHoldTick) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    g_nextHoldTick = now + kHoldIntervalMs;
+    std::byte* const object = config_object();
+    g_object = reinterpret_cast<std::uintptr_t>(object);
+    if (object == nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (Lanes current{}; read_block(object, current)) {
+        capture_locked(current);
+    }
+    if (!configured.enabled) {
+        release_locked(object);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    // Held rather than written once, because an activity change re-authors these lanes.
+    const Lanes desired = held_lanes(configured);
+    if (write_block(object, desired)) {
+        g_applied = desired;
+        g_appliedValid = true;
+        g_holding = true;
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Reports what the override reached. */
+Status status() noexcept {
+    Status output{};
+    AcquireSRWLockShared(&g_lock);
+    output.resolved = g_getter != nullptr;
+    output.address = g_object;
+    output.captured = g_capturedValid;
+    ReleaseSRWLockShared(&g_lock);
+    return output;
+}
+
+} // namespace sunrise::client::hooks::inactivity

+ 35 - 0
Sunrise/src/client/hooks/inactivity/inactivity_override.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::hooks::inactivity {
+
+/** What the override reached, which is what a lane not taking says. */
+struct Status {
+    /** Address of the activity config object, or zero until the Client publishes one. */
+    std::uintptr_t address{};
+    /** Set once the config getter has been found in the image. */
+    bool resolved{};
+    /** Set once the Client's own lanes have been read back. */
+    bool captured{};
+};
+
+/**
+ * Resolves the activity config getter, which the lanes are reached through.
+ * @return True when it was found.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Puts the Client's own lanes back and drops the resolved getter. */
+void uninstall() noexcept;
+
+/**
+ * Holds the configured milliseconds in the activity config object, or puts back the ones the
+ * Client authored. Call once a frame from any steady tick.
+ */
+void poll() noexcept;
+
+/** @return A consistent copy of what the override reached. */
+[[nodiscard]] Status status() noexcept;
+
+} // namespace sunrise::client::hooks::inactivity

+ 269 - 0
Sunrise/src/client/inactivity/inactivity_settings_store.cpp

@@ -0,0 +1,269 @@
+/**
+ * The inactivity configuration store. It is separate from Core settings because the interface
+ * changes these values while the game runs and saves each change at once, which Core settings,
+ * read once at boot, do not do.
+ */
+
+#include "inactivity_settings_store.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <cstdlib>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::inactivity {
+namespace {
+
+/** The module-owned configuration file, beside the generated settings and logs. */
+constexpr std::wstring_view kFileSuffix = L"\\inactivity.json";
+/** Fourteen scalars and their keys fit well inside this. */
+constexpr std::size_t kFileCapacity = 2048;
+/** Longest scalar accepted from the file. Anything longer is malformed rather than large. */
+constexpr std::size_t kScalarCapacity = 32;
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+Settings g_settings{};
+core::path::Buffer g_path{};
+bool g_pathResolved{};
+
+/** @param settings Candidate configuration. @return True when every lane is in range. */
+[[nodiscard]] bool valid(const Settings& settings) noexcept {
+    return std::all_of(
+        settings.timeouts.begin(), settings.timeouts.end(), [](std::uint32_t value) noexcept {
+            return value >= kMinimumTimeoutMs && value <= kMaximumTimeoutMs;
+        });
+}
+
+/** @param reason Key naming the step that failed. */
+void report_fail(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=inactivity stage=store result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Finds one key's raw scalar text.
+ * @param text Whole document.
+ * @param key Quoted key to locate.
+ * @param output Receives the text between the colon and the next separator.
+ * @return True when the key exists and carries a non-empty value.
+ */
+[[nodiscard]] bool
+scalar_for(std::string_view text, std::string_view key, std::string_view& output) noexcept {
+    const std::size_t at = text.find(key);
+    if (at == std::string_view::npos) {
+        return false;
+    }
+    const std::size_t colon = text.find(':', at + key.size());
+    if (colon == std::string_view::npos) {
+        return false;
+    }
+    std::size_t begin = colon + 1;
+    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
+        ++begin;
+    }
+    std::size_t end = begin;
+    while (end < text.size() && text[end] != ',' && text[end] != '}' && text[end] != '\n'
+           && text[end] != '\r') {
+        ++end;
+    }
+    output = text.substr(begin, end - begin);
+    return !output.empty();
+}
+
+/**
+ * Copies one scalar into null-terminated storage the C conversions require.
+ * @param value Scalar text taken from the document.
+ * @param output Receives the terminated copy.
+ * @return True when the scalar fits.
+ */
+[[nodiscard]] bool terminated(std::string_view value,
+                              std::array<char, kScalarCapacity>& output) noexcept {
+    if (value.size() >= output.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < value.size(); ++index) {
+        output[index] = value[index];
+    }
+    output[value.size()] = '\0';
+    return true;
+}
+
+/**
+ * Layers one document over the current defaults. A missing or malformed key keeps its default,
+ * so a hand-edited file cannot stop the module loading.
+ * @param text Whole document.
+ * @param output Receives the parsed configuration.
+ */
+void parse(std::string_view text, Settings& output) noexcept {
+    std::string_view scalar;
+    if (scalar_for(text, "\"enabled\"", scalar)) {
+        output.enabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"custom\"", scalar)) {
+        output.custom = scalar.starts_with("true");
+    }
+    std::array<char, kScalarCapacity> buffer{};
+    for (std::size_t index = 0; index < kActivityCount; ++index) {
+        std::array<char, 64> quoted{};
+        const int written = std::snprintf(quoted.data(),
+                                          quoted.size(),
+                                          "\"%.*s\"",
+                                          static_cast<int>(kActivities[index].key.size()),
+                                          kActivities[index].key.data());
+        if (written <= 0
+            || !scalar_for(
+                text, std::string_view(quoted.data(), static_cast<std::size_t>(written)), scalar)
+            || !terminated(scalar, buffer)) {
+            continue;
+        }
+        // Clamped, not refused. One out-of-range lane must not drop every other saved value.
+        output.timeouts[index] =
+            std::clamp(static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0)),
+                       kMinimumTimeoutMs,
+                       kMaximumTimeoutMs);
+    }
+}
+
+/**
+ * Writes the whole document. It is small enough that a complete rewrite is the simplest correct
+ * save, which the shared settings file is not.
+ * @param settings Configuration to store.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool store(const Settings& settings) noexcept {
+    if (!g_pathResolved) {
+        return false;
+    }
+    std::array<char, kFileCapacity> document{};
+    int size = std::snprintf(document.data(),
+                             document.size(),
+                             "{\n  \"enabled\": %s,\n  \"custom\": %s",
+                             settings.enabled ? "true" : "false",
+                             settings.custom ? "true" : "false");
+    if (size <= 0) {
+        return false;
+    }
+    for (std::size_t index = 0; index < kActivityCount; ++index) {
+        const int written = std::snprintf(document.data() + size,
+                                          document.size() - static_cast<std::size_t>(size),
+                                          ",\n  \"%.*s\": %u",
+                                          static_cast<int>(kActivities[index].key.size()),
+                                          kActivities[index].key.data(),
+                                          static_cast<unsigned>(settings.timeouts[index]));
+        if (written <= 0 || static_cast<std::size_t>(size + written) >= document.size()) {
+            return false;
+        }
+        size += written;
+    }
+    const int tail = std::snprintf(
+        document.data() + size, document.size() - static_cast<std::size_t>(size), "\n}\n");
+    if (tail <= 0 || static_cast<std::size_t>(size + tail) >= document.size()) {
+        return false;
+    }
+    size += tail;
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    DWORD written = 0;
+    bool complete =
+        WriteFile(file, document.data(), static_cast<DWORD>(size), &written, nullptr) != FALSE
+        && written == static_cast<DWORD>(size);
+    complete = CloseHandle(file) != FALSE && complete;
+    return complete;
+}
+
+/** Reads the configuration file into the active settings when one exists. */
+void load() noexcept {
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    std::array<char, kFileCapacity> buffer{};
+    DWORD read = 0;
+    const bool readOk =
+        ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size() - 1), &read, nullptr)
+        != FALSE;
+    (void)CloseHandle(file);
+    if (!readOk || read == 0) {
+        return;
+    }
+    Settings parsed{};
+    parse(std::string_view(buffer.data(), read), parsed);
+    g_settings = parsed;
+}
+
+} // namespace
+
+/** Resolves the configuration file and loads it when one exists. */
+void initialize(void* module) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_pathResolved =
+        core::path::artifact_directory(module, g_path) && core::path::append(g_path, kFileSuffix);
+    if (g_pathResolved) {
+        load();
+    } else {
+        report_fail("path");
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_path = core::path::Buffer{};
+    g_pathResolved = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** @return One lock-consistent copy of the current configuration. */
+Settings get() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const Settings snapshot = g_settings;
+    ReleaseSRWLockShared(&g_lock);
+    return snapshot;
+}
+
+/** Publishes one configuration and writes it straight to disk. */
+bool publish(const Settings& settings) noexcept {
+    if (!valid(settings)) {
+        return false;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = settings;
+    const bool stored = store(settings);
+    ReleaseSRWLockExclusive(&g_lock);
+    if (!stored) {
+        report_fail("write");
+    }
+    return true;
+}
+
+} // namespace sunrise::client::inactivity

+ 94 - 0
Sunrise/src/client/inactivity/inactivity_settings_store.h

@@ -0,0 +1,94 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+namespace sunrise::client::inactivity {
+
+/** Activity lanes the Client keeps a separate inactivity timeout for. */
+inline constexpr std::size_t kActivityCount = 14;
+
+/** Shortest timeout offered, in milliseconds. Short enough to prove a change without a wait. */
+inline constexpr std::uint32_t kMinimumTimeoutMs = 10000;
+/** Longest timeout offered, in milliseconds. A day outlasts any session. */
+inline constexpr std::uint32_t kMaximumTimeoutMs = 86400000;
+
+/**
+ * The orbit lane.
+ *
+ * A timeout that fires in orbit drops the session, and this Client cannot establish another one:
+ * the next screen is a marrionberry error and the process has to be restarted. The lane is held
+ * at its longest whenever the hold is on, and no field or file value reaches it.
+ */
+inline constexpr std::size_t kOrbitLane = 13;
+
+/** One activity lane's names and its key in the stored document. */
+struct ActivityInfo {
+    /** Full name, shown where there is room for it. */
+    std::string_view name;
+    /** Column heading, short enough for fourteen lanes across one grid. */
+    std::string_view column;
+    /** Key this lane is stored under. */
+    std::string_view key;
+};
+
+/** Every lane, in block order. */
+inline constexpr std::array<ActivityInfo, kActivityCount> kActivities{{
+    {"PvE", "PvE", "pve"},
+    {"PvE (guided)", "PvE gd", "pve_guided"},
+    {"PvE (matchmade, multiple fireteams)", "PvE mm+", "pve_mm_multiple"},
+    {"PvE (matchmade, single fireteam)", "PvE mm", "pve_mm_single"},
+    {"PvE (special)", "PvE sp", "pve_special"},
+    {"PvP", "PvP", "pvp"},
+    {"PvP (guided)", "PvP gd", "pvp_guided"},
+    {"PvP (matchmade, multiple fireteams)", "PvP mm+", "pvp_mm_multiple"},
+    {"PvP (matchmade, single fireteam)", "PvP mm", "pvp_mm_single"},
+    {"PvP (special)", "PvP sp", "pvp_special"},
+    {"PvP (private)", "Private", "pvp_private"},
+    {"PvP (Trials)", "Trials", "pvp_trials"},
+    {"Social", "Social", "social"},
+    {"Orbit", "Orbit", "orbit"},
+}};
+
+/** @return Every lane at its longest. */
+[[nodiscard]] consteval std::array<std::uint32_t, kActivityCount> longest_timeouts() noexcept {
+    std::array<std::uint32_t, kActivityCount> values{};
+    values.fill(kMaximumTimeoutMs);
+    return values;
+}
+
+/** Compiled lanes a fresh install holds. */
+inline constexpr std::array<std::uint32_t, kActivityCount> kDefaultTimeouts = longest_timeouts();
+
+/** Runtime inactivity configuration. This module owns it; Core settings do not carry it. */
+struct Settings {
+    /** Milliseconds per lane, in block order. Held only while custom is set. */
+    std::array<std::uint32_t, kActivityCount> timeouts{kDefaultTimeouts};
+    /** False puts back the lanes the Client authored. */
+    bool enabled{false};
+    /** False holds every lane at its longest and leaves the stored milliseconds alone. */
+    bool custom{false};
+};
+
+/**
+ * Resolves the configuration file and loads it when one exists.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ */
+void initialize(void* module) noexcept;
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept;
+
+/** @return One lock-consistent copy of the current configuration. */
+[[nodiscard]] Settings get() noexcept;
+
+/**
+ * Publishes one configuration and writes it straight to disk.
+ * @param settings Candidate configuration, refused when a lane is out of range.
+ * @return True when the value was published. A failed write is logged, not returned.
+ */
+bool publish(const Settings& settings) noexcept;
+
+} // namespace sunrise::client::inactivity

+ 3 - 0
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -18,6 +18,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
@@ -173,6 +174,8 @@ void clear_game_targets() noexcept {
     (void)hooks::noclip::install();
     // Attaches whether or not the feature is on, so the interface can enable it without a restart.
     (void)hooks::infinite_ammo::install();
+    // Resolves the activity config getter here; the hold itself runs on the frame tick.
+    (void)hooks::inactivity::install();
     (void)hooks::queuez::install();
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // space. Without it the widget's stored-reference reader faults.

+ 7 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -6,6 +6,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
@@ -14,6 +15,7 @@
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
+#include "../inactivity/inactivity_settings_store.h"
 #include "../movement/movement_settings_store.h"
 #include "../player/player_settings_store.h"
 #include "../targets/game.h"
@@ -29,6 +31,7 @@ bool initialize(void* module) noexcept {
     // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     player::initialize(module);
+    inactivity::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -62,6 +65,8 @@ bool shutdown() noexcept {
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::infinite_ammo::uninstall();
+    // Puts the Client's own timeouts back, so a detached module leaves them as it found them.
+    hooks::inactivity::uninstall();
     hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
@@ -99,6 +104,8 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
+    // The reverse of the order the stores initialize in.
+    inactivity::shutdown();
     player::shutdown();
     movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");

+ 120 - 0
Sunrise/src/client/ui/inactivity/inactivity_panel.cpp

@@ -0,0 +1,120 @@
+/**
+ * The inactivity section. The switch on its own is the whole feature for most operators, so the
+ * per-lane milliseconds sit behind an advanced header and a switch of their own. Every control
+ * saves at once, so a change survives a restart.
+ */
+
+#include "inactivity_panel.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <imgui.h>
+
+#include "../../../core/ui/components/toggle/ui_toggle_component.h"
+#include "../../hooks/inactivity/inactivity_override.h"
+#include "../../inactivity/inactivity_settings_store.h"
+
+namespace sunrise::client::ui::inactivity {
+namespace {
+
+namespace settings = client::inactivity;
+namespace toggle = core::ui::components::toggle;
+
+/** Lanes per row in the advanced grid. Seven lays the fourteen out in two even rows. */
+constexpr int kLaneColumns = 7;
+
+/**
+ * Draws one lane's field.
+ * @param index Lane in block order.
+ * @param configured Configuration updated on an edit.
+ * @return True when this lane changed.
+ */
+[[nodiscard]] bool draw_lane(std::size_t index, settings::Settings& configured) noexcept {
+    const bool orbit = index == settings::kOrbitLane;
+    bool changed = false;
+    ImGui::PushID(static_cast<int>(index));
+    ImGui::BeginDisabled(orbit);
+    ImGui::TextUnformatted(settings::kActivities[index].column.data());
+    if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
+        ImGui::SetTooltip("%s", settings::kActivities[index].name.data());
+    }
+    ImGui::SetNextItemWidth(-FLT_MIN);
+    std::uint32_t milliseconds = configured.timeouts[index];
+    ImGui::InputScalar("##lane",
+                       ImGuiDataType_U32,
+                       &milliseconds,
+                       nullptr,
+                       nullptr,
+                       "%u",
+                       ImGuiInputTextFlags_CharsDecimal);
+    // Taken when the field is left rather than on each keystroke, so a half-typed number is
+    // never clamped out from under the caret.
+    if (ImGui::IsItemDeactivatedAfterEdit()) {
+        configured.timeouts[index] =
+            std::clamp(milliseconds, settings::kMinimumTimeoutMs, settings::kMaximumTimeoutMs);
+        changed = true;
+    }
+    ImGui::EndDisabled();
+    ImGui::PopID();
+    return changed;
+}
+
+/**
+ * @param status What the override reached.
+ * @param enabled Whether the hold is switched on.
+ */
+void draw_status(const client::hooks::inactivity::Status& status, bool enabled) noexcept {
+    if (!status.resolved) {
+        ImGui::TextDisabled("Timeouts not reachable in this build.");
+    } else if (status.address == 0 || !status.captured) {
+        ImGui::TextDisabled("Waiting for an activity to load.");
+    } else if (enabled) {
+        ImGui::TextDisabled("Holding this session's timeouts.");
+    } else {
+        ImGui::TextDisabled("Using the client's own timeouts.");
+    }
+}
+
+} // namespace
+
+/** Draws the inactivity section inside whichever page hosts it. */
+void draw_section() noexcept {
+    settings::Settings configured = settings::get();
+
+    ImGui::TextUnformatted("Inactivity");
+    ImGui::Separator();
+    ImGui::TextWrapped("The client returns a session to orbit once its controller has been idle "
+                       "for the timeout of the activity it is in.");
+    ImGui::Spacing();
+
+    bool changed = toggle::control("Enabled##inactivity", configured.enabled);
+    draw_status(client::hooks::inactivity::status(), configured.enabled);
+
+    if (ImGui::CollapsingHeader("Advanced##inactivity")) {
+        changed =
+            toggle::control("Use set timeouts##inactivity_custom", configured.custom) || changed;
+        ImGui::TextDisabled("Milliseconds per activity, the unit the client's own inactivity "
+                            "overlay prints. Taken when a field is left.");
+        ImGui::Spacing();
+        ImGui::BeginDisabled(!configured.custom);
+        if (ImGui::BeginTable("lanes", kLaneColumns, ImGuiTableFlags_SizingStretchSame)) {
+            for (std::size_t index = 0; index < settings::kActivityCount; ++index) {
+                ImGui::TableNextColumn();
+                changed = draw_lane(index, configured) || changed;
+            }
+            ImGui::EndTable();
+        }
+        ImGui::EndDisabled();
+        ImGui::Spacing();
+        ImGui::PushTextWrapPos(0.0F);
+        ImGui::TextDisabled("Orbit is held but not editable; a timeout there needs a restart.");
+        ImGui::PopTextWrapPos();
+    }
+
+    if (changed) {
+        (void)settings::publish(configured);
+    }
+}
+
+} // namespace sunrise::client::ui::inactivity

+ 8 - 0
Sunrise/src/client/ui/inactivity/inactivity_panel.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::ui::inactivity {
+
+/** Draws the inactivity section inside whichever page hosts it. */
+void draw_section() noexcept;
+
+} // namespace sunrise::client::ui::inactivity

+ 5 - 0
Sunrise/src/client/ui/player/player_panel.cpp

@@ -6,6 +6,7 @@
 
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
 #include "../../player/player_settings_store.h"
+#include "../inactivity/inactivity_panel.h"
 
 namespace sunrise::client::ui::player {
 
@@ -23,6 +24,10 @@ void draw() noexcept {
     if (changed) {
         (void)client::player::publish(settings);
     }
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    inactivity::draw_section();
 }
 
 } // namespace sunrise::client::ui::player