|
| 1 | +// This file is part of ClassicAPI. |
| 2 | +// |
| 3 | +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms |
| 4 | +// of the GNU General Public License as published by the Free Software Foundation, either |
| 5 | +// version 3 of the License, or (at your option) any later version. |
| 6 | +// |
| 7 | +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY |
| 8 | +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR |
| 9 | +// PURPOSE. See the GNU General Public License for more details. |
| 10 | +// |
| 11 | +// You should have received a copy of the GNU General Public License along with |
| 12 | +// ClassicAPI. If not, see <https://www.gnu.org/licenses/>. |
| 13 | + |
| 14 | +// `C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)` — change a |
| 15 | +// cvar for this session without the change reaching Config.wtf, then put the |
| 16 | +// saved value back. Upstream's gamepad mode flips two dozen camera and |
| 17 | +// soft-target cvars this way so the user's own settings survive it. |
| 18 | +// |
| 19 | +// Two engine facts make this nearly free: |
| 20 | +// |
| 21 | +// 1. The setter only marks the config dirty when its last argument is |
| 22 | +// nonzero (FUN_0063E0B0: `if (a6) VAR_CVAR_CONFIG_DIRTY = 1`). A temp set |
| 23 | +// passes 0, so it applies the value and fires the change callback exactly |
| 24 | +// as SetCVar does, but never asks the config writer to run. |
| 25 | +// 2. The config writer (FUN_CVAR_CONFIG_WRITE) reads each cvar's live value |
| 26 | +// pointer and nothing else of ours. When something ELSE dirtied the config |
| 27 | +// and the writer does run, a co-hook points each temp cvar's value back at |
| 28 | +// its saved string for the duration of the write and restores it after. So |
| 29 | +// the file gets exactly the line the engine would have written had the |
| 30 | +// temp value never been set — including no line at all when the saved |
| 31 | +// value is the default. |
| 32 | +// |
| 33 | +// A plain SetCVar on a temp cvar wins: the entry notices the live value is no |
| 34 | +// longer the one it set and drops itself, both in the writer hook and in |
| 35 | +// RemoveTempCVar, so the user's own set is saved and never rolled back. |
| 36 | +// |
| 37 | +// Validation mirrors Script_SetCVar (FUN_00488C10) word for word: a usage |
| 38 | +// error, `Couldn't find CVar named '%s'`, and `"%s" is read only`. The lookup |
| 39 | +// is FUN_FIND_CVAR, so a cvar Lua cannot see is refused as unknown, which is |
| 40 | +// the contract's "valid and public". Nothing here is secure, the remaining |
| 41 | +// documented requirement. A nil value becomes "", as it does for SetCVar. |
| 42 | + |
| 43 | +#include "Factory.h" |
| 44 | + |
| 45 | +#include "Game.h" |
| 46 | +#include "Offsets.h" |
| 47 | + |
| 48 | +#include <cstdint> |
| 49 | +#include <cstring> |
| 50 | +#include <string> |
| 51 | +#include <vector> |
| 52 | + |
| 53 | +namespace CVar::Temp { |
| 54 | + |
| 55 | +namespace { |
| 56 | + |
| 57 | +struct Entry { |
| 58 | + CVar::Factory::Handle cvar; |
| 59 | + std::string saved; // the value before the first temp set |
| 60 | + std::string temp; // the value that landed (after the change callback) |
| 61 | +}; |
| 62 | + |
| 63 | +std::vector<Entry> g_entries; |
| 64 | + |
| 65 | +Entry *FindEntry(CVar::Factory::Handle cvar) { |
| 66 | + for (Entry &e : g_entries) |
| 67 | + if (e.cvar == cvar) |
| 68 | + return &e; |
| 69 | + return nullptr; |
| 70 | +} |
| 71 | + |
| 72 | +void EraseEntry(const Entry *entry) { |
| 73 | + g_entries.erase(g_entries.begin() + (entry - g_entries.data())); |
| 74 | +} |
| 75 | + |
| 76 | +const char *LiveValue(CVar::Factory::Handle cvar) { |
| 77 | + const char *value = CVar::Factory::GetString(cvar); |
| 78 | + return value != nullptr ? value : ""; |
| 79 | +} |
| 80 | + |
| 81 | +// True while the live value is still the one SetTempCVar put there. |
| 82 | +bool StillTemp(const Entry &e) { |
| 83 | + return e.temp == LiveValue(e.cvar); |
| 84 | +} |
| 85 | + |
| 86 | +const char **ValueSlot(CVar::Factory::Handle cvar) { |
| 87 | + return reinterpret_cast<const char **>(static_cast<uint8_t *>(cvar) + |
| 88 | + Offsets::OFF_CVAR_VALUE_STR); |
| 89 | +} |
| 90 | + |
| 91 | +// Script_SetCVar's own lookup and checks, with its error text. |
| 92 | +CVar::Factory::Handle Resolve(void *L, const char *usage) { |
| 93 | + if (!Game::Lua::IsString(L, 1)) { |
| 94 | + Game::Lua::Error(L, "%s", usage); |
| 95 | + return nullptr; |
| 96 | + } |
| 97 | + const char *name = Game::Lua::ToString(L, 1); |
| 98 | + CVar::Factory::Handle cvar = CVar::Factory::Find(name); |
| 99 | + if (cvar == nullptr) { |
| 100 | + Game::Lua::Error(L, "Couldn't find CVar named '%s'", name); |
| 101 | + return nullptr; |
| 102 | + } |
| 103 | + const uint32_t flags = *reinterpret_cast<const uint32_t *>( |
| 104 | + static_cast<const uint8_t *>(cvar) + Offsets::OFF_CVAR_FLAGS); |
| 105 | + if ((flags & Offsets::CVAR_FLAG_READ_ONLY) != 0) { |
| 106 | + Game::Lua::Error(L, "\"%s\" is read only", name); |
| 107 | + return nullptr; |
| 108 | + } |
| 109 | + return cvar; |
| 110 | +} |
| 111 | + |
| 112 | +int __fastcall Script_SetTempCVar(void *L) { |
| 113 | + CVar::Factory::Handle cvar = |
| 114 | + Resolve(L, "Usage: C_CVar.SetTempCVar(\"cvar\", value)"); |
| 115 | + if (cvar == nullptr) |
| 116 | + return 0; |
| 117 | + const char *value = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : ""; |
| 118 | + |
| 119 | + Entry *entry = FindEntry(cvar); |
| 120 | + if (entry != nullptr && !StillTemp(*entry)) { |
| 121 | + // A plain SetCVar replaced the temp value; that is the saved value now. |
| 122 | + entry->saved = LiveValue(cvar); |
| 123 | + } else if (entry == nullptr) { |
| 124 | + g_entries.push_back({cvar, LiveValue(cvar), {}}); |
| 125 | + entry = &g_entries.back(); |
| 126 | + } |
| 127 | + CVar::Factory::SetString(cvar, value, /*persist*/ false); |
| 128 | + // Record what landed, not what was asked: a change callback may clamp. |
| 129 | + // Re-find rather than reuse `entry`, since the callback runs Lua. |
| 130 | + if (Entry *landed = FindEntry(cvar)) |
| 131 | + landed->temp = LiveValue(cvar); |
| 132 | + return 0; |
| 133 | +} |
| 134 | + |
| 135 | +int __fastcall Script_RemoveTempCVar(void *L) { |
| 136 | + CVar::Factory::Handle cvar = |
| 137 | + Resolve(L, "Usage: C_CVar.RemoveTempCVar(\"cvar\")"); |
| 138 | + if (cvar == nullptr) |
| 139 | + return 0; |
| 140 | + Entry *entry = FindEntry(cvar); |
| 141 | + if (entry == nullptr) |
| 142 | + return 0; |
| 143 | + if (StillTemp(*entry)) { |
| 144 | + // Copy first: the setter's change callback could, in principle, reach |
| 145 | + // back in here and move the vector. |
| 146 | + const std::string saved = entry->saved; |
| 147 | + EraseEntry(entry); |
| 148 | + CVar::Factory::SetString(cvar, saved.c_str(), /*persist*/ false); |
| 149 | + } else { |
| 150 | + EraseEntry(entry); |
| 151 | + } |
| 152 | + return 0; |
| 153 | +} |
| 154 | + |
| 155 | +// --- Config writer co-hook ------------------------------------------------ |
| 156 | + |
| 157 | +using ConfigWrite_t = int(__cdecl *)(); |
| 158 | +ConfigWrite_t s_configWrite_o = nullptr; |
| 159 | + |
| 160 | +int __cdecl ConfigWrite_h() { |
| 161 | + // Only a dirty config gets written; skip the swap work otherwise. |
| 162 | + if (g_entries.empty() || |
| 163 | + *reinterpret_cast<const uint8_t *>(Offsets::VAR_CVAR_CONFIG_DIRTY) == 0) |
| 164 | + return s_configWrite_o(); |
| 165 | + |
| 166 | + for (size_t i = g_entries.size(); i-- > 0;) |
| 167 | + if (!StillTemp(g_entries[i])) |
| 168 | + g_entries.erase(g_entries.begin() + i); |
| 169 | + |
| 170 | + std::vector<const char *> live(g_entries.size()); |
| 171 | + for (size_t i = 0; i < g_entries.size(); ++i) { |
| 172 | + const char **slot = ValueSlot(g_entries[i].cvar); |
| 173 | + live[i] = *slot; |
| 174 | + *slot = g_entries[i].saved.c_str(); |
| 175 | + } |
| 176 | + const int result = s_configWrite_o(); |
| 177 | + for (size_t i = 0; i < g_entries.size(); ++i) |
| 178 | + *ValueSlot(g_entries[i].cvar) = live[i]; |
| 179 | + return result; |
| 180 | +} |
| 181 | + |
| 182 | +const Game::HookAutoRegister _hookConfigWrite{ |
| 183 | + Offsets::FUN_CVAR_CONFIG_WRITE, |
| 184 | + reinterpret_cast<void *>(&ConfigWrite_h), |
| 185 | + reinterpret_cast<void **>(&s_configWrite_o)}; |
| 186 | + |
| 187 | +// --- Registration --------------------------------------------------------- |
| 188 | + |
| 189 | +const Game::Doc::Field kSetArgs[] = { |
| 190 | + Game::Doc::Req("name", "cstring"), |
| 191 | + Game::Doc::Opt("value", "cstring"), |
| 192 | +}; |
| 193 | +const Game::Doc::Function kSetTempCVar{ |
| 194 | + "Sets a console variable for this session only; the change is not saved.", |
| 195 | + kSetArgs, {}}; |
| 196 | + |
| 197 | +const Game::Doc::Field kRemoveArgs[] = { |
| 198 | + Game::Doc::Req("name", "cstring"), |
| 199 | +}; |
| 200 | +const Game::Doc::Function kRemoveTempCVar{ |
| 201 | + "Restores the value a console variable had before SetTempCVar.", |
| 202 | + kRemoveArgs, {}}; |
| 203 | + |
| 204 | +void Register() { |
| 205 | + Game::Lua::RegisterTableFunction("C_CVar", "SetTempCVar", &Script_SetTempCVar, |
| 206 | + &kSetTempCVar); |
| 207 | + Game::Lua::RegisterTableFunction("C_CVar", "RemoveTempCVar", &Script_RemoveTempCVar, |
| 208 | + &kRemoveTempCVar); |
| 209 | +} |
| 210 | + |
| 211 | +// Cvar storage is process-global, so the same surface works pre-login. |
| 212 | +const Game::ModuleAutoRegister _autoreg{&Register}; |
| 213 | +const Game::GlueModuleAutoRegister _glueAutoreg{&Register}; |
| 214 | + |
| 215 | +} // namespace |
| 216 | + |
| 217 | +} // namespace CVar::Temp |
0 commit comments