stan před 3 týdny
rodič
revize
e3920740c7

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -206,9 +206,12 @@
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
+    <ClCompile Include="src\client\ui\player\player_panel.cpp" />
+    <ClCompile Include="src\client\player\player_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\sword_skate\sword_skate.cpp" />
     <ClCompile Include="src\client\hooks\sword_skate\sword_skate.cpp" />
     <ClCompile Include="src\client\hooks\fly\fly.cpp" />
     <ClCompile Include="src\client\hooks\fly\fly.cpp" />
+    <ClCompile Include="src\client\hooks\infinite_ammo\infinite_ammo.cpp" />
     <ClCompile Include="src\client\input\window_focus.cpp" />
     <ClCompile Include="src\client\input\window_focus.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
@@ -761,9 +764,12 @@
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
+    <ClInclude Include="src\client\ui\player\player_panel.h" />
+    <ClInclude Include="src\client\player\player_settings_store.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\sword_skate\sword_skate.h" />
     <ClInclude Include="src\client\hooks\sword_skate\sword_skate.h" />
     <ClInclude Include="src\client\hooks\fly\fly.h" />
     <ClInclude Include="src\client\hooks\fly\fly.h" />
+    <ClInclude Include="src\client\hooks\infinite_ammo\infinite_ammo.h" />
     <ClInclude Include="src\client\input\window_focus.h" />
     <ClInclude Include="src\client\input\window_focus.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />

+ 1 - 2
Sunrise/src/client/hooks/fly/fly.cpp

