Skip to content

Commit 6000df8

Browse files
committed
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.
1 parent fde3bec commit 6000df8

6 files changed

Lines changed: 275 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ reference in **[docs/API.md](docs/API.md)**.
6464
| [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` |
6565
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
6666
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
67-
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.SetCVarBitfield` |
67+
| [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` |
6868
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
6969
| [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` |
7070
| [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.
135135
|-------|-------|
136136
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
137137
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
138-
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
138+
| 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) |
139139
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
140140
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
141141
| State | `IsLoggedIn` |

‎docs/API.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ build instructions.
127127
- [`C_CVar.DoesCVarExist(name)`](#c_cvardoescvarexistname)
128128
- [`C_CVar.AreCVarsLoaded()`](#c_cvararecvarsloaded)
129129
- [`C_CVar.GetCVarBitfield(name, index)` / `C_CVar.SetCVarBitfield(name, index, value)`](#c_cvargetcvarbitfieldname-index--c_cvarsetcvarbitfieldname-index-value)
130+
- [`C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)`](#c_cvarsettempcvarname-value--c_cvarremovetempcvarname)
130131
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
131132
- [The interface memory limit](#the-interface-memory-limit)
132133

@@ -3243,6 +3244,37 @@ for. A successful write saves the cvar the same way `SetCVar` does.
32433244
Any cvar works, and a cvar you register yourself is the usual way to use
32443245
these. Every bit is independent, so setting one leaves the rest alone.
32453246

3247+
### `C_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)`
3248+
3249+
`SetTempCVar` changes a cvar for this session only. The game uses the new
3250+
value at once, but it does not save the value to Config.wtf.
3251+
`RemoveTempCVar` puts back the value that the cvar had before the first
3252+
`SetTempCVar`. Neither function returns a value.
3253+
3254+
```lua
3255+
-- Saved value of cameraDistanceMax is "15".
3256+
C_CVar.SetTempCVar("cameraDistanceMax", "40")
3257+
GetCVar("cameraDistanceMax") -- "40"
3258+
-- After /reload or logout, Config.wtf still holds "15".
3259+
C_CVar.RemoveTempCVar("cameraDistanceMax")
3260+
GetCVar("cameraDistanceMax") -- "15"
3261+
```
3262+
3263+
Use this pair for a mode that changes many cvars and later turns off, for
3264+
example a gamepad mode. The saved settings of the user do not change.
3265+
3266+
- If you call `SetTempCVar` two times on one cvar, `RemoveTempCVar` puts
3267+
back the value from before the first call.
3268+
- If `SetCVar` changes a cvar that has a temp value, the `SetCVar` value
3269+
replaces the temp value. The game saves it, and `RemoveTempCVar` then does
3270+
not change the cvar.
3271+
- If `value` is `nil`, the cvar gets an empty string, as with `SetCVar`.
3272+
- `RemoveTempCVar` does nothing for a cvar that has no temp value.
3273+
3274+
Both functions give the same errors as `SetCVar`: for a name that is not a
3275+
cvar, and for a read-only cvar. Both are also available on the login and
3276+
character-select screens.
3277+
32463278
### `C_CVar.GetCVarBool(cvar)`
32473279

32483280
Returns the cvar's value coerced to a boolean, or `nil` if no cvar

‎src/Offsets.h‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,19 @@ enum Offsets {
11601160
// Where `FUN_0063D380` stores that pointer. Read by the loader call above
11611161
// and by the save.
11621162
VAR_CONFIG_FILENAME_PTR = 0x00C4EDD4,
1163+
// The config writer, `"WTF\" + [VAR_CONFIG_FILENAME_PTR]`. `int(void)`
1164+
// (decompiled). Returns 1 at once unless VAR_CVAR_CONFIG_DIRTY is set;
1165+
// otherwise clears it and writes `SET name "value"` for every cvar with
1166+
// CVAR_FLAG_ARCHIVE, taking the value from OFF_CVAR_STAGED_STR, else
1167+
// OFF_CVAR_VALUE_STR, else OFF_CVAR_DEFAULT_STR, and skipping any that
1168+
// equals the default. Called from the /reload + logout teardown
1169+
// FUN_00490BD0, from FUN_0046B500 (glue), and from the cvar shutdown
1170+
// FUN_0063DAF0 — cold paths only.
1171+
FUN_CVAR_CONFIG_WRITE = 0x0063D980,
1172+
// u8 "config needs saving". Set by the inner setter FUN_0063E0B0 only when
1173+
// its last argument (a6) is nonzero, and by the staged-value path in
1174+
// FUN_SET_CVAR_VALUE; cleared by FUN_CVAR_CONFIG_WRITE.
1175+
VAR_CVAR_CONFIG_DIRTY = 0x00C4EDD8,
11631176

11641177
// Internal CVar registrar — what `Script_RegisterCVar` calls after a
11651178
// `FindCVar` miss (the call at `0x00488B8A`). `__fastcall`; ECX=name,
@@ -1192,6 +1205,9 @@ enum Offsets {
11921205
// when they differ, which is what identifies each; +0x34 is the reset
11931206
// value it prints the same way, and +0x38 holds a staged value (below).
11941207
OFF_CVAR_DEFAULT_STR = 0x30,
1208+
// The staged value (CVAR_FLAG_STAGED below). FUN_CVAR_CONFIG_WRITE prefers
1209+
// it over the live value when non-null.
1210+
OFF_CVAR_STAGED_STR = 0x38,
11951211
// Flag bits within OFF_CVAR_FLAGS, each from the code that acts on it:
11961212
// 0x1 archive — the registrar forces it on, and the config writer
11971213
// (FUN_0063D980) skips any cvar without it.

‎src/cvar/Factory.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ int GetInt(Handle cvar, int fallback) {
9191
return std::atoi(value);
9292
}
9393

94-
void SetString(Handle cvar, const char *value) {
94+
void SetString(Handle cvar, const char *value, bool persist) {
9595
if (cvar == nullptr || value == nullptr)
9696
return;
9797
auto set = reinterpret_cast<SetCVarValue_t>(Offsets::FUN_SET_CVAR_VALUE);
98-
set(cvar, value, 1, 0, 0, 1);
98+
set(cvar, value, 1, 0, 0, persist ? 1 : 0);
9999
}
100100

101101
void SetInt(Handle cvar, int value) {

‎src/cvar/Factory.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ int GetInt(Handle cvar, int fallback = 0);
8080

8181
// Set the cvar's value (fires its change callback). No-op if `cvar` is
8282
// null. `value` is copied by the engine into the cvar's inline buffer.
83-
void SetString(Handle cvar, const char *value);
83+
//
84+
// `persist` is the setter's last argument, the only thing that marks the
85+
// config dirty (FUN_0063E0B0 sets VAR_CVAR_CONFIG_DIRTY only when it is
86+
// nonzero). False applies the value for this session without asking the
87+
// config writer to run; `CVar::Temp` relies on that.
88+
void SetString(Handle cvar, const char *value, bool persist = true);
8489

8590
// Convenience: set from an integer.
8691
//

‎src/cvar/Temp.cpp‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)