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.
This commit is contained in:
Brues
2026-09-25 09:59:27 -05:00
parent fde3beca9d
commit 6000df8009
6 changed files with 275 additions and 5 deletions
+2 -2
View File
@@ -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` |
+32
View File
@@ -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
+16
View File
@@ -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.
+2 -2
View File
@@ -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<SetCVarValue_t>(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) {
+6 -1
View File
@@ -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.
//
+217
View File
@@ -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 <https://www.gnu.org/licenses/>.
// `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 <cstdint>
#include <cstring>
#include <string>
#include <vector>
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<Entry> 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<const char **>(static_cast<uint8_t *>(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<const uint32_t *>(
static_cast<const uint8_t *>(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<const uint8_t *>(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<const char *> 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<void *>(&ConfigWrite_h),
reinterpret_cast<void **>(&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