@@ -129,8 +129,7 @@ bool g_steered{false};
 }
 }
 
 
 /**
 /**
- * Forward turned about the up axis. Which turn is right is unverified: if strafing is mirrored,
- * negate both lanes.
+ * Forward turned about the up axis. This turn is the player's right, confirmed in flight.
  * @return The strafe axis, or zeroes when the camera looks straight up or down.
  * @return The strafe axis, or zeroes when the camera looks straight up or down.
  */
  */
 [[nodiscard]] teleport::Vector right_of(const teleport::Vector& forward) noexcept {
 [[nodiscard]] teleport::Vector right_of(const teleport::Vector& forward) noexcept {

+ 192 - 0
Sunrise/src/client/hooks/infinite_ammo/infinite_ammo.cpp

@@ -0,0 +1,192 @@
+/**
+ * Infinite ammo. Three setters hold a weapon's supply, and each is asked for a full one rather than
+ * written to, so a stored count's encoding stays out of this. Reserves and magazine do not clamp,
+ * so their number is the number shown. A sword's setter does clamp.
+ */
+
+#include "infinite_ammo.h"
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "../../patterns/image_scan.h"
+#include "../../player/player_settings_store.h"
+
+namespace sunrise::client::hooks::infinite_ammo {
+namespace {
+
+/** Call sites. Each setter starts with an obfuscated jump whose bytes repeat, so it is not scanned
+ *  for directly. */
+constexpr std::string_view kReservesCallText = "2B F5 48 8B CF 8B D6 E8 ? ? ? ?";
+constexpr std::string_view kMagazineCallText = "8D 14 33 48 8B CF E8 ? ? ? ?";
+/** Masked forms. These are what the image scan takes. */
+constexpr auto kReservesCall =
+    patterns::signature<patterns::signature_length(kReservesCallText)>(kReservesCallText);
+/** Masked form of the magazine call site. */
+constexpr auto kMagazineCall =
+    patterns::signature<patterns::signature_length(kMagazineCallText)>(kMagazineCallText);
+
+/** Offset of the call opcode inside each pattern above. */
+constexpr std::size_t kReservesCallOffset = 7;
+constexpr std::size_t kMagazineCallOffset = 6;
+/** A near call is one opcode byte and a signed displacement. */
+constexpr std::size_t kNearCallOperand = 1;
+constexpr std::size_t kNearCallLength = 5;
+
+/**
+ * The sword setter, which has an ordinary entry. Its first instruction is `push rsi` with a
+ * redundant REX prefix; a pattern starting a byte later matches inside the function.
+ */
+constexpr std::string_view kSwordText =
+    "40 56 48 83 EC 50 0F 29 74 24 40 48 8B F1 0F 29 7C 24 30 48 83 C1 60";
+/** Masked form of the sword setter. */
+constexpr auto kSword = patterns::signature<patterns::signature_length(kSwordText)>(kSwordText);
+
+/** Reserve count stored. No magazine holds over ~200, so the two read under 1000 together. */
+constexpr std::int32_t kRequestedCount = 500;
+/** Supply asked for on a sword. Its setter clamps this down to the sword's own maximum. */
+constexpr float kRequestedSupply = 9999.0F;
+
+using Setter = std::int64_t(__fastcall*)(void*, std::int32_t);
+using SwordSetter = void(__fastcall*)(void*, float);
+
+/** One slot each, so all three are attached together. */
+constexpr std::size_t kHandleCount = 3;
+constexpr std::size_t kReservesSlot = 0;
+constexpr std::size_t kMagazineSlot = 1;
+constexpr std::size_t kSwordSlot = 2;
+
+std::array<hooking::detour::Handle, kHandleCount> g_handles{};
+
+/** @return True while the feature is on. */
+[[nodiscard]] bool enabled() noexcept {
+    return client::player::get().infiniteAmmoEnabled;
+}
+
+/**
+ * Asks for a full reserve instead of the amount the game worked out.
+ * @param weapon Weapon instance.
+ * @param amount Amount the caller wanted to store.
+ * @return Whatever the original returns.
+ */
+std::int64_t __fastcall set_reserves(void* weapon, std::int32_t amount) noexcept {
+    const Setter next = reinterpret_cast<Setter>(g_handles[kReservesSlot].original);
+    if (next == nullptr) {
+        return 0;
+    }
+    return next(weapon, enabled() ? kRequestedCount : amount);
+}
+
+/**
+ * Passes the magazine through and tops the reserve up here. The reserve setter only runs on a
+ * reload, so a weapon that starts empty never reaches it.
+ * @param weapon Weapon instance.
+ * @param amount Amount the caller wanted to store.
+ * @return Whatever the original returns.
+ */
+std::int64_t __fastcall set_magazine(void* weapon, std::int32_t amount) noexcept {
+    const Setter next = reinterpret_cast<Setter>(g_handles[kMagazineSlot].original);
+    if (next == nullptr) {
+        return 0;
+    }
+    const std::int64_t result = next(weapon, amount);
+    const Setter reserves = reinterpret_cast<Setter>(g_handles[kReservesSlot].original);
+    if (enabled() && reserves != nullptr && weapon != nullptr) {
+        (void)reserves(weapon, kRequestedCount);
+    }
+    return result;
+}
+
+/**
+ * Keeps a sword's supply full. Its own clamp decides how full.
+ * @param weapon Weapon instance.
+ * @param supply Supply the caller wanted to store.
+ */
+void __fastcall set_sword_supply(void* weapon, float supply) noexcept {
+    const SwordSetter next = reinterpret_cast<SwordSetter>(g_handles[kSwordSlot].original);
+    if (next == nullptr) {
+        return;
+    }
+    next(weapon, enabled() ? kRequestedSupply : supply);
+}
+
+/**
+ * Decodes the setter a call site targets.
+ * @param site Base of the matched call-site pattern.
+ * @param offset Offset of the call opcode inside that pattern.
+ * @return The setter, or null when the site does not resolve.
+ */
+[[nodiscard]] std::byte* setter_from(std::byte* site, std::size_t offset) noexcept {
+    if (site == nullptr) {
+        return nullptr;
+    }
+    std::byte* const call = site + offset;
+    return static_cast<std::byte*>(
+        patterns::resolve_relative(call + kNearCallOperand, call + kNearCallLength));
+}
+
+/** @param reason Key naming the step that failed. @return False, for a direct return. */
+[[nodiscard]] bool fail(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=infinite_ammo stage=install 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)});
+    }
+    return false;
+}
+
+} // namespace
+
+/** Attaches to all three setters. The magazine one only tops the reserve up. */
+bool install() noexcept {
+    if (g_handles[kReservesSlot].original != nullptr) {
+        return true;
+    }
+    std::byte* const reserves =
+        setter_from(patterns::scan_main_image_unique(kReservesCall, "infinite_ammo_reserves"),
+                    kReservesCallOffset);
+    if (reserves == nullptr) {
+        return fail("reserves");
+    }
+    std::byte* const magazine =
+        setter_from(patterns::scan_main_image_unique(kMagazineCall, "infinite_ammo_magazine"),
+                    kMagazineCallOffset);
+    if (magazine == nullptr) {
+        return fail("magazine");
+    }
+    std::byte* const sword = patterns::scan_main_image_unique(kSword, "infinite_ammo_sword");
+    if (sword == nullptr) {
+        return fail("sword");
+    }
+    const std::array<hooking::detour::Spec, kHandleCount> specs{
+        hooking::detour::Spec{reserves, reinterpret_cast<void*>(&set_reserves)},
+        hooking::detour::Spec{magazine, reinterpret_cast<void*>(&set_magazine)},
+        hooking::detour::Spec{sword, reinterpret_cast<void*>(&set_sword_supply)},
+    };
+    if (!hooking::detour::install(specs, g_handles)) {
+        return fail("attach");
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=infinite_ammo stage=install result=ok");
+    return true;
+}
+
+/** Detaches every detour. */
+void uninstall() noexcept {
+    if (g_handles[kReservesSlot].original == nullptr) {
+        return;
+    }
+    (void)hooking::detour::uninstall(g_handles);
+    g_handles = {};
+}
+
+} // namespace sunrise::client::hooks::infinite_ammo

