Update 2026-07-17 — Companion v0.4.24 (a real write guard) and addon 3.3.24-2 (pay gate restructured)
Date: 2026-07-17 Author: Jesper (JesperLive / MrSataana), developer of GRIP - Enhanced Macro Sequencer (GRIP-EMS) Subject: GSE Companion v0.4.24, and GSE addon build 3.3.24-2-g7732fe5 Previous update: UPDATE-2026-07-15-v0.4.23.md
⚠ PARTIAL RETRACTION — 2026-07-17, later the same day
The addon / pay-gate half of this document contained a false claim, and it is withdrawn. I wrote that
QoL.lua“is not freely accessible to or viewable by the general public — it sits behind a Patreon role lock”, and argued the 3.3.24-2 restructure was a worse answer to Blizzard’s policy point 2.That is wrong.
GSE_QoLis public source in GSE’s own GitHub repository and has been since 2024-07-07. The commit this document analyses (7732fe5) was made to that public repo. The GitHub copy is byte-identical to the PATRON zip’s once line endings are normalised, nothing checks entitlement, and every gated feature works for anyone who downloads it. Point 2 is satisfied; my point-2 argument is withdrawn in full, and my point-1 reading is substantially weakened. Full detail in the retraction block in the “3.3.24-2 restructures the pay gate” section below.Correction, 2026-07-18. The bolded sentence above ends by saying my “point-1 reading is substantially weakened.” That was an error in the opposite direction — the README’s retraction block records the same correction, dated 2026-07-17, and it belongs in this file too, since this is where the README sends readers for the full detail. Point 2 asks whether the code is completely visible. Point 1 asks whether the add-on is distributed free of charge and whether there are premium versions with additional for-pay features. Those are different tests.
GSE_QoLbeing public source answers the first one. It does not answer the second, and I am not withdrawing point 1 on point 2’s evidence. The point-2 withdrawal stands as written.I compared the two zips to each other and never opened the repository. The repository is where point 2 is decided.
The Companion half of this document is unaffected and stands as written: the write guard, the capture, the
idxbranch, the updater analysis and the live capture were all verified against the shipped build and none of them turn on the pay-gate question. The mechanism descriptions in the addon section also stand — raw edit and multi-window really are compiled into the free build behind a nil hook. That is how the gate works; it is not evidence of a paywall, because the module that flips the hook is free.
Two things shipped since the last update, and they pull in different directions.
Companion v0.4.24 adds a real guard. The ed25519-signed engine’s write path is now restricted to filenames matching /^GSE.*\.lua$/i. GRIP-EMS.lua does not match it, so the signed engine can no longer write to or delete my addon’s SavedVariables. I checked that three ways instead of trusting the error string it throws, and it holds.
What v0.4.24 does not touch is the collection half. The unsigned, server-triggered arbitrary-file capture is unchanged: on a server push it still walks your Interface\AddOns and WTF folders, reads any file the server names, and POSTs the content to GSE. The engine’s read operation still has no basename guard. The BugGrabber/BugSack error-log gather and the unsigned auto-updater are unchanged.
Bearing: GSE removed the ability to destroy a competitor’s data and kept the ability to collect it. Both halves of that sentence matter. The first half is a genuine improvement and I would rather report it accurately than spin it.
Addon 3.3.24-2 removed the GSE.Patron flag entirely, 17 references down to zero, and moved the gated features into a separate GSE_QoL module that ships in the PATRON zip. For three of the five features that is a real change. For raw edit and multi-window it is not: the code is still compiled into the free build and withheld by a nil function. (Corrected 2026-07-17: an earlier wording called GSE_QoL “patron-only”. It is not — the module is public source on GitHub and free to anyone. Only the prebuilt zip is role-locked. See the retraction above.)
Chain of custody
| Artifact | SHA-256 | Size |
|---|---|---|
| GSE Companion Setup 0.4.24.exe | D912618652C9CFDB3EFB5D23E9CD78ED6A3810F02DAD5F41A8546C0CF381D76D |
81,326,455 B |
| resources/app.asar (from the installer) | 0E1FD392E9BF84BEBCF60EC21531B8E4C364815C7EF87F3593B078CE8C141D0C |
6,202,541 B |
| out/main/index.js (inside that asar) | 26FD8635ED6F39ED2A51D8E1AC7BB67F3C9F384B52C72163DE808D3C20D3B0A9 |
136,477 B |
| GSE-3.3.24-2-g7732fe5.zip (free build) | 435128B3251B41F0C1A421CDE8C9B5D8C3C87C4550CC652D4B749D6A49AF91D5 |
2,561,512 B |
| GSE-3.3.24-2-g7732fe5-PatronBuild.zip (patron build) | 575196444D1FA7EA288867921AC7D3B4D19489E6575C1D4D26D7683C3F7FE795 |
2,570,462 B |
The installed application was hashed against the installer’s payload on 2026-07-17: %LOCALAPPDATA%\Programs\gse-companion\resources\app.asar matches 0E1FD392…C141D0C exactly, so the code analysed here is the code that runs on my machine.
v0.4.23’s out/main/index.js was 136,361 bytes; v0.4.24’s is 136,477, a difference of 116.
On dates and motive. I know when I downloaded 0.4.24 (2026-07-17 02:03 local) and when commit 7732fe5 was authored (2026-07-16T20:10:11Z). I do not know GSE’s release date for 0.4.24, and a file timestamp on my disk is not one. Nothing on the client side can tell me why any of this shipped, so this document makes no claim about why. It records what the builds do.
What v0.4.24 changed: the engine’s write path is now scoped to GSE’s own files
The atomic write function gained a second guard:
const Io = /^GSE.*\.lua$/i;
function Ao(e, t, n) {
if (!ps(e, n)) throw new Error("path out of scope");
if (!Io.test(dt(e))) throw new Error("write refused: not a GSE SavedVariables file");
const s = cn(t), o = `${e}.svmnt.tmp`, a = Xn(o, "w");
try { Yn(a, s), Zn(a); } finally { es(a); }
an(o, e);
}
dt is basename. The v0.4.23 counterpart (function Io(e,t,n) at line 1033 of the beautified 0.4.23 file) has an identical body — same .svmnt.tmp write, same rename — but only the ps() path-scope check. The basename test is new in v0.4.24. The function was renamed Io to Ao only because the minifier reassigned Io to the new regex constant.
GRIP-EMS.lua does not match /^GSE.*\.lua$/i.
Why the guard is load-bearing and not decoration
A thrown string proves nothing on its own — the interesting question is whether anything can reach the filesystem without passing it. Three checks:
Aohas exactly one call site, at line 1122: the plan interpreter’swriteoperation.- The interpreter’s full operation set was read (lines 1073-1130):
listFiles,forEach,read,extractKeys,deleteKeys,selectKeys,setKey,write, and a default that throws. Onlywritetouches the filesystem.deleteKeysandsetKeymutate in-memory bindings and mark them dirty; they can reach disk only viawrite, which means only viaAoand its guard. - Every filesystem-mutating call in the application was enumerated case-sensitively and checked for whether its path can come from the server:
| Line(s) | Primitive | Target | Server-supplied path? |
|---|---|---|---|
| 110/112/116 | openSync/writeSync/renameSync | the app’s own settings file | No (fixed) |
| 641, 4464 | copyFileSync | bridge file; hard-coded GSE.lua backup |
No |
| 644, 729, 771, 803, 821, 843, 881, 884, 4499 | writeFileSync | bridge JSON / bridge state / hard-coded GSE.lua |
No (derived from local wowPaths, or hard-coded) |
1039/1041/1045 (Ao) |
openSync/writeSync/renameSync | server-supplied path | Yes — guarded |
| 5234/5236 | unlinkSync/renameSync | updater AppImage (Linux branch) | No |
Ao is the only filesystem-mutating call in the entire application that accepts a server-supplied path, and it is now guarded.
runAccountCleanup and purgeGripCharSequences are absent from the build. The only remaining routine with “cleanup” in its name, _sidecarCleanup_v1, blanks GSE’s own bridge files and is not a deletion path for third-party addons.
The guard’s own edges
So it is not credited with more than it does:
- It is basename-only, not directory-scoped.
Io.test(dt(e))checks only the filename, whileps()allows anywhere underInterface\AddOnsandWTF. AGSE*.luafile can therefore still be written into any in-scope directory, including insideInterface\AddOns\GRIP-EMS\. It cannot overwriteGRIP-EMS.lua. - Read-then-write laundering is still possible. The interpreter can
readGRIP-EMS.luainto a binding andwritethat content into aGSE*.luafile. This does not damage my addon, and the capture path already uploads the same data directly, so it adds little. /^GSE.*\.lua$/iis case-insensitive with an unbounded.*, sogse-anything.luapasses it. No GRIP-EMS impact.
None of these three undo the guard. They are the honest fine print on it.
What v0.4.24 did not change: the collection half is intact
The arbitrary-file capture is untouched. Traced end to end in this build:
// line 2219 — the SSE dispatch
} else t?.type === "companion:request" && (
t.task ? Yo(t.task).catch(()=>{})
: Array.isArray(t.idx) ? Xo(t.requestId, t.idx).catch(()=>{})
: Qo(t.requestId, t.kinds || [], t.paths || []).catch(...)
);
t is the message from GSE’s server over the SSE stream, so t.paths is server-supplied. From there:
async function Qo(e, t, n) { ... const r = qo(n); o.push(...r); ... } // line 1904
function qo(e) { ... } // line 1269
qo iterates the server’s list. For each entry it either resolves a /-containing relative path directly, or, for a bare filename, walks every root matching a.name === t exactly, reads via lt(), and pushes {kind, path, content}. The restrictions are:
- entries containing
..are rejected - a realpath scope check against the roots (
Interface\AddOnsandWTF) - 4 MB per file, 40 files, 40,000 walk entries
There is no basename or extension restriction on the capture. The server can name GRIP-EMS.lua in paths and the Companion will locate it, read it, and POST it to /diagnostic/upload/<requestId> (line 1929, Bearer-authenticated).
Also unchanged: the engine’s read operation (_o, line 1028) has only the path-scope guard and no basename guard, so any file under the roots can still be read into bindings. The BugGrabber/BugSack gather (Eo = /^!?Bug(Grabber|Sack)\.lua$/i, used at line 1258) and the unsigned --force-run updater (line 5226) are both present.
The third dispatch branch (t.idx), examined and cleared
The dispatch above has three branches. The t.task branch is the signed engine and the paths branch is the capture; both were covered in earlier updates. The middle branch, Array.isArray(t.idx) ? Xo(t.requestId, t.idx), had never been examined in any previous write-up of mine. It is a third server-triggered path, so it needed reading before I could claim the enumeration was complete. Having read it: it is a code self-attestation channel and it is not a data path.
async function Xo(e, t) { // e = requestId, t = idx[]
if (!e || !Array.isArray(t) || !t.length || !_) return;
let n;
try { n = fs(e, t); } catch { return; }
const s = await ve();
if (s) try {
await fetch(`${oe}/diagnostic/report/${encodeURIComponent(e)}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${s}`, ...ie() },
body: JSON.stringify({ digest: n })
});
} catch {}
}
And the machinery it calls, at lines 912-948:
const dn = [ me, un, fn, cs, ls, ue, cn, $e ]; // 8 of the app's OWN functions
function us(e) { return String(e).replace(/\r\n/g, "\n"); } // CRLF -> LF, nothing else
function bo(e, t, n) { // sha256 over requestId, then per index:
const s = Xs("sha256"); // (index, byte length, n(index))
s.update(Buffer.from(String(e), "utf8"));
for (const o of t) {
const a = n(o);
if (typeof a != "string") throw new Error("bad index " + o);
...
}
return s.digest("hex");
}
function So(e, t) { // throws "empty" on a non-array or empty array;
if (!Array.isArray(e) || e.length === 0) throw new Error("empty");
for (const n of e) // throws "range N" unless every entry is an
if (!Number.isInteger(n) || n < 0 || n >= t) throw new Error("range " + n);
} // integer in [0, dn.length)
function fs(e, t, n = dn) { return So(t, n.length), bo(e, t, (s) => us(n[s].toString())); }
| Question | Answer |
|---|---|
| What does it read? | Nothing from disk. dn[s].toString() returns the in-memory source text of one of the app’s own functions. No filesystem call, no WTF, no Interface\AddOns. |
| What does it write? | Nothing. |
| What does it upload? | One SHA-256 hex digest of GSE’s own function sources, POSTed as {"digest": "..."}. No file content, no user data. |
| What guards it? | So bounds-checks every index to an integer in [0, 8) and throws otherwise. The array must be non-empty, the requestId truthy. |
.toString() extracts source text; it does not execute. So although dn includes $e (the file-read primitive the engine’s read op uses) and ls (the bridge blanker), this path only hashes their source. It cannot call them, and a server-supplied index cannot escape the eight-entry table.
Read plainly, it lets GSE’s server challenge the client to prove that named functions in its own code are unmodified — anti-tamper on GSE’s own binary. It is the runtime twin of the --emit-build-manifest argv path at lines 1380-1382, where ds() maps the same dn through the same us.
It is also not new in v0.4.24. The three-branch dispatch and the endpoint both pre-date this build:
| Build | companion:request dispatch |
/diagnostic/report |
--emit-build-manifest |
|---|---|---|---|
| v0.4.22 | three branches | present (line 2021) | present (line 1440) |
| v0.4.23 | t.task ? Xo : idx ? Qo : Jo (line 2217) |
present (line 1960) | present (line 1379) |
| v0.4.24 | t.task ? Yo : idx ? Xo : Qo (line 2219) |
present (line 1962) | present (line 1382) |
The handler names rotate between builds because the minifier reassigns them; v0.4.24’s Xo is v0.4.23’s Qo. Nothing about this path changed. I am recording it because my earlier “every server-triggered path” framing excluded it, and a gap I did not notice is still a gap.
A trap for anyone reproducing this
A second copy of the same regex, vo = /^GSE.*\.lua$/i, sits at line 1146 inside the capture region and looks like capture scoping. It is not. It governs only the default gather of GSE’s own SavedVariables at line 1223, and the identical regex was already in v0.4.23 (To, line 1144). Reading it as “the capture is now GSE-only” would be wrong.
Stepping back: what can GSE’s server actually make the Companion do?
Every update I have written, including this one up to this point, has answered a narrow question: what can the server do to my addon’s files. That framing let something bigger sit in a footnote for five builds, so I want to correct it here.
I went through every input the server controls, ranked by what it can cause:
| Surface | Server controls | What it can make the app do | Authentication |
|---|---|---|---|
| Auto-updater | the release record, including the asset id | download an installer and run it with /S --force-run, then quit |
none |
Signed engine (task) |
the plan | rewrite a key inside a GSE*.lua file |
ed25519 signature, embedded key, expiry, WoW closed, basename guard (+ an optional target-persona narrowing — see below) |
File capture (paths) |
the file list | read any file under Interface\AddOns and WTF and upload it |
none |
Self-attestation (idx) |
8 bounds-checked indices | hash GSE’s own code, return a digest | reads nothing of yours |
access-policy |
enforce |
gates the retired purge and some UI | n/a |
| The website | nothing | nothing | n/a |
The largest capability is the updater, and it is the only one with no check at all. The download function, in full, is: fetch <base>/file/<id> where the id came from the server, stream the bytes to a temp file, return the path. There is no hash comparison, no signature check and no publisher check anywhere in it. The apply step then does, on Windows:
Bt(t, ["/S", "--force-run"], { detached: !0, stdio: "ignore" }).unref(),
setTimeout(() => Y.quit(), 1e3);
/S is the silent-install switch. The check runs at startup and every four hours; I saw the startup one in my capture at 03:15:33. There is a setting to require a Restart click first, but the app also supports applying it automatically — on my machine the update channel is “Tester (alpha builds)” and “Install + relaunch automatically when an update finishes downloading” is ticked, so no click is involved.
Set the two side by side and the asymmetry is hard to unsee. A directive whose maximum effect is deleting one key from a Lua table is signed with an ed25519 key, checked against an expiry, optionally narrowed to a named account, and only runs with WoW closed. The executable that runs as you is not verified at all.
Three things this does not mean, because precision cuts both ways:
- It does not mean anyone has done this. Across three instrumented captures — 2026-07-09, 2026-07-15 and 2026-07-17 — the only updater traffic I have ever seen is the release-list check. No unexpected asset was fetched and nothing was spawned.
- It does not mean any passer-by can use it. The channel is HTTPS, so a third party would need control of that host, its DNS, or a trusted certificate for it. What a signature check buys you is the second layer: the one that still protects users if the distribution host itself is compromised or misused. That is the entire reason code signing exists, and it is the layer that is missing.
- It is not aimed at me or at any competitor. This one affects every GSE user identically, including GSE’s own. I flagged it back in the v0.4.14 audit as an unrelated general-security issue and then kept writing about the parts that mention my addon. That was the wrong weighting: asked what the server can make the app do, this is the answer, and the competitor-facing machinery is a subset of it.
I am not publishing an exploitation path, and I have not probed GSE’s server — everything above is read out of a client I downloaded. The fix is ordinary and well-understood: sign the installer, or publish a digest in the release record and check it before spawning.
Can GSE’s server still flag a user and command their app?
This is the question the v0.4.23 update raised and did not fully answer, so I checked it against v0.4.24 rather than trusting the shipped comment. The command half is intact and per-user. The flag half left the client — which removed the evidence, not the capability.
Every server-driven path requires you to be signed in, and that is structural. The variable gating them is not a policy flag; it is your access token. It is set on login and on token refresh, restored from your saved session at startup, and cleared to null on sign-out or refresh failure. The unsigned paths test it with a bare if (!token) return;. So the surface exists exactly while a user is signed in — no longer, no shorter.
Targeting happens in two places, and only one of them is visible to you. The POST /events/subscribe stream is Bearer-authenticated, so GSE’s server knows precisely which signed-in account every open connection belongs to, and chooses which connection to push to. That is per-user targeting, it is complete on its own, and nothing in the client can observe or constrain it.
The signed directive may additionally carry a targetPersona, and this is where I have to correct my own earlier wording. The check is:
if (t.targetPersona && a && String(t.targetPersona) !== String(a)) return;
It refuses only when the server supplied a target persona and it does not match yours. Omit the field and the check is skipped. I have previously described this as though your account had to be the target for a directive to run; that is not what the code says. It is an optional narrowing available to the sender, not a protection the client enforces for you.
And the flag is genuinely gone from the client. restrictedAccount and integrityRef have zero references in v0.4.24. policy:state hard-codes restricted: false, with GSE’s own comment that the Companion “performs no client-side presence scan. Any account restriction is decided server-side.”
So here is the honest shape of it. Through v0.4.22 the entire loop was legible in the shipped client: scan for the competitor, set the flag on your account, server reads the flag, server pushes the command. Every step was something you could read for yourself. In v0.4.24 only the last step is still legible. The scan is gone and the flag write is gone — both real removals, and I said so. But the server still knows who you are, can still hold whatever flag it likes on your record where nobody outside GSE can look, and can still push a signed directive to your signed-in client. The decision moved somewhere unauditable. The arm that acts on the decision did not move at all.
That GSE says the restriction is “decided server-side” is a claim about a system nobody can inspect. I am not calling it untrue — I have no way to test it either way, and that is exactly the point. What I can test is the client, and in the client the acting code is unchanged.
One thing I checked and found clean, which is worth saying because the opposite is what people assume: the website cannot drive the app. The Companion never loads gse.tools into a window. In a packaged build it does loadFile("../renderer/index.html") — local content only — with contextIsolation: true and nodeIntegration: false, which is the correct baseline. The exposure is the API and the updater, not the site.
The addon: 3.3.24-2 restructures the pay gate
Commit 7732fe5, “#1970 Restructure power user features”, authored 2026-07-16T20:10:11Z. This is the secondary finding in the README, not the Companion behaviour above.
Measured, not inferred, by comparing the two zips:
- File counts: 165 free, 168 patron.
- Patron-only files, exactly three:
GSE_QoL/Bootstrap.lua,GSE_QoL/GSE_QoL.toc,GSE_QoL/QoL.lua. .luahash-set difference: 2 patron-only hashes, zero free-only. Every shared.luais byte-identical between the builds.- The five content-differing files are the five module
.tocversion strings (free## Version: 3.3.24-2-g7732fe5, patron…-PatronBuild).
What the restructure did. GSE.Patron references went from 17 in 3.3.24-1 to 0 in 3.3.24-2. Init.lua no longer contains if GSE.VersionString:find("Patron") then GSE.Patron = true end. WagoAnalytics:Switch("Patron", ...) is hard-coded false in the shared file, and re-reported true by QoL.lua when that module is present. The GSE.GUI.Feature table is deleted from Editor_Utils.lua.
The gate is now module distribution. GSE_QoL/QoL.lua defines six capability hooks — GSE.CanMultiWindow, GSE.CanRawEdit, GSE.OnBuildClickTimingOptions, GSE.OnTreeContextMenuExtras, GSE.OnEditorBooleanTab, GSE.OnEditorMacroTab — and the shared files call them defensively (if GSE.OnEditorMacroTab then, GSE.CanMultiWindow and GSE.CanMultiWindow()). Verified in the free build: zero hook definitions, all call sites present. With the hooks nil, every gated branch is dead code.
| Feature | Status in the 3.3.24-2 free build | Evidence |
|---|---|---|
| Advanced export | Now free for everyone. | GSE.GUIAdvancedExport is defined in the free build and called with no gate (Utils.lua:2015); the if GSE.Patron then wrapper was deleted. |
| Tab-completion | Genuinely absent. | Implementation moved into QoL.lua. |
| Click-timing options | Genuinely absent. | Moved into QoL.lua; the three Options.lua handlers deleted. |
| Tree context extras | Genuinely absent. | Moved into QoL.lua. |
| Raw edit | Still fully compiled into the free build. | Editor.lua:6126-6160 builds raweditbutton — widget, label, width, three callbacks — in the FREE build. Only linegroup1:AddChild(raweditbutton) at 7073 is gated on the nil GSE.CanRawEdit. |
| Multi-window | Still fully compiled into the free build. | CreateEditor is present; Editor.lua:1955 merely returns the existing editor when CanMultiWindow is nil. |
Credit where it is due: for three of the five features the implementation genuinely left the free artifact, and one became free for everyone. That is real movement and I am not going to pretend otherwise.
What did not move: for raw edit and multi-window the arrangement is materially unchanged. The premium feature code is still compiled into the free build and withheld by a gate. The gate changed from a boolean set by a version string to a nil function defined only in the patron module. Same code, same zip, same users locked out, different lever.
How the gate actually works, and what checks your entitlement
Nothing checks your entitlement. There is no licence key, no server call, no Patreon verification, no account binding, no signature and no checksum in either build. The gate is the presence of a folder on disk. The full chain, all of it readable in the two public zips:
The free build carries the dispatcher, in GSE/API/Init.lua:
local SUBMODULES = {
GSE_Utils = true, GSE_Options = true, GSE_GUI = true,
GSE_LDB = true, GSE_QoL = true, GSE_Companion = true,
}
local function pushGSEInto(addon)
if not SUBMODULES[addon] then return end
local initFn = _G[addon .. "_Initialize"]
if type(initFn) == "function" then
initFn(GSE)
end
end
fired from GSE:ADDON_LOADED. The patron build supplies the other half, GSE_QoL/Bootstrap.lua, twelve lines whose whole job is to receive that table and run the module’s deferred setup. That setup then does, verbatim (QoL.lua:134 and :137):
-- Editor capability: allow more than one editor window open at once.
GSE.CanMultiWindow = function() return true end
-- Editor capability: show the Raw Edit button.
GSE.CanRawEdit = function() return true end
Two closures that return the literal true and consult nothing. That is the entire entitlement. The shared files then test those slots for nil at seven sites (Editor.lua 1955 / 5580 / 7072, Editor_Macro.lua 401, Editor_Tree.lua 673, Options.lua 2958, Utils.lua 2150).
Three consequences follow from the code, not from my opinion of it:
- Enforcement is at the download, never at runtime. A copy of the PATRON zip is a permanent, offline, unrevocable unlock for anyone who obtains it. Nothing phones home; there is nothing to check and nothing to revoke.
- The allowlist is keyed on folder name alone.
pushGSEIntolooks up_G[addon .. "_Initialize"]after a single name lookup. It verifies no authorship, signature, checksum or origin. So the two features that are still compiled into the free build are reachable without any GSE code. I am not publishing that and have not shipped it; the point is that the gate has no runtime strength, which matters only for how it is described. - It opens a side door in the namespace GSE closed to everyone else. Build 3.3.22-12 replaced the global
GSEtable with a locked proxy, in GSE’s own words “to deny in-memory scraping by third-party addons.”pushGSEIntohandsGSE_QoLthe real table directly. The same design that shut the namespace to third parties keeps a name-keyed entrance for the paid module.
One more thing the patron module does: QoL.lua:129-131 calls GSE.WagoAnalytics:Switch("Patron", true). The shared file hard-codes that flag false; the patron module puts it back. So GSE.Patron is gone as a gate, but patron installs stay distinguishable in GSE’s analytics.
Whether that remediates depends on what was objected to:
- “The free build contains a pay-gate flag” — addressed. The flag is gone, 17 references to 0.
- “Premium features are compiled into the free build and switched off” — not addressed for raw edit and multi-window.
“A paid build with additional for-pay features exists at all”, which is what Blizzard’s UI Add-On Development Policy point 1 speaks to — not addressed. Three extra files still deliver four extra features to payers.CORRECTED 2026-07-17 (later the same day): the three extra files are not delivered only to payers.GSE_QoLis public source in GSE’s own GitHub repository and has been since 2024-07-07; the GitHub copy is byte-identical to the PATRON zip’s once line endings are normalised, and nothing checks entitlement at runtime. Anyone can download the module for free and every gated feature works. So no monetary compensation is required to access the features, and this bullet as written was wrong. What remains is narrower: a zip branded “PatronBuild” is Patreon-exclusive, even though none of its contents are. See the retraction block below.
Blizzard’s policy point 1, quoted exactly:
1) Add-ons must be free of charge. All add-ons must be distributed free of charge. Developers may not create “premium” versions of add-ons with additional for-pay features, charge money to download an add-on, charge for services related to the add-on, or otherwise require some form of monetary compensation to download or access an add-on.
There is a second clause that has had less attention than point 1, and the restructure arguably moves toward it rather than away. Point 2, quoted exactly:
2) Add-on code must be completely visible. The programming code of an add-on must in no way be hidden or obfuscated, and must be freely accessible to and viewable by the general public.
RETRACTED 2026-07-17 (later the same day). The paragraph that stood here was wrong.
It said: “
QoL.luais 473 lines of add-on programming code that is not freely accessible to or viewable by the general public — it sits behind a Patreon role lock,” and concluded that the 3.3.24-2 restructure was “a cleaner answer to point 1 and a worse one for point 2.”
QoL.luais freely accessible to and viewable by the general public. It is in GSE’s public GitHub repository, atTimothyLuke/GSE-Advanced-Macro-Compiler/GSE_QoL/QoL.lua, and has been since 2024-07-07 — over 100 commits touch that folder, and commit7732fe5(“#1970 Restructure power user features”), the very commit this document analyses, was made to that public repo. The repo reports"private": false.Verified rather than assumed: the GitHub copy is 20,942 bytes with LF endings; the PATRON zip’s copy is 21,415 bytes with 473 CRLF pairs. The difference is exactly 473 bytes — the line count. Newline-normalised, the two files are byte-identical. It is the same module, not a stub.
patreonBuild.js(also public, same repo) doesfs.cpSync('./GSE_QoL', './.release/GSE_QoL', { recursive: true })— the “PatronBuild” is the ordinary build plus a copy of the public folder plus a-PatronBuildversion suffix.Point 2 is satisfied and my point-2 argument is withdrawn in full. Not softened — withdrawn. The error was conflating two different things: the zip is role-locked on Patreon, which is true and is what the developer’s own Patreon wording describes; the code is withheld from the public, which is false. I compared the two zips against each other and never opened the repository, and the repository is where point 2 is decided.
This also weakens my point-1 reading, and I would rather say so than leave it standing. Point 1 bars requiring “monetary compensation to download or access an add-on”. No payment is required to access
GSE_QoL: it is free on GitHub, in GSE’s own public repository, and nothing checks entitlement. The strongest surviving version of the point-1 argument is narrow — a zip branded “PatronBuild” is Patreon-exclusive even though none of its contents are — and it is much weaker than “features are withheld from non-payers”, which is what I implied.Correction, 2026-07-18. Two notes on the paragraph above. First, “this also weakens my point-1 reading” reaches a point-1 concession from point-2 evidence — the same mistake the README’s retraction block corrects, dated 2026-07-17. Point 1 is a separate test and I am not withdrawing it on point 2’s evidence; the narrow PatronBuild-zip observation stands on its own terms. Second, this paragraph originally cited GSE’s CurseForge description — “This addon is 100% free. There are extra modules available for GSE for power users stored in GSE’s GitHub repository.” — and called it “true and checkable.” The README deliberately rules that description out as evidence in either direction, because treating it as confirmation would be circular. The citation is removed and quoted here for the record; the public-repository fact carries the point on its own.
What survives unchanged: everything in the table above about mechanism. Raw edit and multi-window really are compiled into the free build and withheld by a nil hook; tab-completion, click-timing and tree-extras really did leave the free artifact; advanced export really is free for everyone. Those are accurate descriptions of how the gate works. They are not evidence of a paywall, because the module that flips the hooks is free to anyone who wants it.
I found this because a reader asked the obvious question I had not: do the files on GitHub actually work, or do you need to be on the supporter list? They work. Nothing checks — no licence key, no server call, no Patreon verification, no account binding.
Statics.Patronshas exactly one functional reference in the entire addon (Options.lua:2046, aSetTexton the About page); it is a credits display and it gates nothing.The original wording is quoted verbatim in the first line above rather than silently removed, so the record shows exactly what was claimed and when. The claim was published on 2026-07-17 and retracted the same day, before anyone relied on it.
Re-verified directly on 3.3.24-2, rather than carried forward from the previous build:
- Competitor-schema scan (
provenanceSource,gse-legacy,GRIP-EMS,GRIP_EMS,restrictedAccount,integrityRef,detectGrip,purgeGrip) across every.lua: 0 hits in the patron build, 0 in the free build. The onlyGRIPsubstring matches are UI comments about the resize grip. - Codec unchanged.
GSE/API/Serialisation.lua:8still writes the plain format:"!GSE3!" .. C_EncodingUtil.EncodeBase64(...).!GSE3!+(the ChaCha20 lock-out format) appears only in comparison and decode positions. It remains provisioned and never written, as at 3.3.22-12, 3.3.23-7 and 3.3.24-1.
The in-game addon remains inert with respect to competitor targeting. The behaviour documented in Finding 1 is in the Companion, not the addon.
Live capture, 2026-07-17
Static analysis says what the code can do. This says what it did do in one 35-minute window on my machine. It is one window on one account, and I say so plainly at the end.
Method. mitmproxy 12.2.3 in process-scoped local mode, targeting GSE Companion.exe only, so nothing else on the machine was captured. The Companion’s main process uses undici, which ignores the system proxy, so it was relaunched with NODE_EXTRA_CA_CERTS pointed at the mitmproxy CA — that is what makes its TLS readable. The CA was never installed into a Windows certificate store. A file watcher polled the whole WTF tree every 15 seconds for *.svmnt.tmp and for any SavedVariables write. Every GRIP-EMS.lua under WTF was hashed before and after.
Timeline (local time).
| Time | Event |
|---|---|
| 03:15:33 | Companion 0.4.24 relaunched under capture; signed in; startup sync ran |
| 03:17:44 | Manual Sync pressed. UI banner: “Outgoing uploads paused — Retail running.” |
| 03:18:52 | File watcher armed |
| 03:19:15 | WoW wrote its SavedVariables (the close flush) |
| 03:20 | WoW and Battle.net fully closed. UI flips to “WoW offline” |
| 03:48:56 | Watcher completed its full 30-minute run |
| 03:50:35 | Capture stopped — 30 minutes after WoW closed |
What the Companion did: 93 decrypted requests. Normal content sync (/sync/incoming, /sync/repair-flags, browse, and content list calls to api.qik.dev). One sync conflict surfaced in the UI, on a macro of mine named GEMS:GRIPProbe E, where GSE skipped the upload rather than overwrite either side. That is a merge guard doing its job.
The access-policy timer, observed four times. The ten-minute refresh is real and it ran on schedule:
03:15:34 GET api.gse.tools/settings/access-policy -> 200 {"enforce":false,"updatedAt":null,"integrity":"verified"}
03:25:34 GET api.gse.tools/settings/access-policy -> 200 {"enforce":false,"updatedAt":null,"integrity":"verified"}
03:35:35 GET api.gse.tools/settings/access-policy -> 200 {"enforce":false,"updatedAt":null,"integrity":"verified"}
03:45:34 GET api.gse.tools/settings/access-policy -> 200 {"enforce":false,"updatedAt":null,"integrity":"verified"}
enforce read false every time, and no integrityRef field was present in any response. The server named no target.
What did not happen:
| Watched for | Result |
|---|---|
companion:request (any branch: task, idx, paths) |
none |
POST /diagnostic/upload |
none |
POST /diagnostic/report |
none |
POST /diagnostic/result or /report/submit |
none |
capture-denied |
none |
restrictedAccount or integrityRef anywhere in traffic |
none |
*.svmnt.tmp anywhere under WTF, across the full 30 minutes |
none |
| Any SavedVariables write after WoW’s own 03:19:15 close flush | none |
SavedVariables integrity. Of the eleven GRIP-EMS.lua files under WTF, exactly two changed across the window, and both changed at 2026-07-17T02:19:15Z — WoW’s own close flush, written by the game before it exited:
…\Arthas\Daddysat\SavedVariables\GRIP-EMS.lua: 348,439 -> 348,438 bytes…\896137#1\SavedVariables\GRIP-EMS.lua: 79,425 -> 79,684 bytes
The other nine were untouched, carrying mtimes from April through 2 July. Nothing wrote to any of them in the thirty minutes after WoW closed, which is precisely the window in which a signed directive would have had to act.
One limit of this capture, stated rather than glossed. The SSE stream is the command channel: one POST /events/subscribe at 03:15:34, held open for the rest of the window with no reconnect. My streaming tap logged zero chunks. I cannot tell from this capture alone whether the server sent nothing at all or whether the tap failed to attach, and I am not going to claim the stronger version. It does not change the conclusion, because every effect of a directive is monitored independently of the SSE stream: a signed write would have produced a .svmnt.tmp and an mtime change, and a capture would have produced a POST /diagnostic/upload in the request log. None of those appeared. So either no directive arrived, or one arrived and did nothing. (The harness now logs the content-type of every response and an explicit tap-armed marker, so the next capture can separate the two.)
Conclusion, narrow: on 2026-07-17, on my account, with my addon present and WoW closed for thirty minutes, Companion 0.4.24 did only content sync. enforce was false, no target was named, no directive arrived, no file was uploaded, and nothing on disk changed. The capabilities are in the shipped code. I did not observe them being exercised in this window, and I have never observed them being exercised in any window I have captured.
How to verify this yourself
The write guard (v0.4.24):
- Install GSE Companion 0.4.24 from https://gse.tools/releases, or extract
resources/app.asarstraight from the installer. Confirm the SHA-256 against the table above. - Extract it:
npx --yes @electron/asar extract app.asar out. The logic isout/main/index.js. Beautify it (npx js-beautify) so the line numbers here line up. - Search for the literal string
write refused: not a GSE SavedVariables file. Read the function it sits in. Confirm it testsbasename(path)against/^GSE.*\.lua$/iin addition to the pre-existing path-scope check. - Confirm the guard is load-bearing: search for that function’s name and confirm it has exactly one call site, in the plan interpreter’s
writeoperation. Read the interpreter’s full operation table and confirmwriteis the only operation that touches the filesystem. - Compare against 0.4.23: the same function there has an identical body with only the path-scope check.
The capture (unchanged, and the part that matters):
- Search for
capture-deniedand/diagnostic/upload. Follow thecompanion:requestdispatch to the branch that takespaths, and followpathsinto the file-gathering function. - Confirm that function has no basename or extension test — only a
..reject, a realpath scope check againstInterface/AddOns+WTF, and the 4 MB / 40 file / 40,000 entry caps. - Confirm the engine’s
readoperation has only the path-scope guard and no basename guard.
Do not be caught by these two:
- A second
/^GSE.*\.lua$/isits inside the capture region and looks like capture scoping. It governs only the default gather of GSE’s own files, and it was already in 0.4.23. Read its call site before drawing a conclusion from it. - Handler names rotate between builds because the minifier reassigns them. Match branches by their shape (
t.task/t.idx/t.paths), not by name.Xomeans different things in 0.4.23 and 0.4.24. - If you grep with PowerShell’s
Select-String, pass-CaseSensitive. It is case-insensitive by default, and these identifiers are one-letter-case apart (Usvsus,Xovsxo). I got two sets of line numbers wrong this way before catching it.
The addon (3.3.24-2):
- Download the free and PATRON zips of 3.3.24-2 from https://gse.tools/releases and diff the trees. Expect 165 vs 168 files, the three extra being
GSE_QoL/. - Grep the free build for
GSE.CanRawEditandGSE.CanMultiWindow: you will find call sites and no definitions. Then openGSE_GUI/Editor.luaaround lines 6126-6160 and confirmraweditbuttonis fully constructed in the free build, with only itsAddChildgated.
What I am not claiming
- I am not claiming GSE deleted anyone’s data. I have never observed a directive being sent, in this capture or any earlier one. The v0.4.12 code shows what the subsystem was built to do; v0.4.24 shows the write path being narrowed. Both are facts about code, not about anyone’s conduct toward a user.
- I am not claiming the write guard is fake or cosmetic. It is real, and I checked it three ways precisely because I have an incentive to find otherwise.
- I am not claiming the
idxbranch is sinister. I had never examined it, so I examined it; it hashes GSE’s own code and uploads a digest. It reads nothing of yours. - I am not claiming a motive or a cause for these changes. I do not know GSE’s release date for 0.4.24, and the client cannot tell me why anything shipped. Timing is not causation and I am not going to dress it up as one.
- What I am stating is narrow and checkable: the shipped v0.4.24 application contains an unsigned, server-triggered routine that reads arbitrary files under your
Interface\AddOnsandWTFfolders and uploads their content, with no basename restriction, and that routine can still be pointed at my addon’s save file. The signed engine that could rewrite that file can no longer do so. Whether GSE sends any directive to a given user is decided on GSE’s server and is not visible from the client.
Method corrections carried from this pass
Three errors of mine, recorded because a document like this is only worth anything if its method is auditable:
Select-Stringis case-insensitive by default. Two sets of “enumerated” line numbers were wrong before I re-ran with-CaseSensitive. The error direction is worth stating: case-insensitive matching is a superset, so it inflated the results rather than hiding a write path. The conclusions survived; the cited line numbers did not.- Searching a surface word instead of the guard. Searching the literal
BugGrabberreturns zero, because the source form is the regexBug(Grabber|Sack). SearchingExportfinds a per-sequence flag and missesGUIAdvancedExport. Absence of a string is not absence of a behaviour. Enumerate the guard and read every reference to it. - “Complete enumeration” claimed while a branch was unread. The
t.idxbranch had never been examined when I described the dispatch as fully traced. It is examined now, and it was benign — but I did not know that when I made the claim, and that is the part that was wrong.