Skip to content

Repo reorg + load-on-demand settings companion (12.1 lane) - #222

Merged
DanderBot merged 34 commits into
DanderBot:feature/aura-factory-12.1from
Krathe82:krathe/repo-reorg-lod
Aug 1, 2026
Merged

Repo reorg + load-on-demand settings companion (12.1 lane)#222
DanderBot merged 34 commits into
DanderBot:feature/aura-factory-12.1from
Krathe82:krathe/repo-reorg-lod

Conversation

@Krathe82

@Krathe82 Krathe82 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Repo reorg + load-on-demand settings companion

Base: feature/aura-factory-12.1 · Head: Krathe82:krathe/repo-reorg-lod · 34 commits
PTR / 12.1 lane only. Retail is untouched and moves later.


What this does

The repo root is no longer the addon folder. It is a container holding
DandersFrames/ and a new DandersFrames_Options/ — the standard layout for
projects that ship a load-on-demand options module. WoW resolves
LoadAddOn("Name") to AddOns/Name/Name.toc, so a companion has to be a
sibling top-level folder, which is only possible if the root is a container.

The settings panel, both designers, the click-casting UI, test mode, the debug
tools, settings search and the changelog text now live in that companion and
are not parsed or held in memory until someone opens /df.

Measured, fresh login, panel never opened: 9,192 KB against a 12,348 KB
baseline — about 25% less — with the companion reading 0.
Roughly 59,000
lines (37.5% of the code) are never parsed at login. Four further blocks moved
after that reading was taken, worth an estimated ~290 KB more; not re-measured.

The 27.5% figure from an early throwaway experiment is optimistic — it
stripped things this build deliberately keeps resident, chiefly the
auto-profile engine. Quote 25%.

The rule this is really about

Nothing may stop working because the settings panel was never opened.

That sounds obvious and it is where every bug in this branch came from. Six
features were already broken this way, four of them before the reorg started:

  • Click-casting never initialised at all — its bootstrap sat behind a
    PLAYER_ENTERING_WORLD gated on isInitialLogin, flags only true on the
    firing a load-on-demand addon has already missed.
  • Live tooltips erroredGUI:ShowTooltip was in the companion while
    HideTooltip stayed resident. Pinned frames, the mover panel's Reset
    Position
    button and the icon library all call it unguarded.
  • The colour-picker global override reverted every session.
  • The colour picker lost its saved swatches, then overwrote them with
    nothing.
  • Auto-profile switching was inert until the panel was opened once.
  • /df test was a silent no-op; /df raidunlock crashed halfway.

All fixed. The pattern that works: deliberate user actions (/df test,
unlock, the profiler window, selective import/export) load the companion;
background events (talent-change UI refreshes) guard and no-op.

Structure

Split Resident Companion
GUI toolkit (13,343 lines) GUI.lua, Widgets.lua — movers, search box, tooltips, styling primitives SettingsWidgets, Sections, Controls, Panel
Aura Designer editor (9,093) Migrations.lua (rewrites saved data) UI/Options + 4 parts
Settings pages (9,681) Pages/Options + 4 parts
Auto Profiles (4,160) Core/AutoProfiles.lua — engine, 2,239 lines Pages/AutoProfiles.lua — page
Profiler (2,173) hooks, recording, chat reports Debug/ProfilerUI.lua — window
Changelog CI-stamped header only the ~180 KB text