+ 15 - 0
Sunrise/src/client/hooks/infinite_ammo/infinite_ammo.h

@@ -0,0 +1,15 @@
+#pragma once
+
+namespace sunrise::client::hooks::infinite_ammo {
+
+/**
+ * Attaches to the reserve, magazine and sword setters. The magazine amount is passed through, so
+ * weapons still reload.
+ * @return True when all three resolved and the detours attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches every detour. */
+void uninstall() noexcept;
+
+} // namespace sunrise::client::hooks::infinite_ammo

+ 180 - 0
Sunrise/src/client/player/player_settings_store.cpp

@@ -0,0 +1,180 @@
+/**
+ * The player configuration store. Separate from Core settings because the interface changes these
+ * values while the game runs and saves each one at once. Core settings are read once.
+ */
+
+#include "player_settings_store.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::player {
+namespace {
+
+/** The module-owned configuration file, beside the generated settings and logs. */
+constexpr std::wstring_view kFileSuffix = L"\\player.json";
+/** The document is a few scalars, so one small buffer covers both reading and writing. */
+constexpr std::size_t kFileCapacity = 256;
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+Settings g_settings{};
+core::path::Buffer g_path{};
+bool g_pathResolved{};
+
+/** @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=player 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 and reads the boolean after it.
+ * @param text Whole document.
+ * @param key Quoted key to locate.
+ * @param output Receives the value, untouched when the key is absent.
+ */
+void boolean_for(std::string_view text, std::string_view key, bool& output) noexcept {
+    const std::size_t at = text.find(key);
+    if (at == std::string_view::npos) {
+        return;
+    }
+    const std::size_t colon = text.find(':', at + key.size());
+    if (colon == std::string_view::npos) {
+        return;
+    }
+    std::size_t begin = colon + 1;
+    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
+        ++begin;
+    }
+    output = text.substr(begin).starts_with("true");
+}
+
+/**
+ * Layers one document over the defaults. A missing or bad 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 {
+    boolean_for(text, "\"infinite_ammo_enabled\"", output.infiniteAmmoEnabled);
+}
+
+/**
+ * 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{};
+    const int size = std::snprintf(document.data(),
+                                   document.size(),
+                                   "{\n  \"infinite_ammo_enabled\": %s\n}\n",
+                                   settings.infiniteAmmoEnabled ? "true" : "false");
+    if (size <= 0) {
+        return false;
+    }
+    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;
+    }
+    parse(std::string_view(buffer.data(), read), g_settings);
+}
+
+} // 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 {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = settings;
+    const bool stored = store(settings);
+    ReleaseSRWLockExclusive(&g_lock);
+    if (!stored) {
+        report_fail("write");
+    }
+    return true;
+}
+
+} // namespace sunrise::client::player

+ 29 - 0
Sunrise/src/client/player/player_settings_store.h

@@ -0,0 +1,29 @@
+#pragma once
+
+namespace sunrise::client::player {
+
+/** Runtime player configuration. This module owns it; Core settings do not carry it. */
+struct Settings {
+    bool infiniteAmmoEnabled{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 Configuration to store.
+ * @return True when the value was published. A failed write is logged, not returned.
+ */
+bool publish(const Settings& settings) noexcept;
+
+} // namespace sunrise::client::player

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

@@ -18,6 +18,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
@@ -170,6 +171,8 @@ void clear_game_targets() noexcept {
     (void)hooks::teleport::install();
     (void)hooks::teleport::install();
     // Noclip owns its Havok-step target, so a patch-specific miss cannot disable teleport.
     // Noclip owns its Havok-step target, so a patch-specific miss cannot disable teleport.
     (void)hooks::noclip::install();
     (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();
     (void)hooks::queuez::install();
     (void)hooks::queuez::install();
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // 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.
     // space. Without it the widget's stored-reference reader faults.

+ 6 - 1
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -6,6 +6,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
@@ -14,6 +15,7 @@
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
 #include "../hooks/teleport/runtime.h"
 #include "../movement/movement_settings_store.h"
 #include "../movement/movement_settings_store.h"
+#include "../player/player_settings_store.h"
 #include "../targets/game.h"
 #include "../targets/game.h"
 #include "../targets/steam_targets.h"
 #include "../targets/steam_targets.h"
 #include "../ui/runtime/client_ui_module_runtime.h"
 #include "../ui/runtime/client_ui_module_runtime.h"
@@ -24,8 +26,9 @@ namespace sunrise::client {
 
 
 /** Initializes Client-owned process state without installing hooks. */
 /** Initializes Client-owned process state without installing hooks. */
 bool initialize(void* module) noexcept {
 bool initialize(void* module) noexcept {
-    // Loaded before the pages register, so the movement page draws saved values on its first frame.
+    // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     movement::initialize(module);
+    player::initialize(module);
     return ui::runtime::initialize();
     return ui::runtime::initialize();
 }
 }
 
 
@@ -58,6 +61,7 @@ bool shutdown() noexcept {
     }
     }
     hooks::bitmap::uninstall();
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::bootflow::uninstall();
+    hooks::infinite_ammo::uninstall();
     hooks::noclip::uninstall();
     hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
     hooks::queuez::uninstall();
@@ -95,6 +99,7 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
     ui::runtime::shutdown();
+    player::shutdown();
     movement::shutdown();
     movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     ReleaseSRWLockExclusive(&runtime::g_lock);

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

@@ -0,0 +1,28 @@
+/** The player module's interface. Every control saves at once, so a change survives a restart. */
+
+#include "player_panel.h"
+
+#include <imgui.h>
+
+#include "../../../core/ui/components/toggle/ui_toggle_component.h"
+#include "../../player/player_settings_store.h"
+
+namespace sunrise::client::ui::player {
+
+/** Draws the player module inside the active Core UI frame. */
+void draw() noexcept {
+    client::player::Settings settings = client::player::get();
+
+    ImGui::TextUnformatted("Infinite Ammo");
+    ImGui::Separator();
+    ImGui::TextWrapped("Keep every weapon's reserves full.");
+    ImGui::Spacing();
+
+    const bool changed = core::ui::components::toggle::control("Enabled##infinite_ammo",
+                                                               settings.infiniteAmmoEnabled);
+    if (changed) {
+        (void)client::player::publish(settings);
+    }
+}
+
+} // namespace sunrise::client::ui::player

+ 8 - 0
Sunrise/src/client/ui/player/player_panel.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::ui::player {
+
+/** Draws the player module inside the active Core UI frame. */
+void draw() noexcept;
+
+} // namespace sunrise::client::ui::player

+ 14 - 4
Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp

@@ -5,27 +5,37 @@
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
 #include "../movement/movement_panel.h"
 #include "../movement/movement_panel.h"
+#include "../player/player_panel.h"
 
 
 namespace sunrise::client::ui::runtime {
 namespace sunrise::client::ui::runtime {
 namespace {
 namespace {
 
 
-/** Namespaced stable ID prevents Client modules from colliding with Server modules. */
+/** Namespaced stable IDs prevent Client modules from colliding with Server modules. */
 constexpr std::string_view kMovementStableId = "client.movement";
 constexpr std::string_view kMovementStableId = "client.movement";
+constexpr std::string_view kPlayerStableId = "client.player";
 /** Short menu label for the shared teleport and noclip page. */
 /** Short menu label for the shared teleport and noclip page. */
 constexpr std::string_view kMovementDisplayName = "Movement";
 constexpr std::string_view kMovementDisplayName = "Movement";
+/** Short menu label for the player page. */
+constexpr std::string_view kPlayerDisplayName = "Player";
 
 
 core::ui::modules::registry::PageRegistration g_movementPage;
 core::ui::modules::registry::PageRegistration g_movementPage;
+core::ui::modules::registry::PageRegistration g_playerPage;
 
 
 } // namespace
 } // namespace
 
 
-/** @return True when the Client module owns its Core UI registry slot. */
+/** @return True when both Client modules own their Core UI registry slots. */
 bool initialize() noexcept {
 bool initialize() noexcept {
-    return g_movementPage.acquire(
+    // Registered after movement, which is the order the menu lists them in.
+    const bool movementOwned = g_movementPage.acquire(
         core::ui::modules::Owner::client, kMovementStableId, kMovementDisplayName, &movement::draw);
         core::ui::modules::Owner::client, kMovementStableId, kMovementDisplayName, &movement::draw);
+    const bool playerOwned = g_playerPage.acquire(
+        core::ui::modules::Owner::client, kPlayerStableId, kPlayerDisplayName, &player::draw);
+    return movementOwned && playerOwned;
 }
 }
 
 
-/** Removes the Client module from the Core UI registry. */
+/** Removes the Client modules from the Core UI registry. */
 void shutdown() noexcept {
 void shutdown() noexcept {
+    g_playerPage.release();
     g_movementPage.release();
     g_movementPage.release();
 }
 }