Ver código fonte

Merge pull request #56 from throgsoft/disable-afk-timer

Add toggle to disable AFK timeouts clientside
stan 3 semanas atrás
pai
commit
61ca2668c0

+ 4 - 0
Sunrise/Sunrise.vcxproj

@@ -218,6 +218,8 @@
     <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\player\player_panel.cpp" />
     <ClCompile Include="src\client\player\player_position.cpp" />
     <ClCompile Include="src\client\player\player_settings_store.cpp" />
@@ -899,6 +901,8 @@
     <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\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();

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

@@ -0,0 +1,436 @@
+/**
+ * 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 <cstdio>
+#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);
+
+/**
+ * The controlled player's index, which the idle clock is keyed by. Stopped at its own ret, because
+ * the bytes after it belong to the next function and would tie this pattern to that one's layout.
+ */
+constexpr std::string_view kControlledIndexText =
+    "48 8B 05 39 0F 26 02 8B 80 60 04 00 00 C3";
+constexpr auto kControlledIndex =
+    signature<signature_length(kControlledIndexText)>(kControlledIndexText);
+
+/** The idle clock. It answers in the lanes' own unit, which is what makes the two comparable. */
+constexpr std::string_view kIdleClockText =
+    "40 53 48 83 EC 20 48 63 D9 48 8D 0D C8 99 A8 01 8B D3 E8 ? ? ? ? 84 C0";
+constexpr auto kIdleClock = signature<signature_length(kIdleClockText)>(kIdleClockText);
+
+/** The session clock, which is what the grace is measured against. */
+constexpr std::string_view kSessionClockText =
+    "48 83 EC 28 E8 ? ? ? ? 48 85 C0 74 ? 80 3D 4B 63 88 01 00 48 89 5C 24 20";
+constexpr auto kSessionClock = signature<signature_length(kSessionClockText)>(kSessionClockText);
+
+/**
+ * The session grace, in the object the getter returns.
+ *
+ * Read so the interface can explain a lane that has not fired yet, and never written: this module
+ * exists to stop a kick, and the only thing a shorter grace can do is bring one forward.
+ */
+constexpr std::size_t kGraceOffset = 0x84;
+
+/** 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*)();
+/** Answers -1 when nothing is being controlled, which is not an error and not an index. */
+using IndexGetter = std::int32_t(__fastcall*)();
+using IdleGetter = std::uint64_t(__fastcall*)(std::int32_t);
+using SessionGetter = std::uint64_t(__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{};
+/** The intent the last poll acted on, so a changed one does not wait for the hold interval. */
+Lanes g_intentLanes{};
+bool g_intentHolding{};
+bool g_intentValid{};
+Lanes g_live{};
+bool g_liveValid{};
+/** Never written, so unlike the lanes there is nothing to capture and put back. */
+std::uint32_t g_liveGrace{};
+bool g_liveGraceValid{};
+
+/** Null is a normal state: a build that does not match still holds its lanes without them. */
+IndexGetter g_indexGetter{};
+IdleGetter g_idleGetter{};
+SessionGetter g_sessionGetter{};
+
+/**
+ * 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;
+}
+
+/**
+ * @param object Config object.
+ * @param value Receives the milliseconds.
+ * @return True when Windows copied it.
+ */
+[[nodiscard]] bool read_grace(const std::byte* object, std::uint32_t& value) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(
+               GetCurrentProcess(), object + kGraceOffset, &value, sizeof value, &read)
+               != FALSE
+           && read == sizeof value;
+}
+
+/**
+ * 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;
+}
+
+/** @return True while either switch asks for a hold. */
+[[nodiscard]] bool holds(const settings::Settings& configured) noexcept {
+    return configured.enabled || configured.custom;
+}
+
+/**
+ * @param configured Current configuration.
+ * @return The lanes a hold puts in place.
+ */
+[[nodiscard]] Lanes held_lanes(const settings::Settings& configured) noexcept {
+    // A hand-edited file can carry both switches, so the blanket hold wins as the safer one.
+    const bool set = configured.custom && !configured.enabled;
+    Lanes values = set ? 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.
+ * @return True when lanes were put back, false when there was no hold to end.
+ */
+[[nodiscard]] bool release_locked(std::byte* object) noexcept {
+    if (!g_holding || !g_capturedValid || !write_block(object, g_captured)) {
+        return false;
+    }
+    g_holding = false;
+    g_appliedValid = false;
+    return true;
+}
+
+/**
+ * Caller holds the lock. Failure is not propagated, because these are reported and never acted on:
+ * a build whose signatures have moved should still install and still hold its lanes.
+ */
+void resolve_clocks_locked() noexcept {
+    std::byte* const index = scan_main_image_unique(kControlledIndex, "inactivity_controlled_index");
+    std::byte* const idle = scan_main_image_unique(kIdleClock, "inactivity_idle_clock");
+    std::byte* const session = scan_main_image_unique(kSessionClock, "inactivity_session_clock");
+    if (index == nullptr || idle == nullptr || session == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=inactivity stage=clocks result=fail");
+        return;
+    }
+    g_indexGetter = reinterpret_cast<IndexGetter>(index);
+    g_idleGetter = reinterpret_cast<IdleGetter>(idle);
+    g_sessionGetter = reinterpret_cast<SessionGetter>(session);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=inactivity stage=clocks result=ok");
+}
+
+} // 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);
+    // Scanned here rather than on first display, because the scan walks the whole image and the
+    // interface asks for these from the render thread.
+    resolve_clocks_locked();
+    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) {
+        // Nothing to report on the way out; the lanes are put back or there was no hold.
+        (void)release_locked(object);
+    }
+    g_getter = nullptr;
+    g_indexGetter = nullptr;
+    g_idleGetter = nullptr;
+    g_sessionGetter = nullptr;
+    g_object = 0;
+    g_nextHoldTick = 0;
+    g_applied = Lanes{};
+    g_appliedValid = false;
+    g_captured = Lanes{};
+    g_capturedValid = false;
+    g_holding = false;
+    g_intentLanes = Lanes{};
+    g_intentHolding = false;
+    g_intentValid = false;
+    g_live = Lanes{};
+    g_liveValid = false;
+    g_liveGrace = 0;
+    g_liveGraceValid = 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();
+    const bool holding = holds(configured);
+    const Lanes desired = held_lanes(configured);
+    AcquireSRWLockExclusive(&g_lock);
+    const std::uint64_t now = GetTickCount64();
+    // A changed intent is the operator waiting on this call, so it does not wait for the interval.
+    const bool changed =
+        !g_intentValid || g_intentHolding != holding || (holding && g_intentLanes != desired);
+    if (g_getter == nullptr || (now < g_nextHoldTick && !changed)) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    g_nextHoldTick = now + kHoldIntervalMs;
+    // Recorded before the object is reached, so neither a poll that finds no activity nor a write
+    // the Client refuses can leave the intent looking changed and skip the interval on every later
+    // frame.
+    g_intentLanes = desired;
+    g_intentHolding = holding;
+    g_intentValid = true;
+    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)) {
+        g_live = current;
+        g_liveValid = true;
+        capture_locked(current);
+    }
+    if (std::uint32_t grace = 0; read_grace(object, grace)) {
+        g_liveGrace = grace;
+        g_liveGraceValid = true;
+    }
+    if (!holding) {
+        const bool released = release_locked(object);
+        ReleaseSRWLockExclusive(&g_lock);
+        // Logged as its own event, so a reader can see a hold end rather than only see one start.
+        // Nothing to put back is not a failure: it is the ordinary state with the feature off.
+        if (changed) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             released ? "ev=inactivity stage=release result=ok"
+                                      : "ev=inactivity stage=release result=noop");
+        }
+        return;
+    }
+    // Held rather than written once, because an activity change re-authors these lanes.
+    const bool wrote = write_block(object, desired);
+    if (wrote) {
+        g_applied = desired;
+        g_appliedValid = true;
+        g_holding = true;
+    }
+    // The Client picks which lane to time by at runtime, so the shortest one is the only figure
+    // that says when a kick can first happen without naming a lane that may not be in force.
+    const std::uint32_t shortest = *std::min_element(desired.begin(), desired.end());
+    ReleaseSRWLockExclusive(&g_lock);
+    if (changed) {
+        // Only on a change, so a steady hold does not fill the log every interval.
+        std::array<char, 128> line{};
+        const int length = std::snprintf(line.data(),
+                                         line.size(),
+                                         "ev=inactivity stage=hold mode=%s shortest_ms=%u result=%s",
+                                         configured.enabled ? "disable" : "set",
+                                         shortest,
+                                         wrote ? "ok" : "fail");
+        if (length > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(length)});
+        }
+    }
+}
+
+/** 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;
+    output.live = g_live;
+    output.liveValid = g_liveValid;
+    output.liveGraceMs = g_liveGrace;
+    output.liveGraceValid = g_liveGraceValid;
+    ReleaseSRWLockShared(&g_lock);
+    return output;
+}
+
+Timers timers() noexcept {
+    Timers output{};
+    AcquireSRWLockShared(&g_lock);
+    const IndexGetter index = g_indexGetter;
+    const IdleGetter idle = g_idleGetter;
+    const SessionGetter session = g_sessionGetter;
+    ReleaseSRWLockShared(&g_lock);
+    if (index == nullptr || idle == nullptr || session == nullptr) {
+        return output;
+    }
+    output.resolved = true;
+    // Called outside the lock and guarded, because these bodies are obfuscated Client code and
+    // run before the Client has published the globals they read on an early frame.
+    __try {
+        const std::int32_t controlled = index();
+        if (controlled >= 0) {
+            output.idleMs = idle(controlled);
+            output.idleValid = true;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        output.idleValid = false;
+    }
+    __try {
+        output.sessionMs = session();
+        output.sessionValid = true;
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        output.sessionValid = false;
+    }
+    return output;
+}
+
+} // namespace sunrise::client::hooks::inactivity

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

@@ -0,0 +1,70 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+
+#include "../../inactivity/inactivity_settings_store.h"
+
+namespace sunrise::client::hooks::inactivity {
+
+/** What the override reached, which is what a lane not taking says. */
+struct Status {
+    /** Read back rather than assumed, so a hold that never reached the Client still reads true. */
+    std::array<std::uint32_t, client::inactivity::kActivityCount> live{};
+    /** 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{};
+    bool liveValid{};
+    /** Zero is meaningful: it is the value that stops the Client gating on it at all. */
+    std::uint32_t liveGraceMs{};
+    bool liveGraceValid{};
+};
+
+/**
+ * The Client's own two clocks, read through the same getters it uses.
+ *
+ * A lane times out when idle passes its milliseconds, and nothing times out at all until the
+ * session passes the grace.
+ */
+struct Timers {
+    /** Input resets this, so it does not track the session and the two can diverge widely. */
+    std::uint64_t idleMs{};
+    std::uint64_t sessionMs{};
+    bool resolved{};
+    bool idleValid{};
+    bool sessionValid{};
+};
+
+/**
+ * Reads the Client's idle and session clocks.
+ *
+ * Every call enters Client code, so this is deliberately kept out of poll(): a caller pays for it
+ * only while it is displaying the result, and nothing pays for it otherwise. A caller that draws
+ * every frame does call it every frame. It writes nothing, and reports nothing when install could
+ * not resolve the getters.
+ * @return The clocks, with a validity flag for each.
+ */
+[[nodiscard]] Timers timers() noexcept;
+
+/**
+ * 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

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

@@ -0,0 +1,273 @@
+/**
+ * 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);
+    }
+    // A hand-edited file can carry both exclusive switches. Removing every timeout wins.
+    if (output.enabled) {
+        output.custom = false;
+    }
+}
+
+/**
+ * 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

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

@@ -0,0 +1,107 @@
+#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.
+ *
+ * The Client will not time any lane out until the session has outlived its own grace, which is
+ * around a minute on this build and which this module does not write. A shorter lane could not
+ * fire any sooner, so offering one would only look like a hold that is not working. A file
+ * carrying a smaller value is clamped up to this rather than refused.
+ */
+inline constexpr std::uint32_t kMinimumTimeoutMs = 60000;
+/** 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.
+ *
+ * The two switches are exclusive, because they describe opposite behaviour: one removes every
+ * timeout and the other replaces each with a chosen one. Neither set leaves the Client's own
+ * timeouts in place.
+ */
+struct Settings {
+    /** Milliseconds per lane, in block order. Held only while custom is set. */
+    std::array<std::uint32_t, kActivityCount> timeouts{kDefaultTimeouts};
+    bool enabled{false};
+    /** Never set alongside enabled; the two ask for opposite things. */
+    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/membership_probe/membership_probe.h"
 #include "../hooks/network/runtime.h"
@@ -174,6 +175,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.

+ 6 - 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,7 @@ bool shutdown() noexcept {
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::infinite_ammo::uninstall();
+    hooks::inactivity::uninstall();
     hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
@@ -99,6 +103,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");

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

@@ -2,12 +2,160 @@
 
 #include "player_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"
 #include "../../player/player_settings_store.h"
 
 namespace sunrise::client::ui::player {
+namespace {
+
+namespace inactivity = client::inactivity;
+namespace toggle = core::ui::components::toggle;
+
+constexpr int kLaneColumns = 7;
+
+/**
+ * Draws one lane's field and, under it, the milliseconds the Client is holding in that lane now.
+ * @param index Lane in block order.
+ * @param configured Configuration updated on an edit.
+ * @param status What the override reached, for the live figure.
+ * @return True when this lane changed.
+ */
+[[nodiscard]] bool draw_lane(std::size_t index,
+                             inactivity::Settings& configured,
+                             const hooks::inactivity::Status& status) noexcept {
+    const bool orbit = index == inactivity::kOrbitLane;
+    bool changed = false;
+    ImGui::PushID(static_cast<int>(index));
+    ImGui::BeginDisabled(orbit);
+    ImGui::TextUnformatted(inactivity::kActivities[index].column.data());
+    if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
+        ImGui::SetTooltip("%s", inactivity::kActivities[index].name.data());
+    }
+    // The live figure is the one the Client is timing by, so it reads as active and the set value
+    // is dimmed. A lane held at its longest is timing nothing, so neither is active.
+    const std::uint32_t live = status.liveValid ? status.live[index] : 0;
+    const bool liveActive = status.liveValid && live != inactivity::kMaximumTimeoutMs;
+
+    ImGui::SetNextItemWidth(-FLT_MIN);
+    std::uint32_t milliseconds = configured.timeouts[index];
+    if (liveActive) {
+        ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]);
+    }
+    ImGui::InputScalar("##lane",
+                       ImGuiDataType_U32,
+                       &milliseconds,
+                       nullptr,
+                       nullptr,
+                       "%u",
+                       ImGuiInputTextFlags_CharsDecimal);
+    if (liveActive) {
+        ImGui::PopStyleColor();
+    }
+    if (ImGui::IsItemDeactivatedAfterEdit()) {
+        configured.timeouts[index] =
+            std::clamp(milliseconds, inactivity::kMinimumTimeoutMs, inactivity::kMaximumTimeoutMs);
+        changed = true;
+    }
+    ImGui::EndDisabled();
+    // Outside the disabled block: the live value reads the same whether or not the field can be
+    // edited.
+    if (!status.liveValid) {
+        ImGui::TextDisabled("-");
+    } else if (liveActive) {
+        ImGui::Text("%u", live);
+    } else {
+        ImGui::TextDisabled("%u", live);
+    }
+    ImGui::PopID();
+    return changed;
+}
+
+/** @param status What the override reached, for the live grace. */
+void draw_inactivity_clocks(const hooks::inactivity::Status& status) noexcept {
+    // Read from the draw rather than the hold, so the Client is only asked while this section is
+    // on screen to answer to.
+    const hooks::inactivity::Timers timers = hooks::inactivity::timers();
+    if (timers.idleValid) {
+        ImGui::Text("Idle %.1f s", static_cast<double>(timers.idleMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Idle -");
+    }
+    ImGui::SameLine();
+    if (timers.sessionValid) {
+        ImGui::Text("Session %.1f s", static_cast<double>(timers.sessionMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Session -");
+    }
+    if (!status.liveGraceValid) {
+        return;
+    }
+    const bool passed = status.liveGraceMs == 0
+                        || (timers.sessionValid && timers.sessionMs > status.liveGraceMs);
+    ImGui::SameLine();
+    // Reported, never written.
+    const double grace = static_cast<double>(status.liveGraceMs) / 1000.0;
+    if (passed) {
+        ImGui::TextDisabled("Grace %.1f s (passed)", grace);
+    } else {
+        ImGui::Text("Grace %.1f s (no kick until then)", grace);
+    }
+}
+
+void draw_inactivity() noexcept {
+    inactivity::Settings configured = inactivity::get();
+    // Taken once, so every line below and the grid all describe the same poll.
+    const hooks::inactivity::Status status = hooks::inactivity::status();
+
+    ImGui::TextUnformatted("Inactivity");
+    ImGui::Separator();
+    ImGui::TextWrapped("Disable AFK timeouts from activities kicking to orbit and the title "
+                       "screen.");
+    ImGui::Spacing();
+
+    // The two switches are exclusive, so turning this one on drops the set timeouts.
+    bool changed = toggle::control("Enabled##inactivity", configured.enabled);
+    if (changed && configured.enabled) {
+        configured.custom = false;
+    }
+
+    if (ImGui::CollapsingHeader("Advanced##inactivity")) {
+        // A per-lane timeout has nothing to act on once every lane is already removed.
+        ImGui::BeginDisabled(configured.enabled);
+        if (toggle::control("Use set timeouts##inactivity_custom", configured.custom)) {
+            if (configured.custom) {
+                configured.enabled = false;
+            }
+            changed = true;
+        }
+        ImGui::EndDisabled();
+        ImGui::TextDisabled("In milliseconds");
+        ImGui::Spacing();
+        ImGui::BeginDisabled(configured.enabled || !configured.custom);
+        if (ImGui::BeginTable("lanes", kLaneColumns, ImGuiTableFlags_SizingStretchSame)) {
+            for (std::size_t index = 0; index < inactivity::kActivityCount; ++index) {
+                ImGui::TableNextColumn();
+                changed = draw_lane(index, configured, status) || changed;
+            }
+            ImGui::EndTable();
+        }
+        ImGui::EndDisabled();
+        ImGui::Spacing();
+        draw_inactivity_clocks(status);
+    }
+
+    if (changed) {
+        (void)inactivity::publish(configured);
+    }
+}
+
+} // namespace
 
 /** Draws the player module inside the active Core UI frame. */
 void draw() noexcept {
@@ -23,6 +171,10 @@ void draw() noexcept {
     if (changed) {
         (void)client::player::publish(settings);
     }
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    draw_inactivity();
 }
 
 } // namespace sunrise::client::ui::player