diff --git a/README.md b/README.md
index a29a70f6..2102ab6d 100644
--- a/README.md
+++ b/README.md
@@ -32,18 +32,19 @@ For features and installation instructions, please visit [the website].
## Support
-| Game | Windows | Linux |
-|-------------------------------------------------------------------------|---------|-------|
-| [Portal 2](https://store.steampowered.com/app/620) | ✔ | ✔ |
-| [Aperture Tag](https://store.steampowered.com/app/280740) | ✔ | [➖](https://wiki.portal2.sr/Aperture_Tag#Linux) |
-| [Portal Stories: Mel](https://store.steampowered.com/app/317400) | ✔ | ✔ |
-| [Thinking with Time Machine](https://store.steampowered.com/app/286080) | ✔ | ✔ |
-| [Portal Reloaded](https://store.steampowered.com/app/1255980) | ✔ | ✔ |
-| [INFRA](https://store.steampowered.com/app/251110) | ✔ | ➖ |
-| [The Beginner's Guide](https://store.steampowered.com/app/303210) | ✔ | ✔ |
-| [The Stanley Parable](https://store.steampowered.com/app/221910) | ✔ | ✔ |
-| [The Cleaning Game](https://store.steampowered.com/app/3281900) | ✔ | ➖ |
-| Divinity Chapter 2 *(closed beta)* | ✔ | ❓ |
+| Game | Windows | Linux |
+|----------------------------------------------------------------------------|---------|-------|
+| [Portal 2 (Steam)](https://store.steampowered.com/app/620) | ✔ | ✔ |
+| [Portal 2 (4554)](https://sourceunpack.gameabusefastcomplete.com/#p2-4554) | ✔ | ➖ |
+| [Aperture Tag](https://store.steampowered.com/app/280740) | ✔ | [➖](https://wiki.portal2.sr/Aperture_Tag#Linux) |
+| [Portal Stories: Mel](https://store.steampowered.com/app/317400) | ✔ | ✔ |
+| [Thinking with Time Machine](https://store.steampowered.com/app/286080) | ✔ | ❌ |
+| [Portal Reloaded](https://store.steampowered.com/app/1255980) | ✔ | ✔ |
+| [INFRA](https://store.steampowered.com/app/251110) | ✔ | ➖ |
+| [The Beginner's Guide](https://store.steampowered.com/app/303210) | ✔ | ✔ |
+| [The Stanley Parable](https://store.steampowered.com/app/221910) | ✔ | ✔ |
+| [The Cleaning Game](https://store.steampowered.com/app/3281900) | ✔ | ➖ |
+| Divinity Chapter 2 *(closed beta)* | ✔ | ❓ |
If you're playing a game with no Linux support, you can use Proton to run it.
diff --git a/docs/cvars.md b/docs/cvars.md
index beeca5d8..9ca8b56f 100644
--- a/docs/cvars.md
+++ b/docs/cvars.md
@@ -173,6 +173,7 @@
|sar_demo_clean_start|0|Attempts to minimize visual interpolation of some elements (like post-processing or lighting) when demo playback begins.|
|sar_demo_clean_start_tonemap|0|Overrides initial tonemap scalar value used in auto-exposure.
Setting it to 0 will attempt to skip over to target value for several ticks.|
|sar_demo_clean_start_tonemap_sample|cmd|sar_demo_clean_start_tonemap_sample [tick] - samples tonemap scale from current demo at given tick and stores it in "sar_demo_clean_start_tonemap" variable. If no tick is given, sampling will happen when `__END__` is seen in demo playback.|
+|sar_demo_modelcache_clear_protected_flags|0|Fix demo model-cache growth by clearing stale CModelLoader protected flags on demo stop. Requires sv_cheats 1 to enable.|
|sar_demo_overwrite_bak|0|Rename demos to (name)_bak if they would be overwritten by recording|
|sar_demo_portal_interp_fix|1|Fix eye interpolation through portals in demo playback.|
|sar_demo_remove_broken|1|Whether to remove broken frames from demo playback|
diff --git a/docs/web/index.html b/docs/web/index.html
index 453f6c39..3884d69d 100644
--- a/docs/web/index.html
+++ b/docs/web/index.html
@@ -128,10 +128,15 @@
Game support
Linux |
- | Portal 2 |
+ Portal 2 (Steam) |
✔ |
✔ |
+
+ | Portal 2 (4554) |
+ ✔ |
+ ➖ |
+
| Aperture Tag |
✔ |
@@ -145,7 +150,7 @@ Game support
| Thinking with Time Machine |
✔ |
- ✔ |
+ ❌ |
| Portal Reloaded |
diff --git a/src/Features/Demo/ModelCacheTools.cpp b/src/Features/Demo/ModelCacheTools.cpp
new file mode 100644
index 00000000..fe45900d
--- /dev/null
+++ b/src/Features/Demo/ModelCacheTools.cpp
@@ -0,0 +1,84 @@
+#include "Event.hpp"
+#include "Modules/Console.hpp"
+#include "Modules/Server.hpp"
+#include "Offsets.hpp"
+#include "Utils.hpp"
+#include "Utils/Memory.hpp"
+#include "Variable.hpp"
+
+namespace {
+constexpr unsigned int MODEL_FLAG_PROTECTED_MASK = 0x7E;
+constexpr unsigned int MODEL_FLAG_SKIP_PROTECTED_CLEAR = 0x40;
+constexpr unsigned int MAX_REASONABLE_MODEL_COUNT = 16384;
+
+DECL_CVAR_CALLBACK(sar_demo_modelcache_clear_protected_flags);
+
+Variable sar_demo_modelcache_clear_protected_flags(
+ "sar_demo_modelcache_clear_protected_flags",
+ "0",
+ "Fix demo model-cache growth by clearing stale CModelLoader protected flags on demo stop. Requires sv_cheats 1 to enable.\n",
+ FCVAR_NONE,
+ sar_demo_modelcache_clear_protected_flags_callback);
+
+DECL_CVAR_CALLBACK(sar_demo_modelcache_clear_protected_flags) {
+ if (sar_demo_modelcache_clear_protected_flags.GetBool() && !sv_cheats.GetBool()) {
+ console->Print("sar_demo_modelcache_clear_protected_flags requires sv_cheats 1.\n");
+ sar_demo_modelcache_clear_protected_flags.SetValue(0);
+ }
+}
+
+void *GetModelLoader() {
+ static uintptr_t global = 0;
+ if (!global) {
+ auto site = Memory::Scan(MODULE("engine"), Offsets::CModelLoaderModelPrecache, Offsets::CModelLoaderModelPrecacheGlobal);
+ if (!site) return nullptr;
+
+ global = Memory::Deref(site);
+ }
+
+ return Memory::Deref(global);
+}
+
+void ClearProtectedModelFlags() {
+ auto modelLoader = reinterpret_cast(GetModelLoader());
+ if (!modelLoader) {
+ static bool warned = false;
+ if (!warned) {
+ warned = true;
+ console->Warning("SAR: Failed to find CModelLoader for repeated-demo model-cache cleanup.\n");
+ }
+ return;
+ }
+
+ auto entries = *reinterpret_cast(modelLoader + Offsets::CModelLoaderEntryArray);
+ auto count = *reinterpret_cast(modelLoader + Offsets::CModelLoaderEntryCount);
+ if (!entries || count > MAX_REASONABLE_MODEL_COUNT) {
+ static bool warned = false;
+ if (!warned) {
+ warned = true;
+ console->Warning(
+ "SAR: Invalid CModelLoader state (loader=%p entries=%p count=%u).\n",
+ reinterpret_cast(modelLoader),
+ reinterpret_cast(entries),
+ count);
+ }
+ return;
+ }
+
+ for (unsigned int i = 0; i < count; ++i) {
+ auto model = *reinterpret_cast(entries + i * Offsets::CModelLoaderEntryStride + Offsets::CModelLoaderEntryModel);
+ if (!model) continue;
+
+ auto flags = reinterpret_cast(model + Offsets::CModelLoaderModelFlags);
+ if ((*flags & MODEL_FLAG_SKIP_PROTECTED_CLEAR) != 0) continue;
+
+ *flags &= ~MODEL_FLAG_PROTECTED_MASK;
+ }
+}
+} // namespace
+
+ON_EVENT(DEMO_STOP) {
+ if (sar_demo_modelcache_clear_protected_flags.GetBool() && sv_cheats.GetBool()) {
+ ClearProtectedModelFlags();
+ }
+}
diff --git a/src/Offsets/Portal 2 8151.hpp b/src/Offsets/Portal 2 8151.hpp
index aca9cecb..cab8e426 100644
--- a/src/Offsets/Portal 2 8151.hpp
+++ b/src/Offsets/Portal 2 8151.hpp
@@ -87,6 +87,9 @@ SIGSCAN_LINUX(InsertCommand, "55 89 E5 57 56 53 83 EC 1C 8B 75 ? 8B 5D ? 81 FE F
// EngineDemoPlayer
SIGSCAN_LINUX(InterpolateDemoCommand, "55 31 C9 89 E5 57 56 53 83 EC 3C 89 4D F0 8B 45 08 8B 4D 14 8B 80 B0 05 00 00 89 45 B8 8B 45 14 83 C0 04 89 45 D0")
+// CModelLoader
+SIGSCAN_LINUX(CModelLoaderModelPrecache, "A1 ? ? ? ? 8B 8D ? ? ? ? 8B 10 C7 44 24 08 04 00 00 00 89 4C 24 04 89 04 24 FF 52 1C")
+
// MaterialSystem
SIGSCAN_LINUX(KeyValues_SetString, "55 89 E5 53 83 EC ? 8B 45 ? C7 44 24 ? ? ? ? ? 8B 5D ? 89 44 24 ? 8B 45 ? 89 04 24 E8 ? ? ? ? 85 C0 74 ? 89 5D")
diff --git a/src/Offsets/Portal 2 9568.hpp b/src/Offsets/Portal 2 9568.hpp
index 9317e8c1..6b90abe4 100644
--- a/src/Offsets/Portal 2 9568.hpp
+++ b/src/Offsets/Portal 2 9568.hpp
@@ -484,6 +484,20 @@ SIGSCAN_DEFAULT(InterpolateDemoCommand, "55 8B EC 83 EC 10 56 8B F1 8B 4D 10 57
"55 57 56 53 83 EC 10 8B 44 24 24 8B 5C 24 2C 8B 88 B0 05 00 00 8B 44 24 30 8D 70 04 8D 90 9C 00 00 00 89 F0 F3 0F 10 40 04")
+// CModelLoader
+// win: "modelprecache" xref -> client string-table update callback -> CModelLoader vtable +0x1C call with flag 4; global immediate is g_pModelLoader
+// linux: "CClientState::ConsistencyCheck" xref -> model consistency type 3 block -> CModelLoader vtable +0x1C call with flag 4; global immediate is g_pModelLoader
+SIGSCAN_DEFAULT(CModelLoaderModelPrecache,
+ "8B 0D ? ? ? ? 8B 11 6A 04 50 8B 42 1C FF D0 50 EB 02 6A 00",
+ "A1 ? ? ? ? 83 EC 04 8B 10 6A 04 FF B5 ? ? ? ? 50 FF 52 1C 89 85 ? ? ? ? 83 C4 10 85 C0")
+OFFSET_DEFAULT(CModelLoaderModelPrecacheGlobal, 2, 1)
+OFFSET_DEFAULT(CModelLoaderEntryArray, 0x8, 0x8) // "CModelLoader::FindModel: NULL name" xref -> successful lookup path reads [this+8] + index*0x10 + 0xC
+OFFSET_DEFAULT(CModelLoaderEntryCount, 0x16, 0x16) // same CModelLoader::FindModel tree/list state; active count is this+0x16
+OFFSET_DEFAULT(CModelLoaderEntryStride, 0x10, 0x10) // same CModelLoader::FindModel lookup path; entry nodes are 0x10 bytes
+OFFSET_DEFAULT(CModelLoaderEntryModel, 0xC, 0xC) // same CModelLoader::FindModel lookup path; entry+0xC is model_t *
+OFFSET_DEFAULT(CModelLoaderModelFlags, 0x108, 0x108) // CModelLoader vtable +0x1C target ORs caller flags into model_t+0x108
+
+
// Matchmaking
SIGSCAN_DEFAULT(UpdateLeaderboardData, "55 8B EC 83 EC 08 53 8B D9 8B 03 8B 50 08",
"55 89 E5 57 56 53 83 EC 2C 8B 45 08 8B 5D 0C")