From 6000df8009857e8e137a1f73621412929218db14 Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:59:12 -0500 Subject: [PATCH] feat(cvar): add C_CVar.SetTempCVar and RemoveTempCVar SetTempCVar changes a cvar for the session without saving it to Config.wtf; RemoveTempCVar restores the value from before the first temp set. The temp set passes a6 = 0 to the engine setter, the only argument that marks the config dirty, so it applies the value and runs the change callback without asking for a save. When something else dirties the config, a co-hook on the config writer (0x0063D980) points each temp cvar's value at its saved string for the write and restores it after. A plain SetCVar over a temp value wins: the entry drops itself, so the user's set is saved and never rolled back. Errors mirror Script_SetCVar. Registered in-game and on glue. --- README.md | 4 +- docs/API.md | 32 +++++++ src/Offsets.h | 16 ++++ src/cvar/Factory.cpp | 4 +- src/cvar/Factory.h | 7 +- src/cvar/Temp.cpp | 217 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 src/cvar/Temp.cpp diff --git a/README.md b/README.md index 7611ff48..db401f05 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ reference in **[docs/API.md](docs/API.md)**. | [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` | | [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` | | [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` | -| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.SetCVarBitfield` | +| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar` | | [Cursor](docs/API.md#cursor) | `GetCursorInfo` | | [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` | | [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` | @@ -135,7 +135,7 @@ the glue state because GlueXML had no way to reach them otherwise. |-------|-------| | [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) | | [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) | -| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) | +| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool`, `C_CVar.SetTempCVar`, `C_CVar.RemoveTempCVar` (storage is process-global — writes from glue are visible in-world and vice versa) | | [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` | | Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) | | State | `IsLoggedIn` | diff --git a/docs/API.md b/docs/API.md index 0ab67379..7bbe8f0f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -127,6 +127,7 @@ build instructions. - [`C_CVar.DoesCVarExist(name)`](#c_cvardoescvarexistname) - [`C_CVar.AreCVarsLoaded()`](#c_cvararecvarsloaded) - [`C_CVar.GetCVarBitfield(name, index)` / `C_CVar.SetCVarBitfield(name, index, value)`](#c_cvargetcvarbitfieldname-index--c_cvarsetcvarbitfieldname-index-value) + - [`C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)`](#c_cvarsettempcvarname-value--c_cvarremovetempcvarname) - [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar) - [The interface memory limit](#the-interface-memory-limit) @@ -3243,6 +3244,37 @@ for. A successful write saves the cvar the same way `SetCVar` does. Any cvar works, and a cvar you register yourself is the usual way to use these. Every bit is independent, so setting one leaves the rest alone. +### `C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)` + +`SetTempCVar` changes a cvar for this session only. The game uses the new +value at once, but it does not save the value to Config.wtf. +`RemoveTempCVar` puts back the value that the cvar had before the first +`SetTempCVar`. Neither function returns a value. + +```lua +-- Saved value of cameraDistanceMax is "15". +C_CVar.SetTempCVar("cameraDistanceMax", "40") +GetCVar("cameraDistanceMax") -- "40" +-- After /reload or logout, Config.wtf still holds "15". +C_CVar.RemoveTempCVar("cameraDistanceMax") +GetCVar("cameraDistanceMax") -- "15" +``` + +Use this pair for a mode that changes many cvars and later turns off, for +example a gamepad mode. The saved settings of the user do not change. + +- If you call `SetTempCVar` two times on one cvar, `RemoveTempCVar` puts + back the value from before the first call. +- If `SetCVar` changes a cvar that has a temp value, the `SetCVar` value + replaces the temp value. The game saves it, and `RemoveTempCVar` then does + not change the cvar. +- If `value` is `nil`, the cvar gets an empty string, as with `SetCVar`. +- `RemoveTempCVar` does nothing for a cvar that has no temp value. + +Both functions give the same errors as `SetCVar`: for a name that is not a +cvar, and for a read-only cvar. Both are also available on the login and +character-select screens. + ### `C_CVar.GetCVarBool(cvar)` Returns the cvar's value coerced to a boolean, or `nil` if no cvar diff --git a/src/Offsets.h b/src/Offsets.h index 8e094ab1..77242b3b 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -1160,6 +1160,19 @@ enum Offsets { // Where `FUN_0063D380` stores that pointer. Read by the loader call above // and by the save. VAR_CONFIG_FILENAME_PTR = 0x00C4EDD4, + // The config writer, `"WTF\" + [VAR_CONFIG_FILENAME_PTR]`. `int(void)` + // (decompiled). Returns 1 at once unless VAR_CVAR_CONFIG_DIRTY is set; + // otherwise clears it and writes `SET name "value"` for every cvar with + // CVAR_FLAG_ARCHIVE, taking the value from OFF_CVAR_STAGED_STR, else + // OFF_CVAR_VALUE_STR, else OFF_CVAR_DEFAULT_STR, and skipping any that + // equals the default. Called from the /reload + logout teardown + // FUN_00490BD0, from FUN_0046B500 (glue), and from the cvar shutdown + // FUN_0063DAF0 — cold paths only. + FUN_CVAR_CONFIG_WRITE = 0x0063D980, + // u8 "config needs saving". Set by the inner setter FUN_0063E0B0 only when + // its last argument (a6) is nonzero, and by the staged-value path in + // FUN_SET_CVAR_VALUE; cleared by FUN_CVAR_CONFIG_WRITE. + VAR_CVAR_CONFIG_DIRTY = 0x00C4EDD8, // Internal CVar registrar — what `Script_RegisterCVar` calls after a // `FindCVar` miss (the call at `0x00488B8A`). `__fastcall`; ECX=name, @@ -1192,6 +1205,9 @@ enum Offsets { // when they differ, which is what identifies each; +0x34 is the reset // value it prints the same way, and +0x38 holds a staged value (below). OFF_CVAR_DEFAULT_STR = 0x30, + // The staged value (CVAR_FLAG_STAGED below). FUN_CVAR_CONFIG_WRITE prefers + // it over the live value when non-null. + OFF_CVAR_STAGED_STR = 0x38, // Flag bits within OFF_CVAR_FLAGS, each from the code that acts on it: // 0x1 archive — the registrar forces it on, and the config writer // (FUN_0063D980) skips any cvar without it. diff --git a/src/cvar/Factory.cpp b/src/cvar/Factory.cpp index 018decb8..31593b79 100644 --- a/src/cvar/Factory.cpp +++ b/src/cvar/Factory.cpp @@ -91,11 +91,11 @@ int GetInt(Handle cvar, int fallback) { return std::atoi(value); } -void SetString(Handle cvar, const char *value) { +void SetString(Handle cvar, const char *value, bool persist) { if (cvar == nullptr || value == nullptr) return; auto set = reinterpret_cast(Offsets::FUN_SET_CVAR_VALUE); - set(cvar, value, 1, 0, 0, 1); + set(cvar, value, 1, 0, 0, persist ? 1 : 0); } void SetInt(Handle cvar, int value) { diff --git a/src/cvar/Factory.h b/src/cvar/Factory.h index 4b64cf31..736c0cf4 100644 --- a/src/cvar/Factory.h +++ b/src/cvar/Factory.h @@ -80,7 +80,12 @@ int GetInt(Handle cvar, int fallback = 0); // Set the cvar's value (fires its change callback). No-op if `cvar` is // null. `value` is copied by the engine into the cvar's inline buffer. -void SetString(Handle cvar, const char *value); +// +// `persist` is the setter's last argument, the only thing that marks the +// config dirty (FUN_0063E0B0 sets VAR_CVAR_CONFIG_DIRTY only when it is +// nonzero). False applies the value for this session without asking the +// config writer to run; `CVar::Temp` relies on that. +void SetString(Handle cvar, const char *value, bool persist = true); // Convenience: set from an integer. // diff --git a/src/cvar/Temp.cpp b/src/cvar/Temp.cpp new file mode 100644 index 00000000..3810691c --- /dev/null +++ b/src/cvar/Temp.cpp @@ -0,0 +1,217 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +// `C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)` — change a +// cvar for this session without the change reaching Config.wtf, then put the +// saved value back. Upstream's gamepad mode flips two dozen camera and +// soft-target cvars this way so the user's own settings survive it. +// +// Two engine facts make this nearly free: +// +// 1. The setter only marks the config dirty when its last argument is +// nonzero (FUN_0063E0B0: `if (a6) VAR_CVAR_CONFIG_DIRTY = 1`). A temp set +// passes 0, so it applies the value and fires the change callback exactly +// as SetCVar does, but never asks the config writer to run. +// 2. The config writer (FUN_CVAR_CONFIG_WRITE) reads each cvar's live value +// pointer and nothing else of ours. When something ELSE dirtied the config +// and the writer does run, a co-hook points each temp cvar's value back at +// its saved string for the duration of the write and restores it after. So +// the file gets exactly the line the engine would have written had the +// temp value never been set — including no line at all when the saved +// value is the default. +// +// A plain SetCVar on a temp cvar wins: the entry notices the live value is no +// longer the one it set and drops itself, both in the writer hook and in +// RemoveTempCVar, so the user's own set is saved and never rolled back. +// +// Validation mirrors Script_SetCVar (FUN_00488C10) word for word: a usage +// error, `Couldn't find CVar named '%s'`, and `"%s" is read only`. The lookup +// is FUN_FIND_CVAR, so a cvar Lua cannot see is refused as unknown, which is +// the contract's "valid and public". Nothing here is secure, the remaining +// documented requirement. A nil value becomes "", as it does for SetCVar. + +#include "Factory.h" + +#include "Game.h" +#include "Offsets.h" + +#include +#include +#include +#include + +namespace CVar::Temp { + +namespace { + +struct Entry { + CVar::Factory::Handle cvar; + std::string saved; // the value before the first temp set + std::string temp; // the value that landed (after the change callback) +}; + +std::vector g_entries; + +Entry *FindEntry(CVar::Factory::Handle cvar) { + for (Entry &e : g_entries) + if (e.cvar == cvar) + return &e; + return nullptr; +} + +void EraseEntry(const Entry *entry) { + g_entries.erase(g_entries.begin() + (entry - g_entries.data())); +} + +const char *LiveValue(CVar::Factory::Handle cvar) { + const char *value = CVar::Factory::GetString(cvar); + return value != nullptr ? value : ""; +} + +// True while the live value is still the one SetTempCVar put there. +bool StillTemp(const Entry &e) { + return e.temp == LiveValue(e.cvar); +} + +const char **ValueSlot(CVar::Factory::Handle cvar) { + return reinterpret_cast(static_cast(cvar) + + Offsets::OFF_CVAR_VALUE_STR); +} + +// Script_SetCVar's own lookup and checks, with its error text. +CVar::Factory::Handle Resolve(void *L, const char *usage) { + if (!Game::Lua::IsString(L, 1)) { + Game::Lua::Error(L, "%s", usage); + return nullptr; + } + const char *name = Game::Lua::ToString(L, 1); + CVar::Factory::Handle cvar = CVar::Factory::Find(name); + if (cvar == nullptr) { + Game::Lua::Error(L, "Couldn't find CVar named '%s'", name); + return nullptr; + } + const uint32_t flags = *reinterpret_cast( + static_cast(cvar) + Offsets::OFF_CVAR_FLAGS); + if ((flags & Offsets::CVAR_FLAG_READ_ONLY) != 0) { + Game::Lua::Error(L, "\"%s\" is read only", name); + return nullptr; + } + return cvar; +} + +int __fastcall Script_SetTempCVar(void *L) { + CVar::Factory::Handle cvar = + Resolve(L, "Usage: C_CVar.SetTempCVar(\"cvar\", value)"); + if (cvar == nullptr) + return 0; + const char *value = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : ""; + + Entry *entry = FindEntry(cvar); + if (entry != nullptr && !StillTemp(*entry)) { + // A plain SetCVar replaced the temp value; that is the saved value now. + entry->saved = LiveValue(cvar); + } else if (entry == nullptr) { + g_entries.push_back({cvar, LiveValue(cvar), {}}); + entry = &g_entries.back(); + } + CVar::Factory::SetString(cvar, value, /*persist*/ false); + // Record what landed, not what was asked: a change callback may clamp. + // Re-find rather than reuse `entry`, since the callback runs Lua. + if (Entry *landed = FindEntry(cvar)) + landed->temp = LiveValue(cvar); + return 0; +} + +int __fastcall Script_RemoveTempCVar(void *L) { + CVar::Factory::Handle cvar = + Resolve(L, "Usage: C_CVar.RemoveTempCVar(\"cvar\")"); + if (cvar == nullptr) + return 0; + Entry *entry = FindEntry(cvar); + if (entry == nullptr) + return 0; + if (StillTemp(*entry)) { + // Copy first: the setter's change callback could, in principle, reach + // back in here and move the vector. + const std::string saved = entry->saved; + EraseEntry(entry); + CVar::Factory::SetString(cvar, saved.c_str(), /*persist*/ false); + } else { + EraseEntry(entry); + } + return 0; +} + +// --- Config writer co-hook ------------------------------------------------ + +using ConfigWrite_t = int(__cdecl *)(); +ConfigWrite_t s_configWrite_o = nullptr; + +int __cdecl ConfigWrite_h() { + // Only a dirty config gets written; skip the swap work otherwise. + if (g_entries.empty() || + *reinterpret_cast(Offsets::VAR_CVAR_CONFIG_DIRTY) == 0) + return s_configWrite_o(); + + for (size_t i = g_entries.size(); i-- > 0;) + if (!StillTemp(g_entries[i])) + g_entries.erase(g_entries.begin() + i); + + std::vector live(g_entries.size()); + for (size_t i = 0; i < g_entries.size(); ++i) { + const char **slot = ValueSlot(g_entries[i].cvar); + live[i] = *slot; + *slot = g_entries[i].saved.c_str(); + } + const int result = s_configWrite_o(); + for (size_t i = 0; i < g_entries.size(); ++i) + *ValueSlot(g_entries[i].cvar) = live[i]; + return result; +} + +const Game::HookAutoRegister _hookConfigWrite{ + Offsets::FUN_CVAR_CONFIG_WRITE, + reinterpret_cast(&ConfigWrite_h), + reinterpret_cast(&s_configWrite_o)}; + +// --- Registration --------------------------------------------------------- + +const Game::Doc::Field kSetArgs[] = { + Game::Doc::Req("name", "cstring"), + Game::Doc::Opt("value", "cstring"), +}; +const Game::Doc::Function kSetTempCVar{ + "Sets a console variable for this session only; the change is not saved.", + kSetArgs, {}}; + +const Game::Doc::Field kRemoveArgs[] = { + Game::Doc::Req("name", "cstring"), +}; +const Game::Doc::Function kRemoveTempCVar{ + "Restores the value a console variable had before SetTempCVar.", + kRemoveArgs, {}}; + +void Register() { + Game::Lua::RegisterTableFunction("C_CVar", "SetTempCVar", &Script_SetTempCVar, + &kSetTempCVar); + Game::Lua::RegisterTableFunction("C_CVar", "RemoveTempCVar", &Script_RemoveTempCVar, + &kRemoveTempCVar); +} + +// Cvar storage is process-global, so the same surface works pre-login. +const Game::ModuleAutoRegister _autoreg{&Register}; +const Game::GlueModuleAutoRegister _glueAutoreg{&Register}; + +} // namespace + +} // namespace CVar::Temp