Skip to the content.

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_QoL is 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_QoL being 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 idx branch, 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:

  1. Ao has exactly one call site, at line 1122: the plan interpreter’s write operation.
  2. The interpreter’s full operation set was read (lines 1073-1130): listFiles, forEach, read, extractKeys, deleteKeys, selectKeys, setKey, write, and a default that throws. Only write touches the filesystem. deleteKeys and setKey mutate in-memory bindings and mark them dirty; they can reach disk only via write, which means only via Ao and its guard.
  3. 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:

  1. It is basename-only, not directory-scoped. Io.test(dt(e)) checks only the filename, while ps() allows anywhere under Interface\AddOns and WTF. A GSE*.lua file can therefore still be written into any in-scope directory, including inside Interface\AddOns\GRIP-EMS\. It cannot overwrite GRIP-EMS.lua.
  2. Read-then-write laundering is still possible. The interpreter can read GRIP-EMS.lua into a binding and write that content into a GSE*.lua file. This does not damage my addon, and the capture path already uploads the same data directly, so it adds little.
  3. /^GSE.*\.lua$/i is case-insensitive with an unbounded .*, so gse-anything.lua passes 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:

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:

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:

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:

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:

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.lua is 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.lua is freely accessible to and viewable by the general public. It is in GSE’s public GitHub repository, at TimothyLuke/GSE-Advanced-Macro-Compiler/GSE_QoL/QoL.lua, and has been since 2024-07-07 — over 100 commits touch that folder, and commit 7732fe5 (“#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) does fs.cpSync('./GSE_QoL', './.release/GSE_QoL', { recursive: true }) — the “PatronBuild” is the ordinary build plus a copy of the public folder plus a -PatronBuild version 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.Patrons has exactly one functional reference in the entire addon (Options.lua:2046, a SetText on 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:

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:

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):

  1. Install GSE Companion 0.4.24 from https://gse.tools/releases, or extract resources/app.asar straight from the installer. Confirm the SHA-256 against the table above.
  2. Extract it: npx --yes @electron/asar extract app.asar out. The logic is out/main/index.js. Beautify it (npx js-beautify) so the line numbers here line up.
  3. Search for the literal string write refused: not a GSE SavedVariables file. Read the function it sits in. Confirm it tests basename(path) against /^GSE.*\.lua$/i in addition to the pre-existing path-scope check.
  4. 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 write operation. Read the interpreter’s full operation table and confirm write is the only operation that touches the filesystem.
  5. 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):

  1. Search for capture-denied and /diagnostic/upload. Follow the companion:request dispatch to the branch that takes paths, and follow paths into the file-gathering function.
  2. Confirm that function has no basename or extension test — only a .. reject, a realpath scope check against Interface/AddOns + WTF, and the 4 MB / 40 file / 40,000 entry caps.
  3. Confirm the engine’s read operation has only the path-scope guard and no basename guard.

Do not be caught by these two:

The addon (3.3.24-2):

  1. 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/.
  2. Grep the free build for GSE.CanRawEdit and GSE.CanMultiWindow: you will find call sites and no definitions. Then open GSE_GUI/Editor.lua around lines 6126-6160 and confirm raweditbutton is fully constructed in the free build, with only its AddChild gated.

What I am not claiming

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:

  1. Select-String is 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.
  2. Searching a surface word instead of the guard. Searching the literal BugGrabber returns zero, because the source form is the regex Bug(Grabber|Sack). Searching Export finds a per-sequence flag and misses GUIAdvancedExport. Absence of a string is not absence of a behaviour. Enumerate the guard and read every reference to it.
  3. “Complete enumeration” claimed while a branch was unread. The t.idx branch 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.