v1.1.0
What's new
Saddlebags
v1.1.0 (2026-09-21)
Full Changelog Previous Releases
- Keep hold of the window when a fight ends mid-drag
A fight ending while the window was being carried used to set it down where it
stood and leave the button held with nothing to do, so the drag had to be started
over. Plain code may move the window again by then, so the ordinary move picks it
up instead: it takes its origin from wherever the cursor is at that instant, so
there is nothing to line up and no jump at the handover, and the release sets the
window down the way it would have anyway.
The two frame levels the window is grabbed at are named rather than written into
the builder, with the rule they carry on them. The background surface sits at the
window's own level and the title strip above everything in it, so anything added
to the window has to end up between the two — a child left at its default level
does, one given a level of its own has to be checked. Neither way of getting it
wrong says anything at the time: above the strip and the title bar stops dragging,
at or below the window's level and the new widget is what stops taking clicks.
followups.md carries the same rule, since that is where it would be looked for.
The window itself no longer registers for drag or carries drag scripts. Both ways
of grabbing it are the secure surfaces, which they have to be for a press to reach
the restricted environment at all, and its own mouse comes off as soon as they
exist — so what was left there could never run again while reading as though it
could, with two drags to race the day someone turned the mouse back on. It keeps
the mouse it is given at creation until those surfaces are built, which is what
stops clicks reaching the world behind it, and it keeps the OnHide that ends a
move Escape cut short. - Name what the notes point at, rather than a line number
followups.md cited twenty file:line pairs and most of them had rotted. BAR_TOGGLE
was cited 850 lines from where it lives, WhenSecureSetupAllowed 400, EDIT_MODE_TINT
20 — and several Skin.lua ones had been wrong before the window's combat drag moved
anything at all. A stale line number doesn't announce itself: it lands on real code
that has nothing to do with the sentence above it, which is worse than no link.
So the references name the function, constant or snippet and link the file. Four
that named nothing at all now do: doneEditingBtn, the Skin.CreateGripButton call in
the bar header, CreateSecureToggleButton for the button parked off-screen, and
Skin.CreateResizeGrip for where the mouse-up rule was first written down. The
convention and the reason for it are at the top of the file.
BAR_RELAYOUT turned out not to exist. The snippet is PAGE_RELAYOUT and has been for
a while; followups.md said the old name in three places and two comments in UI.lua
said it in two more. That is the argument for names over numbers made in one go — a
wrong name can be searched for and comes back empty, a wrong number cannot.
The combat drag's entry also drops three things it still listed as unconfirmed and
aren't: the window's background as a grab surface, whether the ring tracks while the
button is held — it does, which is why the carry reads as slightly rough rather than
as a jump on release — and the ordinary out-of-combat drag on the rewritten move. - Drag the window anywhere during a fight
Moving a protected frame is a protected action, so the window could only be
moved from inside the restricted environment — which has no StartMoving, no
OnUpdate and no reading of the cursor beyond GetMousePosition, which answers
only for a frame the cursor is already over. One snippet can place the window
at "$cursor", and that looked like the whole of what was possible: one press,
one teleport, which nobody wanted.
What was missed is that SecureHandlerEnterLeaveTemplate runs a snippet when the
cursor enters a frame, and that its OnEnter only fires it when the enter came
from the cursor moving — the template's ownif (motion)guard. That is a
callback on cursor movement, the thing the restricted environment was thought
not to have. One enter is still one event, so four frames cover the screen
outside a small square around the pointer; leaving the square fires _onenter on
whichever was crossed into, and that snippet places the window at the cursor and
re-centres all four on it, leaving the pointer back inside the square ready to do
it again. The window lags by CARRY_DEAD_ZONE and catches up in steps of it, and
a placement can happen at most once a frame, which is the ceiling an OnUpdate
has too.
The release was the hard half, and cost three rounds to two rules that are now
written down. A mouse-up reaches the frame that was pressed, and only while
the cursor is still over it — not the frame the cursor happens to be over, which
hears nothing if it wasn't pressed. And RegisterForDrag on that frame eats the
release outright: once the client has started a drag, the button coming up ends
the drag and OnMouseUp never runs, so every drag long enough to cross the drag
threshold lost its release while a short press let go correctly. That was the
whole of the "sometimes" in it.
So the pressed frame has to still be under the cursor at the end of a drag that
may have crossed the screen, and it can be: the window is following the pointer
with the grabbed spot pinned under it. The pick-up grows that surface over the
window and a margin, and raises it above everything inside, until the drop puts
it back. The ring stays out of the way by taking cursor movement and not clicks,
so the press and the release both fall through it.
Two surfaces offer the grab, so a fight doesn't change where the window can be
held. The band of chrome beside the title sits above every child of the window.
The window's background sits at the window's own level, below the bars, the
buttons, the pager, the scroll bar, the X and the resize grip, so each of those
takes its click first and only what falls between them arrives there — which is
exactly what the window itself offered a drag on, so its own mouse comes off and
this stands in for it.
The window no longer moves by frame:StartMoving() at all, in a fight or out of
one. StopMovingOrSizing is protected too, so a move the engine had started and a
pull interrupted couldn't be stopped by anything, and the window trailed the
cursor for the rest of the fight until PLAYER_REGEN_ENABLED let go of it.
AttachWindowMove runs the move off the cursor in an OnUpdate, the way the resize
grip has always run sizing, and asks about combat every sample.
One dead end worth not repeating: decoupling the secure half doesn't work. A
throwaway addon put three insecure holders on screen — one bare, one with a
SecureActionButtonTemplate anchored to it, one with the same button parented —
and in combat the bare one moved while both others were refused, with
ADDON_ACTION_BLOCKED naming the anchored holder's ClearAllPoints and SetPoint.
Protection travels up an anchor exactly as it does a parent chain, so there is no
arrangement of frames that gets the window moved by ordinary code mid-fight.
followups.md carries both mouse rules, the probe's result and what is left to
exercise at the edges of a carry. - Find another copy of a pet without erroring on its species id
C_PetJournal.FindPetIDByName returns the species id first and the pet's GUID
second, and ResolvePetID was taking the first. Any entry whose saved GUID no
longer resolved — the copy released or caged, or simply the journal not
populated yet at login — fell through to that fallback and handed a species
id to GetPetInfoByPetID, which is an error rather than a miss: "Usage:
GetPetInfoByID(petGUID)", thrown from the first Refresh at login.
The same wrong value reached SummonPetByGUID through the macro path, so a
pet that had lost its saved copy wouldn't summon either. Blizzard's own
/summonpet discards the first return the same way. - Scroll the list with the wheel during a fight
The wheel wrap was a dead end for three passes and the reason was one word:
explicitly. SecureHandlerWrapScript accepts any frame, but what runs when
the script fires is SecureHandler_Other_Execute, whose first act is
GetFrameHandle(self, true) — and that answers nil for a frame protected only
implicitly, so the wrapper returns on its first line without a word.
SaddlebagsScrollFrame had protection travelling up from the secure buttons
parented under it, which made select(2, IsProtected()) false and the wrap
dead on arrival. Creating it with SecureFrameTemplate is the whole fix.
Two things kept that hidden. SecureHandlerWrapScript checks the header is
explicitly protected and errors if it isn't, and never checks the frame
being wrapped, so the call was accepted loudly and failed silently for ever
after. And an OnClick wrap on a protected button did fire, and an OnHide
wrap did too, which made it look like OnMouseWheel was the special case when
it was the frames that differed.
The snippet runs the same _scrollstep the arrows' covers run and returns
false, which is what stops Blizzard's own handler — the one ending in the
blocked SetVerticalScroll — running after it. It returns false in combat and
out of it, so the wheel has one behaviour rather than two that have to agree
about which is driving. A wrap snippet's arguments are named by the
wrapper's signature, "self,offset" for a wheel, so ... is empty and a
snippet reading it would scroll by nil; owner is the header, which is how
the wheel reaches the stepper without a frame ref of its own.
A list that changed shape mid-fight was leaving the slide measured against a
height that had gone: collapse a bar while scrolled down and the list stayed
scrolled past its own end for the rest of the fight, and the fold at the end
of it wrote that out-of-range offset into the scroll frame. PAGE_RELAYOUT
re-clamps the slide with the same arithmetic SCROLL_STEP uses, and the fold
clamps against the frame's real scroll range.
The frozen scroll offset moves onto the scroll frame it describes, so
SCROLL_STEP, PAGE_STEP and PAGE_RELAYOUT read one copy rather than each
holding its own.
The bar stays greyed and still while the wheel moves the list, and that is
as good as it gets: ScrollBarMixin:SetScrollPercentage ends in
TriggerEvent(OnScroll), and the callback InitScrollFrameWithScrollBar
registers against that event is SetVerticalScroll, so driving the thumb in a
fight walks straight back into the call it was greyed to avoid. - Grey what a fight won't allow, and summon pets from a macro
Mounts are the one kind the game refuses outright in combat, whoever asks,
so their icons desaturate for the length of one and the macro behind them
starts with /stopmacro [combat] — without it the click still ran the
SummonByID and earned an error for it. Desaturating rather than fading keeps
fading meaning what it already means here, which is that the collection no
longer has the thing. Toys are left alone, because which ones work in a
fight varies per toy and the client offers no flag to read, and pets are
left alone because a pet summons in combat quite happily.
Collections.UsableInCombat is the per-kind answer, and one watcher repaints
the icons rather than each button listening for itself: a button built while
a fight is already on never hears the event. The state comes from the event
rather than from InCombatLockdown(), which answers false at
PLAYER_REGEN_DISABLED — the fight has started but the lockdown hasn't.
Pets now go through a macro too. SummonPetByGUID is refused from a plain
click in combat, so the button is secure like the others and runs
/summonpet, which takes the pet's custom name or its species name. Its
clicks are registered on both edges: a secure button set to AnyUp alone
didn't fire at all here, in or out of a fight.
A mount's cooldown can't be drawn mid-fight at all — the client answers with
secret values, which addon code may not compare or hand back to SetCooldown
— so the call is attempted and the swirl is caught up when the fight ends.
The README and followups.md describe the whole of it, including the dead
ends: wrapping the scroll frame's wheel script, which never fired, and
protecting Blizzard's own scroll steppers, which blocked their code in our
name. - Keep editing out of fights, and end edit mode on a pull
Every edit ends in a Refresh, and a Refresh rebuilds the secure buttons toys
and mounts are used from, so it can't run in combat. An edit made in one
therefore changed the saved layout and nothing visible, and then landed all
at once when the fight ended, which is a confusing way to lose track of what
you just did.
Saddlebags:EditingBlocked declines and says why, and every mutator calls it:
adding, removing and renaming pages and bars, moving bars between pages,
adding, removing and moving items, page icons, and entering or leaving edit
mode. That's the backstop. In front of it the pager's + and each bar's +
switch off and grey for the duration, with a red line in the tooltip, so the
guard should be something nobody meets.
Edit mode already on when the pull happens is the remaining case: the
lockout stops it being left, which would have frozen the Rename, × and Page
links on screen, live to the eye and refusing underneath.
LeaveEditModeForCombat drops the flag at PLAYER_REGEN_DISABLED and hides
them, which is allowed because that event lands a beat ahead of the lockdown
itself; the rebuild behind it runs there too, or waits for the end of the
fight if it can't. The affordances are collected as they're built so there's
a list to hide. - Scroll the list in combat from the scroll bar's arrows
A list you can't scroll is a list you can't reach past the top of, so the
window being open in a fight isn't worth much without this. Scrolling a
protected frame isn't allowed, though: every route ends in
SetVerticalScroll, which is refused, and nothing in the restricted
environment a snippet runs in can scroll a frame either.
So the frame doesn't move — the page container inside it does. SCROLL_STEP
slides it by a step and clamps against the page's own height, which the
container carries, and the offset lives in an attribute because the
restricted environment can't read a point back off a frame. When the fight
ends the slide is folded into the scroll frame's real offset, so the frame
owns the position again and the bar, the wheel and the thumb pick up from
where the list was left rather than jumping.
The clicks come from the bar's own Back and Forward, each under a
transparent secure button sized and placed from the real one. The arrows are
Blizzard's, so nothing new appears beside them, and covering them beats
protecting them: protecting Blizzard's buttons made Blizzard's own code the
thing being blocked. The rest of the bar — wheel and thumb — is switched off
and dimmed for the duration rather than left moving over a list that can't
follow it. - Build every page, so one can be turned mid-fight
Turning a page means showing one set of bars and hiding another, and both
are protected. A snippet can do it, but only to frames that already exist,
so Refresh now builds every page rather than only the one on screen: each
gets a protected container of its own, holding that page's bars, and all but
the current one is hidden. Collapsing a bar is the same problem one size
down — the bars below have to move up and the list has to be resized — so it
goes the same way, through a snippet on the bar's own header.
Three snippets do the work. BAR_TOGGLE collapses and expands, PAGE_RELAYOUT
re-stacks a page's bars to the same rhythm Refresh uses (which is why the
list's pads and gaps are named constants now, handed over as attributes
rather than written out twice), and PAGE_STEP shows the next container and
hides the current one. The plain-code half — the saved page, the drag
geometry, the pager's label — follows through CallMethod, and asks for the
Refresh that waits for the end of the fight.
The pager grows a pair of arrows to drive it, native UIPanelSquareButton so
they read as siblings of the dropdown beside them, and they stop at the ends
rather than wrapping: flying past the first or last page while trying to
reach it would be worse than stopping. Next page and Previous page also go
on the toggle button's side buttons, for anyone who'd rather bind them.
The dropdown itself can't come along. A Blizzard menu entry runs as plain
code whatever the pages are built like, so it greys out for the duration and
says why, rather than looking usable and doing nothing. - Let the window open and close during a fight
The window holds the secure buttons toys and mounts are used from, which
makes it a protected frame: plain code may not show or hide it in combat,
which is exactly when reaching a toy matters. Every route in and out now
ends in a click on a protected button we own, which is the one way into a
secure snippet that has proved reliable here.
SaddlebagsToggleButton is that button — one pixel, parked off screen, and
pressed by the binding declared in Bindings.xml. The chrome's X gets a
transparent secure cover that hands hover and press back to the real one
underneath. Escape is bound to the same button's right press for the length
of a fight, through a state driver, and released afterwards: the window
can't simply sit in UISpecialFrames, because closing from there is plain
code. Bound this way it closes the window first and leaves the rest of
Escape's job for the next press, which is how Blizzard's own windows behave.
Dragging and resizing stay out of combat. Both are protected on this frame,
so they're declined when they start rather than blocked halfway through, and
a drag a pull interrupts is let go of when the fight ends.
Wiring a secure frame is itself forbidden mid-fight, so anything that has to
be built during one is queued by WhenSecureSetupAllowed and flushed at
PLAYER_REGEN_ENABLED. The window is built at login now rather than on first
open, so opening it mid-fight has something to open. - Stop tracking Claude Code's per-machine permissions
settings.local.json is the personal half of the pair — settings.json is
the one meant to be shared — so it was in every clone by accident rather
than by intent. The single rule it carried allowed reads under a
c--Dev-toy-chest directory that no longer exists, from before this project
was renamed, so nothing was relying on it either.
It was already kept out of the built zip by the .claude entry in
.pkgmeta; this only takes it out of the repository.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com - Move the release workflow off Node 20
Both releases so far ended with a warning: actions/checkout@v4 targets
Node 20, which the runners now force onto Node 24 and will stop
supporting outright.
v5 through v7 all declare node24, so any of them clears it; v7 is current.
Nothing in the two majors since v4 touches this workflow. v6 changed where
checkout persists credentials, and the packager authenticates with
GITHUB_OAUTH rather than the checked-out credentials. v7 blocks checking
out fork pull requests for pull_request_target and workflow_run, and this
only ever runs on a tag push. The single input in use, fetch-depth: 0, is
unchanged across all of them, and the packager needs it to find the
previous tag when it builds the changelog.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com - Stop documenting a -nolib build that was never switched on
The README and the workflow header have both described two zips since the
day the workflow was added, and the first two real runs produced one.
Nothing is misconfigured: nolib packages are opt-in, via
enable-nolib-creation in .pkgmeta, and that was never set.
Leaving it that way is the right call rather than an oversight to fix.
Minimap.lua takes LibDataBroker-1.1 and LibDBIcon-1.0 straight out of
LibStub with no guard, and a nolib build strips LibStub itself, so the
zip would error at load for anyone who does not already run another addon
that supplies them — which is most of the people who would download it.
The usual argument for offering one does not apply either: .pkgmeta
checks every library out fresh from upstream at build time, so a release
cannot ship stale copies in the first place.
Offering one later means both enable-nolib-creation and a guard on those
two calls, so the README says that rather than leaving the next reader to
work out why the option is off.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
This mod has no additional files