Test mode keeps a resident Shim.lua (live rendering calls into it) and the
Aura Designer keeps its migrations resident (they rewrite saved data; a guarded
call would silently skip and leave a player's data unmigrated).

Reviewing this

The diff is large but mostly mechanical. What is worth actual attention:

  1. Core/AutoProfiles.lua — the engine/page split. Two things could not
    just move: CONTENT_TYPES was reassigned on locale refresh (a mutable local
    cannot span an addon boundary — it now rebuilds in place), and the engine
    calls four UI methods across 11 unguarded sites, so it defines them as
    no-ops that the page overwrites.
  2. Debug/Profiler.lua — same shape. profilerFrame and UpdateUI were
    shared mutable locals; the window now publishes them as Profiler.frame and
    Profiler.UpdateUI, and the six resident read sites are nil-guarded.
  3. The EnsureOptionsLoaded call sites — each is scoped so the cheap case
    stays cheap. ExportProfile only loads for selective exports, so the
    external DandersFrames_Export API (full exports) never pays.
  4. generate_changelog.sh — the only change that lands on the maintainer's
    side rather than mine, so it is worth reading properly. It now writes two
    files: the CI stamps stay resident (Core.lua:114 reads RELEASE_CHANNEL
    at login), the changelog string rides the companion (three consumers, all
    in the panel). There is still one changelog — one file split, not
    duplicated, and the in-game changelog tab is unchanged. release.yml's
    build-skip filter lists both generated paths; its RELEASE_CHANNEL
    grep still reads the resident one. It also now syncs ## Version: in
    both TOCs — see "A bug this found" below.

Everything else is byte-identical moves — verified as such, see below.

Verification

Every split was proven byte-identical by reconstructing the original from the
parts and comparing to the pre-split commit. The GUI-widget extraction was
non-contiguous (33 function blocks), so the check re-inserts each block at its
recorded position; the Auto Profiles halves interleave, so that one
re-interleaves at four cut points. All 4,160 / 2,557 moved lines unchanged.

Six local checkers under docs/reorg-tools/ (gitignored) run clean:

script catches
toc_check file on disk not in TOC, or vice versa
loadorder_check file-scope DF.X read before its defining file loads
alias_check the split alias/publish plumbing — caused two in-game crashes
lod_gate_check resident code calling the companion unguarded — DF, GUI, CC, plus a self: arm
lod_login_check login-time work stranded in the companion
split_verify historical record of three byte-identity proofs

lod_gate_check grew the GUI, CC and self: arms during this work, and
every one of them immediately found real bugs — which is the argument for
each: a checker watching one namespace is blind to the others, and one watching
namespaces at all is blind to self:X() inside a method of a shared table. The
self: arm is explicitly heuristic (a resident self:X( where X is
payload-owned is attributed to that namespace) and reports separately so it is
never mistaken for the exact scan. All three were negative-tested by reverting a
real fix and confirming the tool reports it.

Also: 116 files parse, both TOCs complete, all load-bearing orderings intact.

The package build

.pkgmeta has now been built and verified. Not via a pushed tag — the
Package and Release step passes CF_API_KEY, so a tag would upload to
CurseForge, cut a GitHub release and notify Discord, and workflow_dispatch
does not help because the job requires a main or tag ref. The packager was
run locally with uploads disabled instead, which exercises the same
move-folders implementation:

Moving DandersFrames/DandersFrames to DandersFrames
Moving DandersFrames/DandersFrames_Options to DandersFrames_Options

Two sibling folders at the zip root, each with its own .toc; all 90 + 36 TOC
entries present; release.yml's locale glob resolving to 11 files; no
repo-root files leaking in.

Review round

Danders' review of this PR found two runtime defects, both real and both fixed
in 1c8f3ebb:

  • ClickCasting/Profiles.lua:545/580 — unguarded CC:RefreshClickCastingUI
    inside CheckLoadoutProfileSwitch, which fires from TRAIT_CONFIG_UPDATED
    and PLAYER_ENTERING_WORLD. A nil-call on login and every talent swap for
    anyone with a click-cast profile assigned to a loadout — and it aborted before
    ApplyBindings, the reapply that handler exists for.
  • CURSOR_LIFT_X/Y unbound in the companion — the declaration went resident
    with ShowTooltip while ShowGameTooltip stayed behind. Silent, because they
    are optional SetOwner args, so spell tooltips on settings rows lost their
    lift while every other tooltip kept it.

Both are self: call shapes one frame deeper than the namespace scan reaches,
which is what prompted the self: arm above.

Also from that round: /df attached now loads the companion; the
GetImportInfo guard moved inside the isFullExport branch so the full-profile
export path stops paying for tables it never reads; ignoreConflictWarning is
read before loading the companion for the conflict popup; and a comment
repeated in 30 companion files was corrected — the DandersFrames global comes
from _G[addonName] = DF at Core.lua:9, not from
## AllowAddOnTableAccess, which governs private-table access and is unrelated.

GUI:CloseAllMenus also moved back resident (e0586adf) to sit with the
registry it iterates. It had been left in the companion, so resident code could
register a menu but not close one — four lines were not worth an addon
boundary, and a nil-guard there would have failed silently by leaving a menu
floating.

A bug this found

The TOC version sync in generate_changelog.sh only ever touched the main TOC.
The packager does not cover for that — its toc_version handling is the
Interface (game) version, not ## Version: — so whatever is committed is what
WoW displays, in the zip and locally through a dev junction.
DandersFrames_Options would have sat at whatever it was hand-written as and
drifted a version further behind on every release. It only looked correct
because both were hand-set to the same value; the drift would have surfaced at
the next bump.

Both TOCs now sync in a loop, and a missing TOC is a hard error rather than a
silent skip. Verified by setting the companion to v0.0.0-STALE and watching
the generator correct it.

⚠ Worth noting the checkers would not have caught this: toc_check.py verifies
files are listed, not that TOC metadata is correct.

Not done

  • Structural performance work is the main outstanding item — see below.
  • Structural performance work (Text Designer render path, routing UNIT_* through
    the roster dispatcher, a pinned-frame unit map) is scoped in
    docs/perfy-plan.md and waiting on a Perfy profiling session. The safe half
    is in this branch.
  • GUI:ApplyPixelBorder / HidePixelBorder (57 lines) are referenced nowhere.
    Left alone rather than deleted.

Reviewer setup

Your clone can no longer live inside Interface/AddOns. With WoW closed: move
the checkout somewhere neutral, delete the leftover AddOns/DandersFrames, and
create two junctions:

mklink /J "DandersFrames"         "C:\path\to\repo\DandersFrames"
mklink /J "DandersFrames_Options" "C:\path\to\repo\DandersFrames_Options"

Junctions (/J), not symlinks — no admin rights needed. After that the
workflow is unchanged: one repo, one branch, one PR, one commit can span both
folders. Users do none of this; the packager ships both folders in the zip.

Krathe82 added 30 commits July 31, 2026 16:59
Pure move. Every .lua relocation is a rename with zero content change
(git diff -M: 16 files changed, 0 insertions, 0 deletions), and the TOC
load ORDER is unchanged -- only the paths on each line differ.

Root now holds only Core.lua, DandersFrames.toc and DandersFrames.xml.

  Config/Changelog/Profile/ExportCategories/RosterEvents/API -> Core/
  DesignerPresets.lua                                        -> Designer/Presets.lua
  Popup.lua                                                  -> GUI/Popup.lua
  Options/*                                                  -> GUI/Pages/
  AuraDesigner|TextDesigner|FilterRegistry settings files    -> */UI/

The */UI/ split separates each module's settings UI from its runtime
files, so a later TOC change can lift the whole settings layer out
without touching the runtime half.

Changelog.lua is CI-generated, so its move needs three references kept in
step or releases silently ship a stale changelog:
  generate_changelog.sh   OUTPUT_FILE
  release.yml             build-skip path filter
  release.yml             RELEASE_CHANNEL grep
Behaviour-preserving prep, no functional change. Four file-scope locals
become fields on a private GUI._state table:

  currentOpenDropdown   read/written 9,000 lines apart
  overrideDebugMode     read/written 6,800 lines apart
  navTrace
  _flowProbe

A file-scope `local` re-declared on the far side of a file split becomes a
second, independent variable. Nothing errors -- the two halves just stop
sharing state, and the symptom surfaces later as a dropdown that will not
close or a debug toggle that does nothing. Holding these on one table means
any future split reads the same state via `local S = GUI._state`.

Read-only aliases are deliberately left alone. The C_* colours, L and GUI
itself are safe to re-declare because doing so yields the same table.

Measured effect on splittability (docs/reorg-tools/cutpoints.py), treating
the colour block as re-declarable preamble:

  before   0 usable cut points
  after   76 usable cut runs (1,176 legal lines)

The split itself is not done here -- this only removes the thing that made
it unsafe.
Behaviour-preserving prep, pure addition (16 lines, 0 deletions).

A `local function` is invisible to any other file, so a helper called
thousands of lines from its declaration pins this file together: it cannot
be split while a later section still calls it. Five helpers span >1000
lines and were private:

  CloseOpenDropdown               412 -> 9493
  PixelsPerUnit                   438 -> 1467
  INFO_BANNER_TONES              2475 -> 5635
  AddOverrideIndicators          5016 -> 9016
  AddOrderListOverrideIndicators 5205 -> 10472

Publishing them on a private GUI._priv table lets a sibling file
re-declare `local X = GUI._priv.X` -- the same object, so no call site
changes and behaviour is identical. Narrower helpers stay private.

Read-only aliases are safe to re-declare this way; a MUTABLE local never
is, which is what the earlier GUI._state commit dealt with.
Pure move, proven not asserted. docs/reorg-tools/split_verify.py concatenates
the five parts and compares byte-for-byte against GUI/GUI.lua at the parent
commit: identical apart from 66 re-declaration lines, each one listed in
docs/reorg-tools/splits.manifest and printed by the tool.

  GUI/GUI.lua       1,963  theme, palette, pixel grid, snapping, backdrops,
                           scrollbar -- plus GUI._state / GUI._priv
  GUI/Sections.lua  1,596  headers, collapsible sections, settings groups
  GUI/Widgets.lua   4,517  tooltips, buttons, override markers, checkboxes,
                           edit boxes, sliders, colour picker, dropdowns
  GUI/Controls.lua  3,363  compound controls: expiration, growth, texture /
                           font / sound dropdowns, order lists, gradient bar
  GUI/Panel.lua     1,965  DF:CreateGUI -- the settings panel shell

Cut points were chosen by docs/reorg-tools/cutpoints.py, which requires two
things of every boundary: no file-scope local declared before it is still
referenced after it, and the cut falls between top-level statements rather
than inside a function body. Checking only the first would allow a cut inside
a function, producing two files that do not parse.

Parts 2-5 open by re-declaring what they use as aliases -- `local GUI = DF.GUI`,
`local S = GUI._state`, `local CreateElementBackdrop = GUI.CreateElementBackdrop`
and so on. These are the same objects the first part created, so no call site
changed anywhere in the addon.

The two earlier commits are what made this legal: GUI._state removed the
mutable file-scope locals (re-declaring one of those yields a SECOND variable
and the halves silently stop sharing state), and GUI._priv published the
private helpers reaching across thousands of lines.

TOC order is load-bearing and is commented as such: GUI.lua must load first
because it creates the table and the two shared sub-tables.
Behaviour-preserving prep for splitting this 9,000-line file. 38 mutable
file-scope locals become fields on DF.AuraDesigner._uiState -- 471 reference
sites rewritten, all mechanical.

A file-scope `local` re-declared on the far side of a split becomes a
SECOND variable, so the two halves silently stop sharing state: no error,
just a tab that will not switch or a preview that never refreshes. Nearly
every widget handle here was in that class -- mainFrame, tabBar, activeTab,
framePreview, the four Build*Tab forward declarations.

The rename was verified safe first: no inner scope anywhere in the file
declares any of these names as a parameter or local, so no reference could
be captured by mistake. Short names like `db` and `page` made that check
worth doing rather than assuming.

GUI and Adapter are deliberately NOT on the table. Both were assigned in
BuildAuraDesignerPage, which made them mutable, but neither ever varies:
every caller arrives as DF.GUI -> SetupGUIPages -> here, and Adapter was
only ever assigned DF.AuraDesigner.Adapter. Resolving both at load instead
leaves 412 further reference sites untouched.

Side effect worth having: file-scope locals drop 196 -> 159. This file had
already hit Lua's 200-per-chunk ceiling once -- there is a comment recording
a constant deleted purely to reclaim a slot.
Behaviour-preserving prep, pure addition (85 lines, 0 deletions).

A `local function` is invisible to any other file, so a helper called far
from its declaration pins this file together. This editor is densely
coupled: 75 read-only helpers reach across the four chosen split
boundaries -- GetAuraDesignerDB, CreateCardShell, ResolveSpec,
CurrentAuraPool, TYPE_DEFAULTS, RefreshPlacedIndicators and the rest.

Each is published immediately after its own definition, so the publish
always lands in the same part as the thing it publishes. Sibling parts
re-declare `local X = P.X` -- the same object, so no call site changes.

Cheap boundaries only exist below line ~750 (2 crossers); by line 3733 it
is 59. Splitting the bulk was never going to be free, and this is the cost.
Pure move, proven not asserted. split_verify.py concatenates the five parts
and compares byte-for-byte against the parent commit: identical apart from
159 alias lines, each listed in docs/reorg-tools/splits.manifest.

  Options.lua      1,738  config resolvers, spec/pool helpers, group CRUD,
                          state and _priv tables
  Groups.lua       2,099  layout + debuff group management, indicator
                          instances, proxies, warning badges
  Indicators.lua   1,041  anchors, drag state, placed-indicator handling
  Cards.lua        2,463  effect cards and the per-type content builders
  Editor.lua       1,916  tab bar, panels, preview, BuildAuraDesignerPage

Parts 2-5 open with alias headers (19-58 lines) re-declaring what they use:
GUI, Adapter, the colour constants, the state table and the shared helpers.
Same objects, so no call site changed.

Every part is well under Lua's 200-locals-per-chunk ceiling -- the largest
is 86, against 196 before this work started. That ceiling was not
hypothetical here: the file carried a comment recording a constant deleted
purely to reclaim a slot.

split_verify now takes a per-entry `base:` in the manifest. A single global
base cannot work once a split is committed, because from then on HEAD's
original file IS part 1 and the check compares it against itself.
Fixes: GUI/Widgets.lua:773 attempt to call a nil value, via
PinnedFrames.lua CreateSetFrames -> Initialize at login.

My alias rule was wrong. It treated `local X` and `GUI.X` as the same
object whenever GUI.X appeared anywhere, so parts 2-5 got
`local CreateElementBackdrop = GUI.CreateElementBackdrop`.

But those two public names are METHOD WRAPPERS declared in Widgets.lua:

    function GUI:CreateElementBackdrop(frame, opts)
        return CreateElementBackdrop(frame, opts)   -- the file-scope local
    end

They are a different object that happens to share the name, and they are
declared AFTER the preamble runs -- so the alias was nil at load. Had it
resolved, the wrapper would have called itself.

Both real locals are now published on GUI._priv beside the others, and all
four aliases point there. The public wrappers are unchanged and still work
for outside callers like PinnedFrames.

An audit of all 10 split files found exactly these two; the other 8 aliases
of the form `local X = GUI.X` are backed by a real `GUI.X = X` identity
assignment and are correct.

The GUI split's byte-identity proof held for the commit that made it; this
fix intentionally adds 8 lines to part 1, so that manifest entry is now
marked historical rather than left as a failing check.
Fixes: Groups.lua:259 attempt to index a nil value, via CreateEffectCard ->
BuildEffectsTab -> SwitchTab when opening the Effects tab.

The publish pass inserted each `P.X = X` immediately after its definition's
last line. For three symbols that last line sat right against a split
boundary, so the publish landed in the NEXT part -- which then did:

    local TYPE_DEFAULTS = P.TYPE_DEFAULTS   -- nil, nothing published yet
    P.TYPE_DEFAULTS = TYPE_DEFAULTS         -- publishes nil

Affected: TYPE_DEFAULTS (Groups -> Options), BuildTypeContent (Cards ->
Indicators), AddGroupAppearanceSection (Editor -> Cards). Each publish now
sits at the end of the part that defines the symbol.

A publish must never be separated from its definition by a cut. That is now
enforced by docs/reorg-tools/alias_check.py, which also covers the wrapper
alias that caused the previous crash. Both faults are invisible to parsing,
the TOC check, load-order and the concat proof -- they only surface at
runtime as a nil value -- so the checker was verified by running it against
the two pre-fix commits and confirming it reports all six.
DF:SetupGUIPages was a single 9,673-line function -- the largest remaining
monolith and the last of the three Krathe asked to split.

  Options.lua     2,010  general, profiles, the shared page helpers
  Frames.lua      1,896
  Auras.lua       1,754
  Indicators.lua  1,918
  Modules.lua     2,131

Not a pure move: each part gains a function header, a hand-off call and a
closing `end`. Everything between them is verbatim, and all 28 added lines
are declared in docs/reorg-tools/splits.manifest, so split_verify still
proves the body byte-for-byte against the parent commit.

The parts run as a CHAIN -- part 1 calls part 2 at its end, part 2 calls
part 3, and so on. That keeps the pages building in their original order and
keeps every added line at a part's start or end, which is the only place a
strip can reach. Six locals crossing a boundary are threaded as parameters:
L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs,
pageIcons.

The two crashes from the earlier splits were both a part unable to SEE a
name it used, and neither was visible to any static check. So the splitter
now refuses to write anything unless every name a part references is either
threaded as a parameter or re-declared in its preamble. That assertion
immediately caught one: `format` is a file-scope local used in three of the
five parts, and would have been nil in all three.

split_verify's `tail:` directive, needed for the trailing hand-off, is
covered by docs/reorg-tools/test_tail_strip.py -- six cases including
interior-`end` survival and a round-trip concat. Three earlier attempts at
it each produced a different wrong answer against the real file, so this
time it was tested against synthetic cases before being trusted.
Phase 4 stage 2. Inert right now: TestMode.lua still loads and overwrites
every stub, so behaviour is unchanged. It exists so the folder can move to
the companion addon without live rendering calling into nothing.

Test mode is a settings-panel feature, but live code calls into it:
docs/reorg-tools/lod_gate_check.py found 31 resident call sites across
Frames/Bars, Create, Init, StatusIcons and Core -- 16 of them
DF:GetTestUnitData, sitting inline in the live render path.

Nearly all are unreachable while test mode is off, so in practice they would
never fire. That is not good enough for a nil call: both crashes after the
file splits were a call into something not loaded, and both were invisible
to every static check. The shim removes the class of failure instead of
relying on control flow to avoid it.

Stubs are the honest inactive answer. GetTestUnitData returns nil -- the
same answer the real function gives for an index with no test unit, and
every caller already handles it. The other nine are void; no-op is exact.

Written out one per line rather than looped over a name list: a loop hides
the stubbed surface from grep and from tooling. lod_gate_check could not see
the table-indexed assignment and kept reporting nine already-stubbed
symbols as unguarded.

lod_gate_check now also recognises the "if not DF.X then DF.X = noop end"
form as a definition -- it previously only matched "function DF:X" and a
line-leading "DF.X = function".

Unguarded resident calls into the payload: 36 -> 7. The remaining seven are
five DF:ToggleGUI (the load trigger, which stage 3 builds) and two that are
guarded by an if-block wider than the checker's context window.
Phase 4 stage 2b, and a correctness fix as much as a reorg step.

Nine published migrations plus their private helpers -- 595 lines, moved
byte-identically -- lived in AuraDesigner/UI/Options.lua, which is bound for
the load-on-demand payload. They rewrite SAVED DATA, so they are data layer,
not UI.

Leaving them there would not have crashed. It would have been worse:

  Core.lua:4201 runs them on ADDON_LOADED over every saved profile, and its
  own comment says "load order guarantees that file has registered
  DF.MigrateAuraDesignerIconBorderKeys by here".
  AuraDesigner/Factory.lua:3033-3041 runs five of them on the render path.

Both call sites are nil-guarded, so with the editor unloaded they would have
silently SKIPPED. A player who never opens the settings panel would keep
unmigrated saved data while live frames rendered from it -- and it would
have migrated later, on first panel open, masking the cause.

Found via docs/reorg-tools/lod_gate_check.py, though not the way intended:
it flagged the two Core.lua call sites as unguarded, which was a false
positive (the guard is an if-block wider than its context window). Reading
the surrounding code to dismiss that is what surfaced the real problem.

GetAuraDesignerDB stays with the editor and now calls the seven it needs by
their published DF names rather than as file locals -- same functions.

Unguarded resident calls into the payload: 7 -> 5, all now DF:ToggleGUI,
which is the load trigger stage 3 builds.

The block is proven byte-identical against the parent commit: 595 lines
moved, 0 changed.
Prep for the load-on-demand companion. WoW resolves LoadAddOn("Name") to
AddOns/Name/Name.toc, so a companion has to be a top-level addon folder --
a sibling of DandersFrames, not a subfolder of it. That is the standard
layout for a project shipping a load-on-demand options module, and it means
the repo root can no longer BE the addon.

  <repo>/
    DandersFrames/            the addon (212 files moved, byte-identical)
    CHANGELOG.md  README.md  TRANSLATING.md
    .pkgmeta  .github/  generate_changelog.sh  Tools/  docs/

215 files changed, 29 insertions, 6 deletions -- every .lua and .tga is a
zero-change rename. The three real edits are path references that were
relative to the repo root and are now one level out from the addon:

  generate_changelog.sh   OUTPUT_FILE, TOC_FILE
  release.yml             build-skip filter, version grep, channel grep

That is the third time this pattern has appeared (Changelog.lua's move, the
Core/ move, now this). Anything outside the addon folder that names a path
inside it has to move with it.

The verification tooling under docs/reorg-tools/ now defaults its root to
<repo>/DandersFrames, so every checker still runs with no arguments.

☠ .pkgmeta is NOT fixed and is marked as such in the file. It still says
package-as: DandersFrames, which would nest the addon one level too deep in
the release zip. move-folders is the mechanism but I could not confirm its
exact path semantics offline, and a wrong guess ships a zip that installs to
the wrong place -- silently. The file carries the test procedure: push a
throwaway tag, inspect the zip's top level, delete the tag.

DO NOT CUT A RELEASE UNTIL THAT IS SETTLED.

The addon does not load from this commit alone: AddOns/DandersFrames now
needs to be a junction to <repo>/DandersFrames. See the follow-up commit for
the dev setup.
The repo root is now a container holding the addon folders side by side, so
a clone can no longer live inside Interface/AddOns. Records the one-time
junction setup both developers need, and why junctions (/J) rather than
symlinks (/D) -- no admin rights or Developer Mode required.

README.md is in .pkgmeta's ignore list, so this is repo-facing only and
never reaches the packaged addon.
The setup section documented DandersFrames_Options as if it were present and
told the reader to junction it. It does not exist yet -- the second mklink
would have failed on a missing target, which is a poor first experience for
anyone following the doc.

Now describes one addon folder and one junction, with a short section on why
the container layout exists at all (the companion needs a sibling top-level
folder, which is only possible if the repo root stops being the addon) and
the measured saving that justifies it. States plainly that the companion is
not built yet.
30 files, 55,096 lines -- the settings panel, both designers, the
click-casting UI, test mode and the debug tools -- move out of the main addon
into a LoadOnDemand companion. All 30 are zero-change renames.

Measured on the PTR build with this payload not loaded: 8,957 KB against a
12,348 KB baseline, so 3,391 KB less, 27.5% of the resident footprint. About
55,000 lines are also never parsed at any login. The memory saving lasts
until the panel is opened; the parse saving happens every login.

Main addon TOC: 121 entries -> 91.

.pkgmeta is resolved. move-folders keys are relative to the working directory
and include the package name as the root, values are relative to the package
root, so:

    move-folders:
      DandersFrames/DandersFrames: DandersFrames
      DandersFrames/DandersFrames_Options: DandersFrames_Options

The first line looks like it collides with its own parent and does not -- this
is the documented shape for a container repo that ships more than one folder.
I had talked myself out of it on the assumption it would collide; reading a
working example settled it.

GUI/LoadOptions.lua is the only thing that loads the companion. DF:ToggleGUI
becomes a stub there that loads it and re-dispatches to the real function,
which the companion installs over the top.

Three ways it can fail and all three report rather than doing nothing: the
companion missing, disabled in the AddOns list, or loading without providing
a panel. The last is caught by comparing DF.ToggleGUI against the stub itself
-- without that check a re-dispatch would recurse until the stack blew. A
settings panel that silently refuses to open is the failure mode this whole
branch has been trying to design out.

Local dev needs a second junction alongside the first:
    mklink /J "DandersFrames_Options" "<repo>\DandersFrames_Options"
Users need nothing: the packager ships both as sibling folders.

Verified: every entry in both TOCs exists on disk, resident load order has 0
violations and 0 unresolved reads with the payload gone, and the loader plus
sampled companion files parse.
The loader was written and committed but never listed in the .toc -- a Python
replace hit an escape-sequence warning and silently did not match, so the
edit was a no-op while everything around it succeeded.

Without the entry the file never loads, DF:ToggleGUI stays nil and /df errors
on the first keypress. Caught by toc_check.py ("on disk but not in TOC"),
which is exactly the silent-failure class that check exists for.

Also drops two stale comment blocks left behind when the settings pages moved
to the companion.
GUI/Widgets.lua is resident but aliased INFO_BANNER_TONES at file scope, and
that table is defined in GUI/Sections.lua -- which now lives in the
load-on-demand companion. At login GUI._priv.INFO_BANNER_TONES is nil, so the
alias captured nil permanently and never saw the companion's later publish.
GUI:CreateDebugCategoryRow would then nil-index it.

Read at call time instead. That function is only ever called from a settings
page, so the companion is loaded by the time it runs.

This is a new failure mode the earlier splits could not have: a load-time
alias is fine across files that always load together, and wrong the moment
one side becomes load-on-demand.

Found by alias_check.py, but only after repointing it -- it and
lod_gate_check.py still had paths from before the payload moved, so both were
silently passing on nothing. Both now resolve across the two addon folders,
and alias_check gained the settings-page chain as a third split set.
Two runtime faults, both invisible to every checker in the harness, both
found from one in-game crash report.

1. Every DF.* read in the companion was nil.

   Each addon's `...` yields its OWN private table. All 30 companion files
   still had `local addonName, DF = ...` from before the move, so DF was the
   companion's empty table and the first `DF.GUI.Colors.x` preamble line blew
   up -- Cards.lua:9 was simply the first one reached. The companion was
   entirely non-functional.

   They now take the parent's table from the global that DandersFrames
   already exposes via ## AllowAddOnTableAccess. DandersFrames.xml has bound
   it that way for a while; XML script blocks have no `...` either.

   addonName was only ever read in one place (Debug/MemoryTest), so it is
   declared there and nowhere else. That reading also now sums both addons:
   the memory panel lives in the companion, so the companion is loaded by
   definition whenever a reading is taken, and measuring only the parent
   would have quietly omitted the larger half.

2. Login-time work does not run in a load-on-demand addon.

   The companion loads when the player opens the settings panel. Anything in
   it waiting on a once-per-session event has already missed it and fails
   silently -- no error, the feature just never happens.

   - ClickCasting's bootstrap sat in UI/BindingEditor behind a
     PLAYER_ENTERING_WORLD gated on isInitialLogin/isReloadingUi. Those are
     only true on the firing already missed, so CC:Initialize() would never
     have run and click-casting would never have worked at all. Moved to the
     resident ClickCasting/Events.lua; CC:Initialize itself was already
     resident, only its trigger was stranded.

   - ColorPicker waited for ADDON_LOADED("DandersFrames") to read its saved
     swatches, and for PLAYER_ENTERING_WORLD to install its hook. The first
     is dead, which meant swatches loading empty and then being overwritten
     with nothing on the next save. Both now just run at file scope: the
     parent and its SavedVariables are loaded before the companion can.

docs/reorg-tools/lod_login_check.py (local) is the checker for this class.
Verified by running it against the pre-fix files, where it reports both DEAD
sites independently. It also flags GUI/Pages/AutoProfiles.lua as DELAYED --
auto-profile switching is driven from a payload event frame, so it stays
inert until the panel is opened once. That one is NOT fixed here; it needs
the engine half split out to the resident side, the same treatment
AuraDesigner/Migrations.lua already got.

split_verify's last live entry is now marked historical: the pages moved
folder and their preamble changed, both deliberately. It has no live splits
left and is a record, not an ongoing check.
RefreshContentTypes reassigned the local on every locale refresh. Harmless
while the whole feature was one file; not harmless once the settings page is
split out into the load-on-demand companion, because the page can only reach
this table by reference. A reassignment would leave the page holding the
previous table forever -- stale labels, no error.

Rebuilding in place keeps the table's identity stable, so any reference taken
anywhere stays valid. Preparation for the engine/page split; no behaviour
change on its own.
Auto-profile switching is live behaviour: entering a raid should change
profile whether or not the player has ever opened the settings panel. It was
driven entirely from GUI/Pages/AutoProfiles.lua, which is now load-on-demand,
so it stayed inert until the panel was opened once -- silently, with no error.

The 4,160-line file is now two:

  DandersFrames/Core/AutoProfiles.lua           2,239  engine, resident
  DandersFrames_Options/GUI/Pages/AutoProfiles  1,980  page, load-on-demand

The cut is engine vs UI, not sequential parts, so the two halves interleave in
the original rather than concatenating. That puts it outside split_verify's
model; it was proven byte-identical against d718956 by re-interleaving the
halves at the four recorded cut points and comparing to the original -- all
4,160 moved lines unchanged. alias_check.py covers the pair from here on, and
was negative-tested by deleting a publish line to confirm it reports.

Resident half: all the shared helpers, edit-mode state, the override system,
runtime profile application, runtime write interception, the event frame and
the slash commands. Companion half: the page, both dialogs, the editing
banner.

Two things had to change rather than just move:

- CONTENT_TYPES now rebuilds in place (previous commit). It was reassigned on
  every locale refresh, which would have left the page holding a stale table.

- The engine calls four UI methods -- RefreshEditingUI, RefreshTabOverrideStars,
  ShowSidebarHint, HideSidebarHint -- across 11 sites, none of them guarded.
  Rather than touch 11 call sites, the engine defines all four as no-ops and
  the companion overwrites them when it loads. Same pattern as TestMode/Shim,
  and written out individually rather than looped so grep and lod_gate_check
  can see them. Without this, auto-profile switching would have errored with
  the panel unloaded rather than merely doing nothing.

TOC: Core\AutoProfiles.lua loads after Core.lua, which it needs for
DF:RegisterLocaleRefresh at file scope.

Verified: both halves parse, 93 TOC entries all present, load order has 0
violations and 0 unresolved reads, alias_check clean on all four split sets,
lod_gate_check 27 guarded / 0 unguarded, lod_login_check 0 dead.
The companion exists now, so the setup section needs both junctions rather
than describing it as future work. Also states the rule the last few commits
were all instances of: live behaviour must not depend on the companion being
loaded.
30 widget factories the live addon never calls -- 2,483 lines -- move from
GUI.lua and Widgets.lua into DandersFrames_Options/GUI/SettingsWidgets.lua.
The biggest are CreateBorderControls, CreateDesignerPresetBar,
CreateColorPicker and CreateAnimationControls: settings-panel furniture with
no path from live rendering.

What stays is what live code actually touches -- the movers, the settings
search box, tooltips, and the styling primitives (StyleButton,
CreateElementBackdrop, CreateSlider, CreateDropdown). CreateSlider and
CreateDropdown stay only because Frames/Position builds the mover panel with
them; that is 818 lines that cannot move while the mover panel is resident.

Deciding what could move was the whole job, and the obvious rule is wrong:

  - "nothing outside the toolkit calls it" over-counts, because a lot of the
    hits are in COMMENTS. Stripping comments first moved RelayoutHost and
    PromptName from the keep list to the move list and back again.
  - Closing only over other GUI: functions is not enough. A staying function
    reaches a moving one through the file's own local helpers too, which is
    invisible to that closure. The real rule is that the REMAINDER, all of it,
    must not mention anything that left. Iterating that pulled five functions
    back -- AttachTooltip, RegisterMenu, PromptName, CreateOverrideMarker and
    CreateOverrideResetButton -- and without it the first three would have been
    nil at runtime.

Five file locals are newly published on GUI._priv for the new file. None is
reassigned after publication, so aliasing them is safe; that was checked, not
assumed, after CONTENT_TYPES turned out to be reassigned in the AutoProfiles
split.

ApplyPixelBorder and HidePixelBorder (57 lines) are referenced nowhere at all.
Left alone rather than moved or deleted -- flagging separately.

Verified: both resident files reconstruct byte-identically from the remainder
plus the extracted blocks; no toolkit local is used in the new file without an
alias; the resident toolkit no longer calls anything that moved; no unused
aliases in the new preamble; all 114 files parse; TOC complete on both sides;
load order 0 violations; alias_check clean on all four split sets.

lod_gate_check now checks the GUI namespace as well as DF -- the toolkit is
reached as GUI.X, so the DF arm could never see it. It immediately found 16
UNGUARDED resident calls into companion-owned GUI methods, all pre-dating this
commit: GUI:ShowTooltip (15 sites, including pinned frames and the mover
panel) and GUI:CreateInfoBanner. The original toolkit split put ShowTooltip in
the companion while leaving HideTooltip resident. Fixed next; the checker is
correct to be red until then.
GUI:ShowTooltip lived in the companion while its counterpart GUI:HideTooltip
sat in the resident toolkit -- the pair split across an addon boundary by the
original toolkit split. Live code calls ShowTooltip from pinned frames, the
mover panel's "Reset Position to Global" button, the icon library and the
settings search box, none of them guarded, so with the panel unloaded every
one of those hovers errored.

ShowTooltip, AddTooltipLines, the cursor-lift constants and INFO_BANNER_TONES
(144 lines) move back to Widgets.lua. Tone colours belong in the toolkit
anyway; the companion now aliases both from _priv instead of owning them.

The profiler window is the other case. It is built from settings widgets, and
CreateInfoBanner parents its hook checkbox, so there is nothing sensible to
fall back to -- and moving that banner resident would cost ~500 lines to serve
one debug panel. It calls DF:EnsureOptionsLoaded() instead: the window is
opened deliberately, so pulling the companion in there costs nothing anyone
will notice.

None of this was reachable by any checker until lod_gate_check learned the GUI
namespace in the previous commit. It also now recognises EnsureOptionsLoaded
in the enclosing function as a guard in its own right -- it is not a nil-test,
but after it every payload symbol is present, and treating only nil-tests as
guards reported the profiler as a bug. Both arms negative-tested: deleting the
load call turns it red again.

lod_gate_check: 27 DF and 37 GUI call sites, 0 unguarded on both arms.
Search was resident so that it could trigger the companion load. It cannot
need to: it has no entry point of its own. No slash command, no keybind --
the search bar is built by GUI/Panel.lua, which is itself in the companion, so
search is only ever reachable with the panel already open.

That makes the planned "make search load the companion" change unnecessary and
the file itself a clean move. 1,123 lines, no events registered, and the only
file-scope statement is DF.Search = Search.

Its two resident callers are the search-registration lines inside CreateSlider
and CreateDropdown, both already nil-guarded. With the panel unloaded they
skip, which is right -- the sliders being created at that point belong to the
mover panel and are not settings entries. Once the panel loads, the settings
sliders it builds register normally.

Payload is now 56,553 of 158,784 lines, 35.6%.
…oaded


An adversarial audit of the reorg (five independent sweeps) found the reorg
plumbing itself clean but a set of features quietly broken for anyone who had
not opened /df that session -- almost all of them calls into the companion
from resident event code, in namespaces the checker did not watch.

Worst first:

- Click-casting UI refreshes errored on every talent swap, level-up and spec
  change: four resident sites called CC:RefreshClickCastingUI (companion-only)
  from DeferAfter timers, which run callbacks raw. They now route through
  CC:RefreshUIIfLoaded, a guarded helper -- a refresh with no UI is correctly
  a no-op, the panel rebuilds from current state when it loads.

- The Clique/Clicked conflict popup was a Lua error 1.5s after login (and on a
  profile switch that enables click-casting), and the warning it exists to
  show never appeared. Both sites now load the companion first: it is a modal
  the user must act on, and only users with a conflicting addon pay.

- /df raidunlock crashed halfway: ShowRaidTestFrames was the one TestMode
  entry point without a Shim stub. Stub added, and both UnlockFrames and
  UnlockRaidFrames now load the companion up front -- unlocking exists to show
  test frames, so the party path's silent "movers with no frames in them" was
  no better than the raid path's crash.

- /df test was a silent no-op behind a nil-guard. It, the mover Toggle Test
  action, and /df testids now load the companion: deliberate user commands
  follow the profiler's pattern, background events guard.

- /df debug cc spells errored with "click-casting module not loaded" -- false
  (only the UI half is not) -- and never self-healed. The dispatcher now loads
  the companion and retries the handler lookup once. The generic /df debug
  dispatcher does the same before falling through, so the companion's four
  dev slashes (memtest, atlas, auraexp, colorhook) work on first use instead
  of opening the settings window.

- The colour-picker global override reverted to the stock picker every
  session: its hook installs at companion load. LoadOptions.lua now loads the
  companion at login when the setting is enabled -- only users who opted into
  the override pay, and what they pay for is the feature working.

lod_gate_check.py grew a CC arm (the click-casting table alias both sides
use), which is how every one of these would have been caught; verified by
reverting one site to its pre-fix shape and watching it report. Known blind
spot documented: self: calls inside a resident method of a shared table
cannot be attributed textually -- keep cross-boundary calls on the table name.
The audit sized what was left after the big moves; these four were the whole
of the low-risk remainder.

1. The changelog text (~180 KB). Core/Changelog.lua was one giant string
   literal held resident forever, read only by the panel's changelog tab. The
   resident file now carries just the CI-stamped BUILD_DATE/RELEASE_CHANNEL
   header; the text is a second GENERATED file in the companion.
   generate_changelog.sh writes both (verified end-to-end with OVERRIDE_TAG;
   plain local runs die on a pre-existing sed when git describe resolves to a
   slash-named backup tag -- CI only sees release tags). release.yml's
   build-skip filter lists both generated paths; its RELEASE_CHANNEL grep
   still reads the resident header.

2. Core/ExportCategories.lua (~85 KB of category tables). Every consumer is a
   selective import/export path. The three resident entry points in
   Core/Profile.lua load the companion on demand, each scoped so the cheap
   case stays cheap: ExportProfile only when categories were requested (the
   external DandersFrames_Export API does full exports and never pays),
   ApplyImportedProfile only for selective payloads, GetImportInfo always
   (it iterates the tables unconditionally). /df exportaudit loads too.

3. The profiler window (642 lines -> Debug/ProfilerUI.lua). The hooks,
   recording engine and chat reports stay resident. NOT a pure move, and the
   commit says so: the halves shared two mutable locals (profilerFrame,
   UpdateUI), which cannot be aliased across an addon boundary. The window
   now publishes Profiler.frame and Profiler.UpdateUI; the six resident read
   sites -- combat auto-profile, QuickProfile, the SetScript hook's
   self-exclusion -- go through those, nil-guarded, where nil honestly means
   "no window exists". The six number formatters are published on Profiler so
   the window renders with exactly what the chat reports use. Profiler.ToggleUI
   is the same load-and-redispatch stub as DF.ToggleGUI, loop guard included.

4. GUI/IconLib.lua (/dficons dev icon browser, 223 lines). Zero external
   references; its slash re-registers on companion load, and the /df debug
   dispatcher's load-and-retry covers first use.

Payload is now 59,085 of 157,693 lines -- 37.5%.
Verified each finding against the code first -- several were already handled,
and the commit only carries what was actually wrong.

- Frames/Border.lua: the shared border-animation driver never stopped. It is
  created on first animated border and its OnUpdate then ran every frame for
  the rest of the session, paying a MemTestDisabled call and an empty pairs()
  loop long after the last animation ended. Registering shows it, and
  unregistering hides it when the registry empties.

- Frames/ReducedMaxHealth.lua: UNIT_MAX_HEALTH_MODIFIERS_CHANGED is a per-unit
  event that storms when an affix or raid debuff lands, and the handler walked
  every party AND raid frame to find the one unit -- quadratic across a storm,
  in M+ and raid, which is exactly where it fires. Single-unit lookups now go
  through DF.unitFrameMap and fall through to the iterators when the map has no
  entry, so pinned frames (deliberately excluded from that map) behave as before.

- Features/HealthFade.lua: the curve cache key was built by concatenating three
  numbers into a string on EVERY health update of every frame, purely to look
  up a hit. Now nested by the three numbers: three hash lookups, allocation
  only on a real miss. wipe() in the invalidator still clears it.

- Frames/StatusIcons.lua: pcall(closure) -> pcall(fn, arg) in the 1s phased
  ticker, which allocated a closure per unit per tick in the open world. The
  file already used the cheaper form a few lines above.

- Frames/Core.lua: DF.CHILD_ATTR, precomputed "child1".."child40". Applied to
  the three per-event header scans the sweep flagged -- the pinned-frame lookup
  in Headers.lua and its duplicates in Range and Highlights -- which between
  them rebuilt up to 80 of those strings per unit event. The ~80 cold sites
  still concatenate; that reads better and costs nothing there.

- Guarded three debugstack() sites that ran with logging OFF: Headers.lua's
  UpdateHeaderVisibility trace (every roster and zone change) was fully
  unguarded, and Position.lua's raid-anchor write log and container-reposition
  log tested `DF.Debug` -- which only asks whether the function exists, and it
  always does -- so ShortCaller built a stack string every time. Now DebugActive.

  ⚠ The audit listed eight debugstack sites; five were already correctly behind
  DF:DebugActive("FLATRAID"). Only these three were real.

Not taken: the pinned-frame unit map that would remove those scans entirely.
It is the bigger win but the invalidation (children re-slot on roster change)
needs in-game validation, so it is on the Perfy list with the other structural
items rather than done blind.
Simulated the packager's ignore pass against the tracked file list rather than
guessing: two repo-root entries were reaching the built zip that plainly should
not. TRANSLATING.md is contributor documentation, alongside README/CHANGELOG
which were already stripped; .gitnexus is editor tooling metadata (wow-api.json
and friends) that nothing reads at runtime.

With these added, exactly two entries survive the ignore list -- DandersFrames
and DandersFrames_Options -- which is what the zip should contain.

Also checked the thing that looked most likely to break in the container
layout: release.yml's locale validation globs .release/DandersFrames/Locales/
*.lua, and after move-folders lifts DandersFrames/DandersFrames up to
.release/DandersFrames, Locales/ sits directly inside it. That path still
resolves.

⚠ Still not a real build. This verifies the ignore list and the path
assumptions, not the packager's move-folders implementation. The tag test
(push a throwaway tag, inspect the zip's top level for two sibling folders each
with its own .toc, delete the tag) is still required before any real release.
House rule: no other addons named in PR-facing material. The move-folders
comment cited a specific addon as precedent; the reasoning it was carrying --
why the key looks like it collides with its parent and does not -- is what
mattered, so that stays.
@Krathe82
Krathe82 force-pushed the krathe/repo-reorg-lod branch from be96fc4 to 614e082 Compare August 1, 2026 09:12
The packager does not rewrite "## Version:" -- its toc_version handling is the
Interface (game) version, not that field -- so whatever is committed is what
WoW shows in the addon list, both in the zip and locally through a dev
junction. The sync step only ever touched the main TOC, which was fine when
there was one; DandersFrames_Options would have sat at whatever it was
hand-written as and drifted a version further behind on every release.

Both TOCs now sync in a loop, and a missing TOC is a hard error rather than a
silent skip -- if the layout changes again, this script should stop rather
than quietly ship a stale version.

Verified by setting the companion to v0.0.0-STALE and running the generator:
it reported the main TOC already matching and corrected the companion.

Found while doing the .pkgmeta build test that had been outstanding. That test
now passes: the packager was run locally with uploads disabled (-d -e -l -z),
which exercises the real move-folders implementation, and it produced exactly
"Moving DandersFrames/DandersFrames to DandersFrames" and "Moving
DandersFrames/DandersFrames_Options to DandersFrames_Options". The built
package has the two folders as siblings at the top level, each with its own
.toc, all 90 + 36 TOC entries present, the release.yml locale glob resolving
to 11 files, and no repo-root files leaking in.

Not done via a pushed tag on purpose: the Package and Release step passes
CF_API_KEY, so a tag would have uploaded to CurseForge, cut a GitHub release
and pinged the dev Discord. workflow_dispatch does not help either -- the job
requires a main or tag ref.
Danders' review found two runtime defects, a policy leak, a dead command and
a wrong claim I had repeated 30 times. All verified against the code before
acting on them.

BLOCKING

- Two unguarded CC:RefreshClickCastingUI calls in ClickCasting/Profiles.lua,
  inside CheckLoadoutProfileSwitch, which fires from TRAIT_CONFIG_UPDATED and
  PLAYER_ENTERING_WORLD. Anyone with a click-cast profile assigned to a talent
  loadout who had never opened /df got a nil-call on login and on every talent
  swap. Not just an error: the throw aborted before ApplyBindings -- the
  reapply that exists to pick up talent spell-overrides -- and before the
  profile-switch confirmation. Both now use CC:RefreshUIIfLoaded, which already
  existed six lines from the handler that reaches them.

- CURSOR_LIFT_X/Y were unbound in the companion. The declaration went resident
  with ShowTooltip while ShowGameTooltip stayed behind, so Sections.lua read
  two nil globals. Silent, because they are optional SetOwner args -- spell
  tooltips on settings rows lost their 8px lift while every other DF tooltip
  kept it. Published on _priv and aliased, so the pair cannot drift.

Both are `self:` call shapes. lod_gate_check.py documented that exact blind
spot and I did not do the manual sweep the limitation demands, so it now has a
self: arm: in a resident file, self:X( where X is payload-owned is reported as
that namespace. Heuristic, and labelled as one -- the false positive is a name
collision, the false negative was a Lua error on every talent swap.
Negative-tested by reverting one fix and watching it report.

CORRECTION, MINE, IN 30 FILES

The companion binding comment said the parent "publishes its own table as a
global via ## AllowAddOnTableAccess". It does not: the global comes from
_G[addonName] = DF at Core.lua:9. AllowAddOnTableAccess governs access to an
addon's PRIVATE table, is unrelated to the global name, and was in the parent
TOC before this branch. Harmless today, nasty later -- someone deleting
Core.lua:9 as "redundant, the TOC handles it" would nil DF in every companion
file at once. Corrected everywhere, including the string the changelog
generator emits.

ALSO

- /df attached was dead. The scanner moved to the companion and registers no
  slash of its own, so it never reached the /df debug load-and-retry. Now loads,
  same as exportaudit two branches down.
- GetImportInfo loaded the companion for ANY payload. The category tables are
  only read in the isFullExport branch; the guard moved inside it, so the
  full-profile Wago path no longer pays for something it does not use.
- The conflict popup force-loaded the companion before checking
  ignoreConflictWarning -- a flag that is resident-readable and is the popup's
  own first line. A user who ticked "don't warn me again" pulled the whole
  settings addon at every login to reach a function that immediately returns.
  Flag hoisted into the resident guard at both sites.

DOCS

- README: the layout diagram omitted the companion; the junction-removal step
  said "delete any real AddOns/DandersFrames folder", which for an existing dev
  is a JUNCTION -- rm -rf follows it and eats the working tree. Now shows
  cmd //c rmdir with an explanation, adds _beta_ to the install list, states
  the LoD rule as a table, and is honest that docs/reorg-tools is not in the
  repo rather than citing files a reader cannot find.
- The companion TOC claimed three load-bearing orderings. Two were not real:
  Sections/Controls/Panel alias only the resident toolkit, never each other,
  and the Pages parts are a runtime tail-call chain. The one genuine
  cross-addon constraint -- Pages/AutoProfiles aliasing the resident engine's
  _priv -- was undocumented. Corrected, since a reader trusting the wrong
  constraints and missing the real one is worse than no comment.
- Companion TOC header still quoted the superseded 27.5% / 55,000 figures.

NOT CHANGED, and why: the review called the committed companion Changelog.lua
"stale (1 section vs CHANGELOG.md's 58)". It is not -- generate_changelog.sh
trims to the first section on a minor/major bump, and the local run confirms
"Minor/major bump detected (v4.9.0 -> v5.0.0-alpha.13): trimmed changelog".
Regenerated anyway so the committed file matches a current run.
The doc listed GUI:RegisterMenu and GUI:CloseAllMenus as a pair. The split put
the registry table and RegisterMenu resident and CloseAllMenus in the
companion, so resident code can register a menu but has no way to close one.

Nothing is broken today -- both CloseAllMenus callers are companion-side, so
the reader and its callers load together -- but resident CreateDropdown does
register menus, and its one resident caller (the mover panel's anchor
dropdown) exists with the panel unloaded. Someone reaching for CloseAllMenus
from resident code would find it nil, and because it is a bulk tidy-up call a
nil-guard fails SILENTLY by leaving a menu floating: the exact symptom the
registry exists to prevent.

Documented as a boundary rather than a pair, with the recommendation to move
the four lines resident if it is ever needed there -- guarding the call would
document the split instead of removing it. Also distinguishes it from
CloseOpenDropdown, which is resident, published on _priv, available both sides,
and is what most call sites actually want.

Caught by Danders' review of DanderBot#222.
Four lines, and having them in the companion split a three-part unit across an
addon boundary for no benefit: the registry table and RegisterMenu were
resident while the only thing that reads the registry was not. Resident code
could add a menu but not close one, and resident CreateDropdown does register
(the mover panel's anchor dropdown is a resident caller).

Nothing was broken -- both callers are companion-side, so reader and callers
loaded together -- but the failure mode if anyone had reached for it from
resident code was the bad kind: a bulk 'dismiss whatever is open' call that is
nil half the time fails SILENTLY, leaving a menu floating, which is the exact
symptom the registry exists to prevent.

Taking the fix rather than documenting the wart, which is also the lesson from
the two ORDER-IS-LOAD-BEARING claims this review found to be false: a comment
describing a split is worse than not having the split.

gui-conventions.md updated to match -- it now says all three pieces are
resident and should stay together, keeps the reasoning as a note on why, and
still distinguishes CloseAllMenus from CloseOpenDropdown (resident, on _priv,
the single-open-dropdown case most call sites actually want).
@DanderBot
DanderBot merged commit c4f7f8f into DanderBot:feature/aura-factory-12.1 Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants