promotional banner

GSE: Sequences, Variables, Macros

GSE is an advanced macro compiler that is an alternative to the limits provided by the default macro editor.
Back to Files

3.3.34

File nameGSE-3.3.34.zip
Uploaded
Sep 16, 2026
Downloads
22.7K
Size
2.6 MB
Flavors
MoP ClassicRetailClassic TBCClassic
File ID
8899535
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 5.5.4
  • 2.5.6
  • 1.15.9

What's new

GSE

3.3.34 (2026-09-16)

Full Changelog Previous Releases

  • #2109 spec: read Init.lua without depending on its line endings
    The Busted job went red on master while luacheck passed:
    spec/gamemode_spec.lua:33: GSE.TOCFlavour block not found in Init.lua
    .gitattributes marks *.lua as eol=crlf, so the blob is stored LF and every
    checkout -- CI's included -- writes CRLF. The GSE.TOCFlavour lift was anchored
    on return "exp" .. tocMajor\nend, which cannot match a file whose lines end
    "\r\n", so the extraction returned nil and the assert fired.
    It passed locally for a reason worth recording, because it will recur otherwise:
    the file had just been written by hand and was still LF when the spec ran. Only
    a later checkout converted it to CRLF, so the green run and the red run were
    reading genuinely different bytes. Verified by reproducing the CI failure
    locally against the now-CRLF working tree, fixing, and re-running against that
    same CRLF file.
    Two changes, because normalising alone would leave the pattern fragile:
    • readInit() strips CR on read, so every pattern in this spec is line-ending
      agnostic rather than each one having to remember.
    • the flavour lift anchors on the final return only and supplies the closing
      "end" itself, so neither a line ending nor an added line inside the function
      can break it. The GameMode lift already anchored on an assignment with no
      newline in it, which is why that half stayed green.
      The specs still bite: disabling the Forever rule fails three of them, unchanged
      from before.
      luacheck 0 warnings / 0 errors across 67 files; busted 470 passing; lua5.1
      spec/run51.lua (PUC 5.1.5) all specs passing -- the last two now run against a
      CRLF working tree, which is what CI actually checks out.
      Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
      Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
  • Merge remote-tracking branch 'origin/master'
  • #2107 toc: Forever and Classic Era are not the same flavour
    The version-mismatch warning compared TOCs at math.floor(toc / 10000), which
    yields the major -- and Forever shares Classic Era's major. A Forever sequence
    (16001) and an Era sequence (11509) both reduced to 1, so carrying a sequence
    between two streams with opposite rulesets raised no "not specifically designed
    for this version of the game" warning at all. Silent, and precisely the case the
    warning exists for.
    The minor separates them: Era is 1.15.x, Forever 1.60.x. Both TOC shapes divide
    the same way, so no digit-count branch is needed -- major = floor(toc / 10000)
    gives 11 for 110005 and 1 for 11509, and minor = floor(toc / 100) % 100 gives 0
    and 15. That is also why this did not need the confirmed TOC after all: the rule
    is "major 1, minor at or above 60", not a specific number, so it holds whether
    the client reports 16001 or something adjacent.
    Added as GSE.TOCFlavour rather than inlined. GSE.GameMode answers "what APIs
    does the RUNNING client have" and is for API gating only; this answers "are
    these two stamped TOCs the same flavour" for an arbitrary sequence TOC. Same
    subject, different question and different input, so it gets its own helper
    instead of a second reading of GameMode -- and it sits directly under the
    FOREVER constants, so the Forever rule exists exactly once in the Mod.
    The key is deliberately opaque and deliberately not a number: Forever returns
    "forever", every other flavour "exp<major>". A numeric key for Forever would
    collide with a major retail reaches on its own, which is the same trap as
    picking a GameMode ordinal above 12. Callers compare with == and nothing else.
    nil means unknown, which differs from every real key, so an unparseable TOC
    still warns.
    Tests cover both directions: patches of one flavour compare equal, different
    flavours compare unequal, Forever is distinct from Era, the Forever key is a
    string and distinct from a six-digit 160001, and junk returns nil. Verified they
    bite -- moving the minor threshold so the rule stops matching fails three of
    them, including the Era/Forever separation.
    luacheck 0 warnings / 0 errors across 67 files; busted 465 -> 470 passing;
    lua5.1 spec/run51.lua (PUC 5.1.5, what CI runs) all specs passing.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
  • #2107 gamemode: Forever reports the API generation it actually has
    GSE.GameMode gates API AVAILABILITY, not content. Every GameMode > x test in
    GSE asks "does this client have the APIs introduced at x" -- the empowered-cast
    events, the Settings menu API, C_SpecializationInfo. Until now the expansion
    major answered that, because content and API generation always advanced
    together.
    Forever breaks that: vanilla-era content (1.60.x) on Retail's API surface. Taken
    at face value its major is 1, which is Classic Era, so every retail path would
    have switched off and every classic path on -- the whole ruleset inverted across
    all 44 call sites. It now reports 12, the generation whose APIs it has, and
    every one of those tests is correct unedited.
    12 is deliberately fixed rather than "whatever retail is now". When The Last
    Titan introduces APIs at 13, a >= 13 test must be FALSE on Forever until
    Forever ships them, and reporting 12 gets that right for free. The
    obvious-looking alternative -- giving Forever an ordinal ABOVE retail -- is
    wrong: it burns a number retail will itself reach, and 13 is already announced
    and already shipping in the Companion and interface maps.
    Identity is deliberately not carried here. A sequence's flavour is its TOC
    (MetaData.TOC, stamped from GetBuildInfo's tocversion in Editor_Tree.lua, which
    this does not touch), so Forever stays distinguishable from Midnight everywhere
    that matters without GameMode encoding two different facts in one integer.
    Anything asking "am I on Forever?" reads the TOC.
    Keyed on the version string, never on the product or install folder: Forever
    currently ships on wow_classic_beta, the shared classic TEST product, which is
    not its own and will move.
    spec/gamemode_spec.lua lifts the derivation block out of the shipped Init.lua
    and runs it, so it tests the real source rather than a copy of the rule --
    change the constants and it fails. Verified it bites: with an ordinal of 16 it
    fails both the Forever mapping and the "does not claim APIs newer than the
    generation it has" case, which is the collision this design avoids.
    Known gap, not addressed here: GSE_Utils/Utils.lua compares TOCs at
    floor(toc/10000), so a Forever TOC (16001) and an Era TOC (11509) both resolve
    to 1 and no "not designed for this version" warning fires when a sequence
    crosses between them. That needs the confirmed TOC before it is worth changing.
    luacheck 0 warnings / 0 errors across 67 files; busted 460 -> 465 passing;
    lua5.1 spec/run51.lua (PUC 5.1.5, what CI runs) all specs passing.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
  • Merge pull request #2108 from LarryThiessen/bindings-active-loadout
    #2107 Bindings tree: gold ring on the loadout you are in, band on the one you edit
  • bindings tree: don't stack the gold band on a host skin's own highlight
    The selected-row band was shown on selected and line.selectHighlight with no
    skin check. That is right for GSE's own skins and wrong under an external
    provider: ElvUI/EllesmereUI already paint the selected row in the host's accent
    colour a few lines below, and this band is ADD-blended gold across the full row,
    so the two stacked wash an ElvUI user's accent with gold. That is the opposite
    of what HostAccentColor is for -- Skin.lua calls it "the single shared source
    for every host-accent paint site" precisely so GSE frames match the host.
    Gated off when a provider is active, rather than tinted to the host accent,
    because the host band already marks the selection: tinting would have left two
    bands of the same colour stacked at different alphas and changed what
    ElvUI/EUI users see today. This way their look is untouched and the band fills
    the gap where there genuinely was none.
    Worth restating the scope, since the issue reads as Modern-only: the old
    highlight was if selected and hasExternalSkinProvider(), so NATIVE had no
    selected-row band either -- and NATIVE is the effective mode whenever no
    provider is installed, which is most players. The band is for both.
    The gold RING is deliberately left unconditional. It is a state indicator --
    "this is the loadout you are in" -- and it reads that way because it matches
    Blizzard's talent UI, so it should stay gold in every skin. The band is
    selection chrome, which is the host's business. Different things, different
    rules.
    luacheck 0 warnings / 0 errors across 67 files; busted 460 passing; lua5.1
    spec/run51.lua (PUC 5.1.5, what CI runs) all specs passing.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
  • #2107 Bindings tree: gold ring on the loadout you are in, band on the one you edit
    The loadout the player is in (GetLastSelectedSavedConfigID, as the keybind
    rebuild uses) wears the gold node ring on its tree icon and panel medallion.
    Spec and loadout rows get a gold band while selected, and opening Bindings
    unfolds the current spec under both areas.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Merge remote-tracking branch 'origin/master'
  • #2106 editor: a released widget must not fire its old life's callbacks
    Deleting one Action block from an imported sequence silently rewrote many
    remaining actions from {type = "spell", spell = 49998} into
    {type = "macro", macro = "<localised spell name>"}. Their spell fields turned
    red and the abilities stopped casting. No Lua error. Setting
    GSE_NoWidgetPool = true made it stop, deterministically (#2106).
    The payload. Editor.lua creates an action block's macro box for EVERY action
    type, not just macros, and fills it with spelltext -- for a spell action that
    is the spell's localised name. Its OnTextChanged is an unconditional convert:
    it stores .macro and nils .spell, .action, .item and .toy, through a keyPath
    captured in the closure.
    The trigger. That box's own OnRelease calls DisableMultilineEditorColoring,
    which is IndentationLib.disable(). disable() restores our OnTextChanged bridge
    and then ends with editbox:SetText(newGetText(editbox)) to put the unformatted
    text back -- firing the live handler in the middle of teardown. The box held a
    spell name, so the handler did exactly what it is written to do and converted
    the action to a macro.
    Why the handler was still live: callbacks were only ever cleared on ACQUIRE, in
    resetForReuse. Release fired OnRelease with every callback still bound and left
    them bound in the pool. So Release now clears them, after OnRelease has run so
    teardown handlers still work, and before ReleaseChildren so each child does the
    same on the way down. That closes the class, not just this path -- any callback
    firing on a released widget is a bug by definition.
    Why the pool decided it: disable() early-returns unless enabled[editbox] is set,
    and that table is keyed by the inner EditBox FRAME inside Indent.lua, where the
    pool cannot reach it. With pooling off every box is new, the flag is nil,
    disable() returns before the SetText, and nothing fires. With pooling on, a box
    banked by the variable editor, the raw sequence-table editor, the macro preview
    or the compare view -- all of which enable it -- carried the flag into its next
    life. That also explains why deleting the first action alone did not reproduce:
    it takes a prior interaction to seed the flag on a widget that later lands in an
    action block.
    So resetForReuse now strips IndentationLib on acquire too. That is the other
    half: the flag surviving into the pool also handed the next consumer
    IndentationLib's SetText/GetText overrides and its OnTextChanged/OnTab/OnUpdate
    hooks. Action blocks escaped that only because Editor.lua disables it by hand
    right after UI:Create -- a per-caller courtesy, not a guarantee. It is placed
    first in the edit box block so the SetText("") and the pristine SetMaxLetters
    below overwrite what disable() restores from its own saved copies, and it is
    safe precisely because callbacks were emptied earlier in the same function --
    the SetText inside disable() is inert with nothing bound.
    Note the mechanism is established from the code, not observed firing: this
    cannot be exercised outside the client. The step worth confirming live is that a
    recycled box still has enabled[editbox] set at release time; everything
    downstream of that is unconditional.
    Same family as the stale pooled-widget state in #2020 and #2052.
    luacheck 0 warnings / 0 errors across 67 files; busted 460 passing; lua5.1
    spec/run51.lua (PUC 5.1.5, what CI runs) all specs passing.
    Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
  • #2104 editor: derive an edit box's font, and re-derive it on reuse
    Opening a three-block sequence, block 3's macro text came out larger than
    blocks 1 and 2 -- same editor, same sequence, nothing set differently on that
    block -- and which blocks changed from one open to the next (#2104).
    An EditBox holds its font at frame level rather than in a FontString, and both
    halves of the widget pool are blind to that. snapshotPristine captures text
    styling by walking the widget for FontStrings, filtering on
    not v.CreateFontString -- an EditBox has one, so it is excluded by
    construction and its font was never recorded. resetForReuse therefore handed
    back a box still wearing whatever face or size its previous life had been given.
    That is the "moves around to different blocks" part.
    The fix is to re-derive the font on acquire rather than restore a recorded one.
    Under the originally proposed fixed 14 the two are identical, but a literal is
    wrong anyway: 14 is legible at one resolution and UI scale and unreadable on a
    large monitor at a small scale. Size and flags now come from ChatFontNormal and
    the face from GSE.Skin.HostFont(), both read at apply time -- and a derived
    value is not constant, because it moves when the user changes skin mode,
    switches ElvUI/EllesmereUI profile, or runs a chat addon that resizes the chat
    font. snapshotPristine only runs on the fresh-construct branch of UI:Create, so
    anything recorded there is frozen at login; restoring it on every acquire would
    re-pin a stale font and fight ApplyHostFontToTree. That is the same trap the
    issue already records for labels -- SetFontObject on a recycled label does
    nothing, because the reset hands it back with an explicit font that outranks it.
    Timing makes re-apply work: resetForReuse is called from UI:Create, on ACQUIRE,
    so the box is handed out with the font as it is now. One helper, called from
    both edit box constructors and from resetForReuse, so creation and reuse cannot
    drift -- the drift being the actual cause here.
    ApplyHostFontToTree now handles an EditBox before its region sweep. An EditBox
    is not returned by GetRegions(), so every edit box sat out the host-font pass
    that each label followed. Size and flags are preserved; only the face changes,
    with the same restore-on-reject guard so a bad path from a skin never blanks
    the text.
    Two consequences, recorded rather than left to be discovered:
    Applied with SetFont, so SetFontObject on an edit box is now outranked and
    silently does nothing -- the same trap Editor_Tree.lua:1118 notes for headings.
    applyEditBoxFont is the styling path for edit boxes. Editor_Tree.lua:310 stops
    tracking ChatFontNormal live once the sweep makes its values explicit; it lands
    on the same size either way, so this is a consistency change, not a visible one.
    Labels are left alone on purpose. resetForReuse still restores every FontString
    to its creation font, which is the same staleness this removes for edit boxes
    and the mechanism behind the SetFontObject symptom above -- but changing it
    touches every pooled widget, not just edit boxes, so it wants its own decision.
    luacheck 0 warnings / 0 errors across 67 files; busted 460 passing; lua5.1
    spec/run51.lua (PUC 5.1.5, what CI runs) all specs passing.
    Claude-Session: https://claude.ai/code/session\_01JdPcb24JAYCGmnki5FLo7Y
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
  • Merge pull request #2100 from LarryThiessen/editor-alphabetical-lists
    Editor: sort the class, Variables and Macros lists alphabetically
  • Editor: sort the class, Variables and Macros lists alphabetically
    Three lists in the editor's tree came out in whatever order their source
    happened to hand them over:
    • Classes sat in class-id order -- Warrior, Paladin, Hunter, ... Evoker --
      which is only an order if you already know the ids. They are now
      alphabetical, sorted on the plain class name rather than the text, which is
      wrapped in the class colour. Global stays last: it is not a class, and it
      reads as the catch-all at the end rather than between Evoker and Hunter.
    • Variables came from pairs(GSEVariables), so hash order: no order at all, and
      it shuffled between sessions.
    • Macros came from walking WoW's macro slots, which is creation order. They are
      sorted within each group, Account and Character, and a node's value is still
      its slot id -- sorting what is displayed leaves what each node points at
      alone.
      All three use GSE.AlphabeticalTableSortAlgorithm, the comparison the sequence
      list already sorts with, so the whole tree agrees on what alphabetical means.
      Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Merge pull request #2096 from LarryThiessen/last-sequence-per-class
    Editor: remember the last opened sequence per class
  • strings: cover StringFunctions end to end, and fix what that turned up
    StringFunctions goes from 45.8% to 100% of the lines coveralls counts. Nothing
    in it needs the game -- a string goes in and a string comes out -- so every
    branch was reachable from a spec. 112 tests across two files: the text half
    (markup, the editor decode path, the sanitiser, the strippers) and the table
    half (Dump and the helpers).
    The three lines still unhit are while true do, a break, and a closure
    header. The VM emits no line event for any of them and coveralls already marks
    them non-relevant.
    Writing them turned up three defects:
    • GSE.StripControlandExtendedCodes duplicated content on any CRLF input. The
      carriage-return branch read str:sub(i, str:byte(10)) -- the byte VALUE at
      position 10, used as an end index -- so "/cast Alpha\r\n/cast Beta" came back
      as "/cast Alpha\r\n/cast Beta\n/cast Beta". This is the import path
      (CompressSequenceFromString), so anything pasted from a Windows clipboard was
      corrupted. It is str:sub(i, i) now, which is what the comment beside it
      ("Leave line breaks Windows style") always said it meant.
    • GSE.DecodeMacroEditorText never repaired the first line. The pattern was
      ([ \t]*)|([%a]+), where the ^ sits INSIDE a capture group and is therefore a
      literal caret, not an anchor -- it only ever matched text beginning with "
      ".
      A one-line macro that lost its slash stayed broken while every line below it
      was repaired. Anchored properly now.
    • GSE.CleanStringsArray carried a dead branch. tempval == [[""]] cannot fire,
      because GSE.CleanStrings has already collapsed an exact "" to an empty
      string before it returns -- and if it ever did fire, tabl[k] = nil inside
      ipairs would truncate the rest of the array. Removed.
      Two more are pinned by tests but deliberately NOT changed, because both are
      decisions rather than typos:
    • GSE.Dump writes a multi-line string as [[\n <value> \n]]. Lua drops the
      newline after [[ but keeps the one before ]], so the value comes back one
      "\n" longer each export/import cycle. Dump feeds the export box, so this is
      real drift in stored macro bodies -- but fixing it changes the export format.
    • GSE.GetMacroStringFormat is effectively the constant "DOWN": C_CVar.GetCVar
      returns the STRING "0" or "1" and both are truthy, so only an unset CVar
      yields "UP". GSEOptions.CvarActionButtonState, the override that would settle
      it, is read in two places and written in none. It feeds CreateMacroString's
      /click, which sits on top of the secure click model, so it is recorded rather
      than guessed at.
      Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
      Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • Editor: put the forget doc block back on the function it describes
    GetLastSequenceEditorPathForClass landed between ForgetLastSequenceEditorNode's
    comment and its body, so the block explaining what PLAYER_LOGOUT clears sat
    above the per-class getter instead. Moved back, and it now names the per-class
    table as one of the things forgetting has to clear -- which is the point: leave
    that table behind and the next login reopens exactly what the option asked to
    forget.
    Comment-only.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • Merge pull request #2098 from LarryThiessen/bindings-chooser-layout
    Bindings chooser: centre the tiles, and square the column highlights up
  • Bindings chooser: centre the tiles, and square the column highlights up
    The chooser's two tiles sat at the top of the pane with the rest of it empty
    below them. They are now centred on the pane as a block: a spacer above them,
    sized once the layout has landed, measured straight from the geometry -- the
    pane's top and bottom, and the block's own top and bottom. It only ever grows,
    so a block taller than the pane stays where it is. The columns keep their
    alignment to each other; the whole block simply moves down together.
    The column highlights lined up with the tiles rather than with the pane, which
    showed once the tiles moved. They now run the pane's full height, from just
    under the section divider -- KB_HOTSPOT_TOP_GAP, 4px, so the rounded top edge
    clears the line -- to the bottom.
    Their outer edges now come from the ends of that divider line, the edge the eye
    measures them against. mirrorHotspots previously mirrored the left column's
    inset onto the right, which put the right edge past the line: the pane reserves
    space on the right for its scrollbar, so the line stops short of the pane edge
    on that side and the two did not agree.
    The right column's content was also reading as shifted left. The two cells are
    0.49 of the row each and pack from the left, so the 2% left over sat to the
    right of the second column; it is now pushed over by exactly that, measured
    after layout, so each column sits the same distance inside its own edge.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Editor: remember the last opened sequence per class
    The editor already saves the last sequence you had open and restores it when
    the editor reopens, and that state persists across sessions. But it is a single
    path, belonging to whichever class was used last, and the restore drops it when
    the current character's tree cannot show it -- so logging in on another
    character landed on New Sequence, and the sequence stayed remembered only for
    the one class.
    The path is now also kept per class. When the most recent one belongs to a
    class this tree does not show, the editor falls back to the last sequence
    opened on THIS class. A Shaman's sequence is therefore never opened on a Demon
    Hunter, and each character comes back to where it left off.
    Everything else is unchanged: the most recent path still wins whenever the tree
    can show it (including a Global sequence while the Global filter is on), a
    remembered sequence that has since been deleted is dropped rather than
    restored, and "Forget Last Opened Sequence on Logout" clears the per-class
    memory along with the rest.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • storage: cover the GSE3 button build, and fix the flat step index
    A frame is a table, SetAttribute writes a key on it, and Execute is handed
    a string of Lua the secure environment runs to rebuild the step list on the
    other side. All of that is measurable without the game, so spec/storagebutton_spec
    records a frame and replays the Execute payload -- the literal string the addon
    builds, not a re-implementation of it -- against the globals the secure
    environment provides. If the encoder and the snippet ever stop agreeing, the
    round trip breaks here.
    36 tests: frame creation and the AnyUp/useOnKeyDown click model, combatreset,
    the rebuild path (no second frame, no second OnClick wrap, pause options
    re-stamped), the step-1 attribute transfer including the macro/macrotext
    mutual clear and the blockPath exclusion, and the encoded list itself --
    order preserved, spell ids numeric and everything else string, macro text
    intact across the \002/|/\001 separators, 253 steps per iteration, 600 steps
    across three iterations with nothing lost or reordered.
    Writing those turned up a real fault. GSE.SequencesExec holds ONE flat list;
    the secure side holds the same steps chunked at 253. Both GSE.GetCurrentButtonIconInfo
    and GSE.UpdateIcon converted back with step + iteration * 254 -- wrong
    multiplier and wrong base. Iteration 2 step 1 resolved to 509 instead of 254,
    so on any sequence longer than 253 steps the button icon, every action-bar
    override of it, and the Step reported to WeakAuras and the sequence debugger
    were all pointing at the wrong step. It is now
    (iteration - 1) * SECURE_STEPS_PER_ITERATION + step, with that constant
    declared once and also driving the chunker, so the two sides cannot drift
    apart again.
    The fix ships with the tests deliberately: reverting the arithmetic fails two
    of them.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • spec: cover the compiler, and drop an accidental Lua 5.1 dependency
    The compiler is the most load-bearing logic in the addon: everything else
    stores, syncs or displays a sequence, this decides what actually happens when
    the key is pressed and in what order. A StepFunction that expands wrongly is
    not cosmetic -- it is somebody's rotation quietly doing something else. It is
    also pure table-building, so it is measurable off-game: give it blocks, read
    back the list.
    Sixteen cases over CompileTemplate / processAction / buildAction:
    StepFunction expansion -- Sequential in order; Priority's triangular
    front-loading asserted as the literal list a,a,b,a,b,c rather than by length;
    ReversePriority mirroring it; Random drawing WITHOUT replacement so no block
    is skipped or fired twice (math.random pinned to make that checkable); Repeat
    multiplying the whole expansion; and 0, negative and non-numeric Repeat all
    still running exactly one pass.
    Skipping and nesting -- a disabled block compiles to nothing, a disabled LOOP
    takes its children with it, a loop inside a loop expands, and a nested
    Priority expands inside a sequential parent.
    Type inference -- a block saved without a type still has to compile to
    something castable, and the fallback order is the contract: macro, item, toy,
    spell, empty macro as the floor. A spell block with no spell demotes to a
    macro rather than building a button that casts nil.
    And that CompileTemplate does not mutate what it was given -- it clones first,
    and buildAction WRITES action.type, so without the clone compiling a sequence
    would rewrite the stored one.
    CompileTemplate's Actions metatable turned out to be 5.1-only by accident. Its
    __index treats the key as a PATH and walks it with ipairs(k); 5.1's ipairs is
    raw so it never fires, but 5.3+ honours __index and a numeric key reached
    ipairs(k) and errored. In game that is 5.1 and it has never mattered, and CI's
    busted is 5.1.5 so CI never saw it -- but every local busted run on 5.4 errored
    on any CompileTemplate call with actions in it. A non-table key now reads as
    absent, which is what the raw read said anyway.
    luacheck 0/0 across 67 files; busted 312 passed (was 296); lua5.1
    spec/run51.lua all specs passed.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • spec: cover the OOC queue's priority rules and duplicate's identity minting
    Twelve cases moved Storage.lua from 16% to 17%, which is not coverage, it is a
    rounding error. Targeting by weight instead: of the twenty largest functions in
    the file most are WoW-UI bound and untestable off-game (PCallCreateGSE3Button,
    UpdateIcon, GetCurrentButtonIconInfo, ManageMacros). EnqueueOOC and
    DuplicateSequence are pure logic, are ~160 lines between them, and are both
    load-bearing.
    EnqueueOOC (17 cases) is the out-of-combat work queue. Everything the addon
    cannot do in combat lands there and is drained on the way out, and the rules
    are not "append" -- a heavier operation supersedes lighter ones already queued
    for the same subject, and a lighter one is dropped when a heavier is pending.
    Every rule in the hierarchy now has a case: MergeSequence > Save/Replace >
    UpdateSequence; importmacro > updatemacro; updatevariable replaces in place;
    FinishReload/managemacros/openoptions are one apiece; CheckMacroCreated is one
    PER SEQUENCE rather than one overall. Plus the properties that matter as much
    as the rules -- a different sequence's work is never collateral, an action with
    no rule is appended rather than silently collapsed, entries with no node to
    compare are not treated as duplicates, and unrelated work keeps arrival order.
    DuplicateSequence (7 cases) mints its own record. The copy must NOT inherit the
    source's PlatformID: sharing one server id has the copy and the original
    overwrite each other on the next Companion sync, which is #2077 in its
    import-rename form. Covered: identity is minted not inherited, the source is
    untouched, the body is deep-copied, a supplied name is normalised the way
    import normalises it, auto-numbering walks Copy/Copy2/Copy3, a name already in
    use is refused rather than overwritten, and a missing source returns nil.
    Both verified by breaking what they cover -- dropping the PlatformID clear, and
    disabling MergeSequence's supersede -- which fails exactly one case each and
    nothing else.
    luacheck 0/0 across 67 files; busted 296 passed (was 272); lua5.1
    spec/run51.lua all specs passed.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • spec: cover Storage.lua's delete, clone and repack-request paths
    Storage.lua sits around 16% covered. packedatrest_spec covers the WRITE side --
    which branch a save takes when the record is protected -- so this takes the way
    OUT, and the request the addon files when it cannot seal something itself.
    Both lose work rather than inconvenience somebody when they are wrong: a delete
    that leaves a fork behind means the next install under that PlatformID silently
    inherits a stranger's edits.
    Twelve cases:
    DeleteSequence -- forgets the record's fork (the id is read BEFORE the entry
    goes, or there is nothing left to key by), leaves a neighbour's fork alone,
    survives a record that never had one, and drops actionbar overrides that
    pointed at it while keeping the rest.
    DeleteCorruptSequence -- forgets the fork when the body did load. The second
    case DOCUMENTS a limit rather than asserting it is right: the PlatformID is
    read from GSE.Library, and a sequence is usually on the corrupt list BECAUSE
    loadOneClass could not decode it and nil'd that entry, so in the common case
    the fork cannot be keyed and survives the delete. GSEPlatformIDs is no help --
    it is keyed name|author and the author is in the body we could not read.
    QueueRepack -- carries identity only and never a body, which is the whole
    point of the queue; a decoded body in the request would recreate the
    plaintext-at-rest exposure in a second place. Also idempotent (ten logins,
    one entry), refuses what it cannot key, and clears.
    CloneSequence -- deep, so a copy cannot write through to the stored original,
    and scalars pass through.
    Each was checked by breaking the thing it covers: dropping DeleteSequence's
    ForgetDeltaFork, making CloneSequence shallow, and letting Versions ride along
    in a repack entry fail 4 of the 12 between them and nothing else.
    luacheck 0/0 across 67 files; busted 272 passed (was 260); lua5.1
    spec/run51.lua all specs passed.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • build: drop the ldoc docs build and the gh-pages deploy
    It has been publishing an empty shell. ldoc prints
    GSE/API/CharacterFunctions.lua:263: no module() call found; no initial doc comment
    and writes only its stylesheet -- the successful run's artifact listing is one
    line, ./docs/ldoc.css. The API files carry no @module tag; they are plain
    chunks since the private-namespace refactor, and ldoc needs a module() call or
    a @module header to treat a file as documentable. So the site has been a
    deploy of nothing, and the only thing it reliably produced was the ref-lock
    race that failed the build.
    Annotating eighteen API files to bring it back is work in service of docs
    nobody has been able to read, so: gone. The step, the pages deploy, the ldoc
    rock, config.ld and pages/index.html, none of which anything else references.
    The concurrency group stays. The gh-pages deploy is the race that failed
    visibly, but Publish Patreon and Publish to GSE Tools are the same shape of
    hazard and are still here.
    Not done, and deliberately: the gh-pages BRANCH is untouched, so the existing
    site keeps serving whatever it last had rather than 404ing the moment this
    lands. Deleting it is a separate decision.
    luacheck 0/0 across 67 files; busted 260 passed; lua5.1 spec/run51.lua all
    specs passed.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • build: stop the gh-pages race, and make luacov report this addon
    Two things noticed while looking at the build. Neither is a code fault.
    The CI failure is a RACE, not a broken build. #2092 and #2093 merged minutes
    apart, both runs force-pushed gh-pages, and the second lost the ref lock:
    ! [remote rejected] ... cannot lock ref 'refs/heads/gh-pages':
    is at 23ff128f but expected 224bfec0
    A concurrency group queues them instead. Deliberately NOT cancel-in-progress:
    by the time a second run starts, the first has usually already published to
    Patreon and GSE Tools, and killing it half way through is worse than letting it
    finish and deploying twice.
    luacov reported every rock busted had loaded because its include filter never
    matched anything. The patterns are Lua patterns matched against the file path,
    and 'GSE$' means "ends with GSE" -- no file in this addon does, GSE/API/Storage
    being the obvious example -- so nothing was ever included and the exclude list
    was doing all the work, which is why third-party code came through. '^GSE'
    covers the addon's own trees and only those: GSE, GSE_GUI, GSE_Utils, GSE_LDB,
    GSE_Options, GSE_QoL, GSE_Companion.
    Two exclude entries went with it. '.lua$' does not mean "a .lua file" -- '.' is
    any character in a Lua pattern, and luacov has already stripped the extension
    before matching -- and '\(lua\_install\)' has a '$' in the middle, which is a
    literal, so it matches nothing any file is called. Verified the resulting
    config by evaluating it: the addon's trees match, spec/, .luarocks and penlight
    do not.
    The docs half of the same step is a separate problem and not fixed here. ldoc
    prints "no module() call found; no initial doc comment" and writes only its
    stylesheet, because the API files carry no @module tag -- the private-namespace
    refactor left them as plain chunks. Wants its own change.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • checksequencesforerrors: recommend a repair the user can actually run
    The error report told people to run
    /run GSE.FixSequenceStructure(2, "OAKRETST")
    and that has never worked. GSE is the addon's private namespace; the only
    global is the locked plugin proxy in API/Plugins.lua, which carries
    RegisterAddon, GetSequenceNamesFromLibrary, isEmpty and an empty Statics, and
    nothing else. So the call found the proxy, found no such field, and answered
    "attempt to call a nil value" -- for every user who followed the advice, on
    every build since the proxy landed. A user reported exactly that, having first
    been told by the checker to try it.
    The function itself is fine and has been since 2026-03-01. It was only ever
    unreachable from where the report sent people.
    /gse fixsequence <classid> <sequence name> runs it from inside the module,
    where the private GSE is in scope. The name is everything after the class id,
    unjoined -- sequence names contain spaces and the dispatcher splits on them --
    and it refuses politely when the class id or name is missing, or when no such
    sequence is in that class library, rather than erroring again in front of
    somebody who is already looking at a broken sequence.
    The report now prints that form.
    Two specs: the report recommends the reachable command, and -- the one that
    would have caught this -- it never tells the user to /run an internal. The
    existing spec asserted the broken string, so it passed all the way through.
    This does not address the structural errors that user is seeing. It addresses
    their only being able to delete the sequence, because the repair the addon
    pointed them at could not be invoked.
    luacheck 0/0 across 67 files; busted 260 passed; lua5.1 spec/run51.lua all
    specs passed.
    Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
    Claude-Session: https://claude.ai/code/session\_01E2e9X8WnFeCvudqLGS8sJx
  • Merge pull request #2093 from LarryThiessen/corrupt-sequence-panel
    Corrupt sequences get a panel in the editor
  • Merge pull request #2092 from LarryThiessen/fix-malformed-sequence-records
    Sequences with missing metadata no longer blank the list or stop a reload
  • Merge pull request #2091 from LarryThiessen/fix-last-block-reset
    Editor: deleting the last block leaves one Action block
  • Corrupt sequences get a panel in the editor
    Clicking a red-flagged sequence printed one chat line telling the player to
    right-click it and choose Delete. It now opens a panel in the editor's right
    pane instead:
    • The issues /gse checksequencesforerrors reports for that sequence, laid
      out centred under a "GSE Addon" header. They come from the same
      checkSeqStructure and the same localised strings, now exposed as
      GSE.CheckSequenceStructure, so the panel follows whatever the scan says.
      They are still printed to chat as well, the way the scan prints them.
    • Delete, through the existing confirmation dialog. GUIDeleteSequence gains
      an optional third argument, a callback run only after the user confirms,
      so the panel can close itself; existing two-argument callers are
      unaffected.
    • Repair, only when the scan can actually fix one of the listed issues. That
      is the scan's own rule, moved out of ScanMacrosForErrors and shared as
      GSE.IsAutoFixableSequenceIssue; the scan keeps using it through an alias.
    • A spec dropdown when a missing SpecID is the only problem, offered only
      when giving the sequence a spec would unbreak it. It lists the specs of the
      class the sequence is stored under, so picking one never has to move it,
      and saves through the same queued Replace the editor's Save uses.
      IsSequenceStructurallyBroken, whose only callers are the tree's red flag and
      its click routing, now also flags:
    • a missing SpecID, tested exactly as the scan reports it -- isEmpty is nil
      or "", so a Global sequence's SpecID 0 is not missing;
    • versions numbered from 0, tested as the scan tests them (Versions[0] ~=
      nil). ipairs sees no versions in such a sequence, so it looked empty in the
      editor and did nothing in game, and it is the one case Repair fixes.
      The panel sizes its text with SetFont rather than SetFontObject, the way
      addSectionDivider does. Labels are pooled and resetForReuse hands one back
      with its original font set explicitly, which outranks SetFontObject, so a
      recycled label silently kept its old size. Each label is also re-measured a
      frame after the panel draws: sized when its text is set -- before a recycled
      label is shown or widened -- the large text could measure as one line and
      the rest was cut off behind "...".
      Merge after the missing-metadata fix: Repair runs the sequence reload that
      change hardens.
      Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Sequences with missing metadata no longer blank the list or stop a reload
    Several paths read MetaData off every Library entry without checking it
    exists, so one malformed record broke each of them for every sequence:
    • GetSequenceNames concatenated j.MetaData.SpecID for every current-class
      and Global sequence, before the tree isolates each sequence in a pcall.
      A record with no MetaData or no SpecID threw, ManageTree never reached
      SetTree, and the sequence list came up completely blank -- New Sequence
      and Import included. Such a record now keys under spec 0, the key a
      not-yet-decoded foreign-class sequence already gets; the tree then flags
      it red for deletion as before.
    • ManageTree called C_ClassColor.GetClassColor(classfile):GenerateHexColor()
      for every class node. A class the client does not know -- a Demon Hunter or
      Evoker sequence on a Classic client, or a bad class id -- has no classfile,
      so it indexed nil after every sequence was built and before SetTree: the
      same blank list. It now gets a plain label.
    • PerformReloadSequences read sequence.MetaData.Disabled and
      sequence.Versions[...] for every sequence; a record with neither stopped
      the reload for every sequence after it (reached from the checksequences
      repair path). Records with no MetaData or no Versions table are skipped --
      there is nothing in them to compile. Everything else compiles exactly as
      before, a missing SpecID included.
    • GetSequenceSummary tested not (MetaData and noExport), which is true
      when MetaData is missing, and went on to read .Help off nil. Healthy and
      noExport records are summarised exactly as before.
      Players have been reporting an empty sequence list after updating, and
      being told to run /gse checksequencesforerrors.
      Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Editor: deleting the last block leaves one Action block
    Deleting the only remaining top-level block left Actions empty, with the
    editor's block selection still pointing at the block just removed. The next
    Add walked that stale path through TableMetadataFunction's __index into
    nothing and errored:
    GSE/API/Statics.lua:520: attempt to index nil
    A sequence now always keeps one block. Deleting the last one puts back the
    same single Action a new sequence starts with, and the existing refocus
    selects it as {1}, which replaces the stale selection.
    The editor also starts at the top. The saved scroll position belonged to the
    list that just emptied, and the rebuild restores it (finishDraw and
    ChooseVersion both SetScroll it), so against one block it clamped to the
    bottom of whatever small range was left.
    Nested lists are unchanged: emptying a Loop still leaves it empty and
    focuses the parent, and deleting any other block keeps the scroll position.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Merge pull request #2087 from LarryThiessen/fix-character-macro-buckets
    Character macros: stop deleting their buckets, and sync into them
  • Merge pull request #2085 from LarryThiessen/loop-step-repeat-one-row
    Loop block: Step FN and Repeat share one row
  • Character macros: stop deleting their buckets, and sync into them
    Two faults, one symptom: macros in WoW's "<Character> Specific" tab never
    reached GSEMacros under their character, so the Companion never uploaded them
    as character macros. On a live account with 13+ characters holding 11-30
    character macros each, there were zero GSEMacros["Name-Realm"] buckets on disk.
    ManageMacros deleted every bucket. GetMacroIndexByName answers 0, not nil, for
    a name the character does not have, and 0 is truthy. The account-level loop
    walks every key in GSEMacros -- the buckets included -- and "Name-Realm" is
    never a macro name, so each bucket went to GetMacroInfo(0), came back nil, and
    was set to nil. startup() recreates the current character's bucket at login;
    the next ManageMacros removed it again. The else branch that keeps tables was
    written for exactly this and could never be reached. The per-character loop
    had the same test and dropped any bucket macro the character does not hold.
    Both now test slot > 0, the way UpdateMacro and SnapshotDependentMacros
    already do.
    SyncWoWMacrosToGSE wrote character macros flat. captureWoWMacros read them
    with character = true and the writer ignored it, so they landed at account
    level, uploaded without category "p", and came back down through ImportMacro
    into General Macros on every character. They now go to the character's
    bucket, keyed exactly as ManageMacros reads it back. Account and character
    macros are captured into separate tables, since the two can share a name and
    one table let the character copy overwrite the account one.
    Every entry now carries text. ManageMacros, UpdateMacro and the Companion
    all read the body from text; the Companion goes further and treats any
    entry without a string text as a character bucket, so an entry holding only
    manageMacro had its own fields read back as macros.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com
  • Loop block: Step FN and Repeat share one row
    Repeat had a row to itself for one four-character box, so every Loop block in
    a sequence spent a whole line of height on it. The two belong together anyway
    -- how the block steps, and how many times -- and they read as one setting
    when they sit side by side:
    Step FN [Priority v] Repeat [2]
    Repeat's label takes its own width rather than loopControlLabelWidth. That 82
    is the leading column that lines the block's labels up down the left edge; a
    second one that wide pushes the box off the end of the row.
    The row is 36 rather than the tallest child's 26. A Flow row already lays its
    children out STYLE.flowPadY down from the top, so a row set to exactly the
    child's height has nowhere to put that padding -- the box overhung the bottom
    edge and the block border closed up right underneath it. 5 + 26 + 5, and
    FlowVAlign CENTER puts them in the middle of the band.
    Co-Authored-By: Claude Opus 5 noreply@anthropic.com

This mod has no additional files