promotional bannermobile promotional banner

Saddlebags

Stop digging through the Collections journal: put your go-to toys, pets and mounts on a page you build yourself
Back to Files

v1.1.0

File nameSaddlebags-v1.1.0.zip
Uploader
CynicaleCynicale
Uploaded
Sep 21, 2026
Downloads
8
Size
409.9 KB
Flavors
Retail
File ID
8939753
Type
R
Release
Supported game versions
  • 12.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 own if (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