| status | in-force |
|---|---|
| summary | The backbone pattern: centralized, self-installing service registries that tools publish into instead of surfaces hardcoding their contents. |
| since | d7ed7e6 2026-08-12 (RegistryBase consolidation) |
| skill | fns-registry |
A general pattern for centralized, self-installing service registries in TouchDesigner, extracted from PaneTypeRegistry (PreviewPanel25) and reused by ToolbarRegistry. This is the backbone of the packaging redesign: each tool ships as a standalone package that publishes into central managers instead of surfaces hardcoding their contents.
Current implementations:
| Registry | Global shortcut | Surface it manages | Master (dev) location |
|---|---|---|---|
FNS_PaneTypeRegistry |
op.FNS_PANETYPEREGISTRY |
Panebar pane-type menu (rows, recall, right-click) | FNSTools/FNS_PaneTypeRegistry |
FNS_ToolbarRegistry |
op.FNS_TOOLBARREGISTRY |
Toolbar widgets (mirrors in /ui/dialogs/bookmark_bar) |
FNSTools/FNS_ToolbarRegistry |
FNS_NavbarRegistry |
op.FNS_NAVBARREGISTRY |
TD's pane bars (stamped copies in panebar_default + every /ui/panes/panebar/*) |
FNSTools/FNS_NavbarRegistry |
FNS_OpMenuRegistry |
op.FNS_OPMENUREGISTRY |
TD's Insert Operator dialog (/ui/dialogs/menu_op) -- search words, row decorations, right-click items |
FNSTools/FNS_OpMenuRegistry |
FNS_MainMenuRegistry |
op.FNS_MAINMENUREGISTRY |
TD's main menu bar (/ui/dialogs/mainmenu) -- ordered entries, left/right justification, anchors |
FNSTools/FNS_MainMenuRegistry |
FNS_ConfigRegistry |
op.FNS_CONFIGREGISTRY |
The aggregated settings file (userPaletteFolder/FNSTools/config/FNStools_config.json) -- per-tool par + state persistence |
FNSTools/FNS_ConfigRegistry |
FNS_Console |
op.FNS_CONSOLE |
The toolkit's web front -- one page, a tab per concern; tools publish tabs via RegisterTab (see ConsoleTabContract.md) |
FNSTools/FNS_Console |
FNS_HubRegistry |
op.FNS_HUBREGISTRY |
FNS_Hub's tab bar -- native panels, viewers and parameter pages tools contribute as tabs (mirrors/viewers injected into FNSTools/FNS_Hub/panel/tabs; see HubContract.md) |
FNSTools/FNS_HubRegistry |
FNS_PaletteRegistry |
op.FNS_PALETTEREGISTRY |
TD's Palette Browser (/ui/dialogs/palette/palette) -- tools contribute native panel COMPs as tabs beside the stock Palette tab (see PaletteTabContract.md) |
FNSTools/FNS_PaletteRegistry |
The table name is the REGISTRY_NAME on the master's extension; the shortcut is
its SHORTCUT. Both are FNS_-prefixed -- the unprefixed spellings
(op.TOOLBARREGISTRY, …) are pre-v3.0.0 and resolve to None. All promoted
globals live in /sys/FNS_Registries; see
RegistryHomeContract.md.
Corrections 2026-09-08 (measured against the live session and the code): the table above lacks
FNS_TimelineRegistry(the tenth registry, timeline dialog panels, TimelineRegistryContract.md), so "nine registries" below reads ten. 2026-10-01: eleven, withFNS_PaneSearchRegistry(controls in each pane's find bar, PaneSearchRegistry.md). 2026-10-11: twelve, withFNS_OverlayRegistry(every tool's layer TOP composited and shown above the nodes of the network editor you work in, NetworkOverlayRegistry.md); the first registry shipped as a package rather than core. The periodic healing watch described in §3 and in the OpMenu notes is switched off in code (RegistryBase.REGISTRY_WATCH_ENABLED = False): healing runs once per project save throughregistry_presave_exec.healAllRegistries(), and the boot re-publish sweep runs inside that heal. The host clone expression in §6 readsop.FNS.op('FNS_ToolbarRegistry') if hasattr(op, 'FNS') else Nonesince v3.0.0. The §5 exception paragraph is out of date: since v3.1.1FNS_CommandRegistryhas a master under/FNSToolswith its own source (modules/suspects/FNSTools/FNS_CommandRegistry/FNSCommandRegistryExt.py,REGISTRY_VERSION1.11.0 since v3.2.15), ships as a store package with aPkgversion, and the launcher carries that released artifact verbatim (packaging/launcher_mirror.json). It still holds no entries and stamps no hosts, which is the part of the exception that stands.
Scope, the two rails, and what each per-tool hatch really gates: ScopeAndPersistence.md.
-
What it manages: ONE aggregated JSON in the user palette (
app.userPaletteFolder/FNSTools/config/FNStools_config.json, override via the master's Config-pageConfigfilepar). Per-tool sections keyed byCanonicalname:pars(custom-par mode/val/expr/ bindExpr of the TOOL COMP -- meta pages About/Version Ctrl/Info/ Callbacks/Common skipped, per-hostExcludepars/Excludepagespatterns, read-only + pulse styles skipped; the tool'sRegistrypage IS persisted -- its pars are the bind masters holding surface order/display, and restoring them is how layout survives a tool replacement), plus optionalstatefrom aconfig_callbacksDAT (onConfigSave() -> dict,onConfigLoad(data)-- probed, optional, raise-contained, spawned viaCreatecallbackslike the op-menu's). -
Apply NEVER creates parameters. TDJSON's loader was rejected because
addParametersFromJSONOpresurrects retired pars from stale files; the hand-rolled applier skips missing pars, falls dangling BINDs back to CONSTANT with the recorded eval, and contains every par in its own try/except. -
Save triggers: TD project pre-save (a
presave_execexecuteDAT -- active on the /sys global only, and NOTE: Execute DATs gate each callback behind its own toggle par,projectpresaveis OFF by default); theSaveallpulse on any host (forwards to the global); the UPDATER right before it replaces the toolkit (guardedop.FNS_CONFIGREGISTRYcall). Writes are read-merge-write: sections of tools not currently installed are PRESERVED (partial installs), atomic same-dir temp +os.replace,schemagate (mismatched files are never merged into and never applied -- moved aside to.bak). -
Load: per tool, once per session, ~30 frames after the host registers (
_applied_this_sessionset stops healing-tick republishes from re-applying every 2 s; /sys rebuilds per boot so 'once per boot' is automatic).Autoloadper host (default on) -- a tool that wants project-local settings turns it off. ExplicitLoadall/LoadToolbypass the session set. -
Cross-project semantics: the file lives at user level, so settings follow the user across projects; same-tool conflicts between projects are last-writer-wins by design.
-
HotkeyManager pilot -- declared-source model: only DECLARED hotkey rows persist: everything under the tools package (implicit) plus any keyboardin CHOP/DAT or COMP carrying (or inside a COMP carrying) the
FNS_hotkeystag. Project-local absolute-path bindings never persist (a/project1keyboardin is a different thing in every project). Restore MERGES intotable_gathered_hotkeys(update/insert declared rows, project-local rows untouched); rows whose declared source is absent in this project are kept aside and ride along on the next save. -
Configurator layouts roam through
state(Toolbar/Navbar/MainMenu). Each configurator owns a privatestatetable -- dividers, the hideable-group brackets, and the adopted built-ins' order/display/width plus their originaltd_order. That table is the only part of a bar layout no host publisher persists, so the hub -- where the three configurators live since 2026-08-23 -- ships ONEconfig_callbacksthat saves every configurator's table whole and restores it whole, keyed by configurator name (one coherent per-user layout, never a row-wise merge). The header row travels with the payload, so a table written under a different column set still migrates on restore. Restore must republish, not just rewrite. The payload lands ~30 frames after registration -- long after the configurator's boot republish -- and the /sys global is ephemeral, soConfiguratorExt.RestoreState(rows)redoes everything boot does: built-in overrides, dividers, stored order, group brackets (§3: virtual entries die unless the configurator republishes them). Two traps it exists to cover:_adoptBuiltinsearly-outs once every built-in is adopted, so stored order needs re-asserting separately (_reapplyStoredOrders, called from_publishOwnedso boot and restore share one path); and a wholesale replace has to retire dividers the incoming layout dropped. Tool entries are NOT in scope here -- their order/display live on their own host pars and roam in theparssection. -
FNS_persisttag -- registration without a host. Tag any COMPFNS_persistand the global registers it (canonical = the COMP name, defaults throughout: autoload on, persist pars, no callback, nosource_registry). It is the zero-configuration path for micro-tools too small to carry a host, and the precedent isFNS_hotkeysin HotkeyManagerExt._sweepPersistTagsnever runs on a timer -- finding a tag means walking the whole project, and this toolkit runs inside live shows, so nothing may add recurring per-frame work. It fires at the only two moments the answer can change anything: the BOOT window (bounded byBOOT_SWEEPS, like the base host sweep -- /sys is ephemeral, so this is what re-registers tagged COMPs on open, in time to apply their settings) and everySaveAll(pre-save, theSaveallpulse, the UPDATER). In between, tagging is inert: a tag added mid-session takes effect at the next save, and its settings apply on the next open. TheSaveAllsweep must run BEFORE the snapshot -- registering queues a deferred apply, so a COMP tagged mid-session whose canonical name already carries another project's section needs its live values written out first, or that apply lands the foreign section on it. A HOSTED tool always wins its canonical name; name clashes between two tagged COMPs keep the first anddebug()the rest. Untagging never deletes the saved section -- it is just an uninstalled tool, and the read-merge-write preserves it. Entries carrytag_source: '1'so the sweep can tell its own registrations from real ones; they survive heal ticks because base pruning only drops entries whose recorded source registry died (had_source), and a vanished COMP still drops through thepanel is Nonepath. -
No Menuorder/Displayed -- order and display are meaningless for a file. Tool page prefix
Cf. The master doubles as the FNS_Config package's own host (canonicalFNS_Config); one extra host at the toolkit ROOT (canonicalFNS,Promoteparsoff) covers root-level pars. Cook-disabled tools (midiMapper) cannot host -- skipped. -
Entries are BEHAVIOUR, not operators. The other two registries publish a thing to place on a surface (a widget, a bar item). This one publishes contributions to a dialog TD already owns, in five kinds: extra fuzzy search words, node-table row decorations, right-click menu items, filter-chain stages, and dialog panels. The first three create nothing in
/uiat all; the last two are injected copies, taggedOpMenuRegistryChain/OpMenuRegistryPanelso pruning only ever touches what the registry owns. -
Appearance is NOT a registry concern -- see
FNS_UISkin. Skinning (panel background TOPs, and whatever customization comes next) briefly lived here as host parameters, then as anonPanelTops()hook. Both were retired: skinning contributes nothing to a surface's behaviour, it just overwrites a parameter on a dialog TD already owns, so routing it through the registry made every skinnable panel a registry concept for no gain.FNS_UISkinis a standalone tool -- no host, no callbacks DAT -- covering every registry surface's background panels, one page each: Op Menu (emptypanel/nodepanel/familypanel/searchpanel), Toolbar (bookmark_bar/emptypanel), Main Menu (mainmenu/emptypanel) and Pane Bar (TD'spanebar_defaulttemplate plus every live/ui/panes/panebar/*-- one parameter, 11+ panels).UISkinExt.SKIN_TARGETSis the single source of truth: it GENERATES the pages and parameters (EnsurePars) and drivesApply(), so adding a knob is one row and nothing else. Targets resolve throughops()patterns on every apply, and a healing tick re-asserts while anything is claimed, so a pane split later still matches. Values are captured once per panel and restored on blank. The rule this settled: the registry is for contributions a surface must aggregate, order, and arbitrate between; a tool that only writes a parameter should just write it. -
Chain stages and panels are what the legacy installer hardcoded. The I/O filter used to be a special case inside
install.py(splicescript_IOFilterafter the injected node; injectradioExposeintosearchpanel; both gated on aparent.FNS.par.Activeopmenuiofiltercheck). IOFilter now declares both for itself viaonChainNodes()/onPanels(), and reads the toggle in its OWN callbacks -- so whether it contributes is IOFilter's decision, not a branch in the installer. Aparexec_activeinside IOFilter callsop.FNS_OPMENUREGISTRY.Resync()when the toggle flips. Chain order follows contributor order and the chain heals around a removed stage, so install order stops mattering.install.pyis down to ONE executable line (the compat-table patch). -
The contribution's CODE lives in the publishing tool. Each tool ships ONE
opmenu_callbacksDAT of its own; the entry carries onlycallback_path/callback_id, and the registry probes which hooks that module defines (onSearchWords,onDecorateLabel,onMenuItems,onMenuItem-- all optional). This is the rule that keeps the surface component free of tool names: FNS_OpMenu used to callop.FNS_OPTEMPLATES.Templatesandop.FNS_OPTEMPLATES.OpenTemplateBase()from its own callback DATs; that code now lives inside OpTemplates and travels in OpTemplates' tox. One host per tool -- capabilities are discovered, not declared, so a tool contributing three different things still ships one host. -
The registry owns NO chain stage of its own (corrected 2026-08-09). It owns the mechanism — aggregation, injection, ordering, healing, menu dispatch (
popmenu_dispatch) — and nothing that knows TD's node-table schema. The stage that APPLIES the aggregated search words and decorators (script_inject+ its callbacks) belongs to FNS_OpMenu, published throughonChainNodesexactly like IOFilter's. It was already written against the registry's public API (SearchWords,Decorators), so it was a contributor in everything but location. Moving it kept the schema-fragile part (column names, thescore <= 3heuristic,layouts/{family}/{opType}strings) inside a tool, where a TD version change breaks one tool instead of the registry. Consequence, and it is the honest trade:onSearchWords/onDecorateLabelonly take effect when some tool supplies an applier stage. The registry still aggregates them regardless. -
Chain wiring is re-asserted every sync, not just on injection (
_relinkChain). A stage that is merely "not stale" is still wired to whatever neighbour it had when injected, so adding, removing or re-ordering ANY other stage silently leaves it mis-linked — the bug that produced a stage with two inputs and no outputs while TD's own downstream ops sat on the wrong stage. Each sync enforcesfamilies -> stage1 -> ... -> stageN -> (whatever consumed the chain), remembering each consumer's exact input index so multi-input consumers reconnect faithfully. -
The right-click menu is rebuilt, not appended to. TD's own three items (Help / Python Help / Snippets,
BUILTIN_MENU_ITEMS = 3) always lead; registered labels follow.popmenu_dispatchroutes any click past the builtins toInvokeMenuItem(index - 3, optype), which calls back into the publishing tool. Rebuilding from the live par's first 3 entries keeps it idempotent across heals. -
Hot path: the injected node cooks over every operator type in the dialog, so it resolves
SearchWordsandDecoratorsONCE per cook and calls the functions per row -- never re-resolve contributors per row.
- Copy an
OpMenuRegistryhost into your tool; setComp=..andCanonicalname. - Pulse
Create Callbacks. It spawnsopmenu_callbacksinto your tool from the registry'scallbacks_templateand wires the host'sCallbackpar to it. Idempotent: an existing DAT is adopted, never overwritten, so pulsing again just repairs an unsetCallback. - Fill in the hooks you want. Turn
Autoregisteron.
The pulse is promoted onto the tool itself (Omcreatecallbacks) alongside
the other Registry-page pars, so the whole setup happens without opening
the host. Two things the spawner does that matter: it strips the file
binding off the copy (the template is a synced repo file -- an inherited
binding would make every tool's callbacks read from, and save over, the one
shared template), and it strips tracker tags so the copy does not get
adopted as a tracked identity.
Every hook is optional -- the registry probes the module and uses what
it finds, so one host covers a tool that defines one hook or all six. The
spawned template defines all six but returns empty from each, so a fresh
publisher contributes NOTHING until you fill something in. me.parent() is
your tool; resolve everything from there, never by absolute path or global
shortcut.
| Hook | Returns | When |
|---|---|---|
onSearchWords() |
{opType: [word, ...]} |
merged into the dialog's fuzzy search |
onDecorateLabel(opType, label) |
replacement str, or None |
once per visible row, per cook -- keep it cheap |
onMenuItems() |
[label, ...] |
appended after TD's own three items |
onMenuItem(label, opType) |
-- | one of YOUR items was clicked |
onChainNodes() |
[scriptDAT, ...] |
stages spliced into the node-table chain, in order |
onPanels() |
[(comp, anchor_name), ...] |
panels injected into the dialog, wired to anchor_name |
Returning [] / {} WITHDRAWS a contribution -- the registry prunes what
it injected. That is how a live toggle works: decide it in your callbacks
(it is your tool's decision, not the registry's) and call
op.FNS_OPMENUREGISTRY.Resync() when the condition changes, or let the ~2s
healing tick pick it up. FNS_OpMenu/IOFilter is the worked example.
A hook that raises is contained: debug()'d, skipped, dialog keeps working.
- Manager API deltas:
RegisterContributor/UnregisterContributor,SetContributorOrder,SetContributorDisplay,Contributors,SearchWords,Decorators,DecorateLabel,MenuItems,InvokeMenuItem,Resync. Tool page prefixOm. No configurator yet (a natural next step: enable/disable and reorder contributions). - The global re-asks hosts to publish during the boot window -- born
here, generalized into
RegistryBase2026-08-12 (every registry now gets it via the base healing tick)./sysdoes NOT save with the project, so on every open (and after ANY extension reinit wave, including the one everyproject.save()triggers) the global comes up empty while hosts believe they are registered: theirAutoregisterran at extension init, which can predate the global being ready. Symptom: hosts reportRegstatus: RegisteredwhileContributorsis empty._reapplyAutoregisterHosts()runs on the firstBOOT_SWEEPS(6) healing ticks, finds liveAutoregisterhosts the global has no entry for, and asks them to republish; then it stops, because it is a project-wide search. Verified: wiping the global's entries entirely and running ONE heal tick restores every contributor and the whole surface.findChildrengotcha paid for here: TD'sdepthargument is an EXACT depth, not a maximum --findChildren(name=X, depth=6)silently matched nothing while the barefindChildren(name=X)found all three hosts. Never passdepthwhen you mean "anywhere below". - The legacy installer is NOT fully retired here -- unlike the navbar.
FNS_OpMenu/installstill runs (fromexecute2, 60 frames after start) for FNS_OpMenu's OWN chrome: the optional IOFilter injection and the family-compat patch. Only the parts the registry took over were removed.
- Plural surface. The navbar is TD's own pane bar:
/ui/dialogs/panebar/panebar_default(the template new panes inherit from) PLUS one live bar per open pane. Sync and the healing tick walk ALL of them; a pane split after the last sync is populated by the next watch tick. - Stamped copies, not selectCOMP mirrors. Every pane bar needs its OWN instance
(a breadcrumb shows ITS pane's path; mirrors would show one source everywhere), and
two entry kinds cannot be mirrored at all. Instances are
nbitem_<canonical>copies taggedNavbarRegistryItem,allowCookingre-enabled (sources ship parked cook-disabled), re-stamped when the registered source changes (RefreshWidget). sideis first-class (left|right, the user-visible left/right adjacency). TD's stock items are NEVER renumbered: at sync time the registry finds the in-flowhmode=fillpivot (panenav, the path area) and fractionally subdivides the open interval between the last stock-left item and the pivot (left side), or the pivot and the first stock-right item (right side). Recomputed live, so Derivative inserting their own fractional items (e.g.homeAll5.4,root5.5) heals on the next pass.kindis first-class:widget(aligned panel; height soft-enforced to the bar's inner height via the legacyme.panelParent()expression),overlay(out-of-flow panel, e.g. the click-through path-cell layer -- shown/hidden but never re-ordered),logic(non-panel COMP that just needs a running copy inside every bar, e.g. the drag-drop hijacker).- Display expressions survive. A manager hide writes constant 0, but while an entry says "shown", a template's own display EXPRESSION is preserved.
- Manager API deltas:
SetWidgetSide,SideSequences,RefreshWidget;SetWidgetSequencereassigns 1..N per side (widgets keep their side). No dividers on this surface.NavbarConfigurator(gear in the bar,op.NAVBARCONFIG) has Name / Side / Show / Origin columns -- Side cell click flips the side. - The legacy installer is retired:
FNS_Navbar/execute1inactive,install.pyfrozen on disk. Sources live UN-parked (cooking on) inFNS_Navbar/containers, because each source carries its own registry host INSIDE it (containers/parent_hierarchy/NavbarRegistry,Comp='..') -- every component is a standalone self-registering unit, same shape drop-to-register stamps. A host inside a cook-disabled subtree cannot compile, hence no parking._injectItemstrips the embedded host from bar copies (the /sys-or-/ui guard would neutralize it anyway; bars stay lean).
- Surface: TD's main menu bar
/ui/dialogs/mainmenu(File/Edit strip on the far left, wiki/forum/tutorials/fps cluster left of center, OpFamUI/update in the right corner). ONE bar, like the bookmark bar -- so entries render as selectCOMP mirrors (mmitem_<canonical>, tagMainMenuRegistryItem), never stamped copies. Mirror height is soft-enforced to 19 px (BAR_ICON_HEIGHT, the same height every stock main-menu item uses); width live-follows the source unless the entry carries an override. - Anchoring contract: every stock item wires its COMP input 0 to the bar's
emptypaneloutput -- mirrors get exactly that wire (_anchorMirror); an unwired panel drops out of the bar's layout flow. sideis first-class, navbar-style (left|right), but the pivot isstringfield(alignorder 4.0, the bar's only in-flowhmode=fillitem -- the stretchy status-message area). Left entries subdivide (last-stock-left ~3.4, 4.0); right entries subdivide (4.0, first-stock-right ~4.9). TD stock alignorders are never touched. The File/Editmenustrip andemptypanelsit at alignorder 0 and are excluded from the scan, same as the pane bar's alignorder-0 cluster.- No
kind, no dividers (v0.1.0): every entry is an aligned panel widget. Groups (bracket pairs) come from RegistryBase and work unchanged. - Built-ins ARE auto-adopted -- seeded by pixels, not alignorder. The whole left
cluster (
wiki..realtime) is adopted into the managed sequence so entries can be ordered BETWEEN TD's items (user decision 2026-08-10). Two deltas from the pane bar's recipe: the duplicate-alignorder pair (gpuUsage/realtimeboth 3.4) is adoptable anyway because first adoption seeds the sequence from LIVE X positions (pixels resolve the tie the way the user sees it), and thelayer == 0adoption filter is dropped (gpuUsagerides layer 5 yet aligns like any fixed item). CRITICAL: snapshot the x positions BEFORE the firstAdoptBarWidgetcall -- every adoption re-flows the bar, so positions read mid-loop are churn, not truth (paid for once: entries jumped ahead ofwiki). Original alignorders persist astd_orderin the Configuratorstatetable; right-click the TD topbar button restores TD's original order.menu(ao 0), thestringfieldpivot, and theOpFamUI/updateright corner stay unmanaged landmarks. Adopted order/display persist in thestatetable (adopted entries have no host publisher). Anchorpar (Registration page,Mmanchoron the tool): pins an entry into the gap directly after a NAMED stock item, overriding the side band -- the escape hatch for placing next to something unmanaged (e.g. the right corner,Anchor=OpFamUI). Name-based and re-resolved live, so it heals across TD builds; a vanished anchor falls back to the side band. With the left cluster adopted, ordinary sequence order covers most cases (projname sits betweentutorialsandstartstopby plain order now).- Manager API deltas:
SetWidgetSide,SideSequences;SetWidgetSequencereassigns 1..N per side.MainMenuConfigurator(gear in the bar,op.MAINMENUCONFIG) is the NavbarConfigurator adapted; tool page prefix isMm. - Callbacks DAT (optional): lifecycle hooks, not interaction. The panel handles
its own clicks (the mirror forwards them); the callbacks DAT is for REACTING to the
manager -- the registry probes it for
onRegistered(canonical, info)(fires on EVERY publish incl. boot/healing re-applies; keep idempotent),onUnregistered,onDisplayChanged(canonical, visible)(manager show/hide only, NOT group collapse),onSideChanged(canonical, side). All optional, raise-contained. The host'sCreate Callbackspulse (Createcallbacks, promoted asMmcreatecallbacks) spawnsmainmenu_callbacksfrom the registry'scallbacks_templateINTO THE HOST'S PARENT TOOL -- not_hostComp(), which on this surface is the registered widget panel itself (the OpMenu recipe copied verbatim spawns the DAT inside a bare textCOMP; paid for once) -- and wires theCallbackpar to it. Idempotent: an existing DAT is adopted, never overwritten. - First registrant: BorderlessWindow publishes its
projname(canonicalProjName, side left) through an embedded host; its legacyinstall.pydirect-copy injection ships nothing, anddisplayProjNamedrives the mirror throughSetWidgetDisplay(dynamic-visibility pattern, like HydroHomie on the toolbar).
Where the globals live. (Contract for agents: RegistryHomeContract.md.) Every promoted global sits in one container,
/sys/FNS_Registries— created on demand by whichever registry promotes first, and shared with FNS_CommandRegistry from TDXLauncherUtility. Global OP shortcuts resolve from any depth, so nothing that consumes a registry changes; the container just keeps the family out of TD's own/sysfurniture and gives the installer/updater one place to look (InstallerExt.PromotedRegistries())./sysstill does not save with the project, so this is per-process state re-created on every open, and copies found parked directly in/sysare treated as pre-container legacy and absorbed.
Every registry component can play two roles, decided at runtime:
- Global instance — lives at
/sys/FNS_Registries/<RegistryName>, owns the global OP shortcut, and is the single manager of its surface: which entries exist, which are shown, in what order, and how they are recalled. It is pure infrastructure — its host-publisher parameters (the Registration page) are neutralized at promotion (kept, reset to inert defaults). - Host instance — an unmodified copy shipped inside a tool. It never
manages the surface. It does exactly two things: (a) on load, install or
version-upgrade the global instance; (b) publish its one entry into the
global (
Autoregister/Registerpulse). All its API calls forward to the global; with no global ready they no-op with adebug()note.
The same COMP is both things — a copy is the distribution format. Drop the component into any tool, set three parameters, done.
On extension init (postInit), an instance that is NOT the sys-global runs:
_installGlobalRegistry():- A global exists and is newer or equal (semver on the
VersionAbout par) → stand down. - A global exists and is older → merge its entries (the incumbent
wins every name it holds: it is the live table, whatever this master
still stores is history), destroy it, copy self into the home
(
_become_global_registry), hand data over via thepost_updateraw-storage handoff (the copy's extension may not compile on the first frames — a 20-attemptreinitextensionsretry loop recovers), promote (set shortcut, neutralize Registration page, sync surface), then drop the master's own table. Every registration survives a takeover: the entries are merged, the heal repairs their paths by op id, hosts the new global lacks republish on the boot sweep, and the surface is rebuilt from the entries. Only extension-instance state (caches, the watcher baseline, the shared-surface ownership flags) starts over. - No global → reconcile any parked (shortcut-less) copies in the home (highest version wins, entries merged additively), then self-promote.
- A global still sitting directly in
/syspredates theFNS_Registrieshome → relocated whatever its version (merge, destroy, re-promote into the home), so the two spots never both hold a live one. - Version mismatch → resolved silently, newest wins. The whole
version is compared (
_parse_versionnormalizes to three components, so1.0==1.0.0) and ties favor the incumbent. This path must never prompt — see Never ask during promotion below.
- A global exists and is newer or equal (semver on the
_release_shipped_shortcut()— a host never keeps the global shortcut._dropParallelTable()— while a separate global is live, a master or host keeps no entry table. Registrations delegate to the global, so a table here is history from before the global existed or a copy's inheritance, and on the next takeover it would shadow the live entries (the Hub and Palette masters carried one, 2026-09-10). A project with no/sysleaves the master as the API, and its table is then the real one._applyHostRegistration()— publish ifAutoregisteris on._ensureSelectionExecuteRole()— host disables any surface-handling ops (ConfigRegistry and MainMenuRegistry also clear the table here, from before the base did it).
The sys-global branch instead: drains post_update, sanitizes stored
entries, re-asserts the shortcut, neutralizes the Registration page
(_neutralizeHostParameters — pars kept, values reset), syncs the surface,
and arms the healing watch.
Promotion runs during project load, once per registry copy — nine registries with dozens of hosts between them. A blocking call in that path is therefore not one dialog, it is a queue of them, and TouchDesigner is trying to initialize extensions the whole time.
_compare_versions and _check_version_against used to raise a
ui.messageBox chooser on a major-version mismatch. On 2026-08-24 that
deadlocked the session outright: FNS_ToolbarRegistry came up with 0 of 20
hosts extensionsReady, the master would not initialize even when asked
directly, and every Envoy read answered "Cannot use an extension during its
initialization." It reproduced from a cold boot — a fresh TD process spinning a
full CPU core with Envoy never binding its port, so the session could not even
be inspected. Removing the dialog restored all 20 hosts on the next reinit.
So: the version comparison resolves silently, newest wins, ties go to the
incumbent. No ui.messageBox, no input, no modal, no wait — anywhere the
promotion path can reach. A version conflict interesting enough to report gets
reported after load, through Regstatus or the log, never through a dialog.
Removing the dialog from scripts/shared/RegistryBase.py does not finish
the job. Every registry host carries its own RegistryBase DAT, and on
2026-08-24 a cold boot raised the chooser again with every file on disk clean.
Two of 102 live copies still held pre-fix text:
| Copy | Why it was stale |
|---|---|
FNS_Hub/OpMenuConfigurator/FNS_HubRegistry/RegistryBase |
bound to the shared master but syncfile off, so it never reloaded (99 of the other copies have it on) |
TDXLauncherUtility/ui_mod/FNS_MainMenuRegistry/RegistryBase |
v0.2.1 embedded in another package's release tox — self-contained by design, so no file edit ever reaches it |
The second is the general case: a package that vendors a registry ships its own frozen copy of this code. Old vendored copies keep prompting forever, and they prompt precisely when they LOSE (v0.2.1 against the live v1.0.0) — so the dialog shows up in projects where nothing is wrong.
After changing anything on the promotion path, sweep the live network, not the
repo: find every RegistryBase, check the text, repair by restoring syncfile
where the copy is file-bound and by pushing the current text where it is
deliberately self-contained. Write the sweep with maxDepth, never depth —
findChildren(depth=99) returns an empty list and reads as "all clean"
(see .claude/rules/td-python.md).
Entries live in the global's StorageManager store (PaneRegistry key —
historical name, shared by all registries) as plain-string dicts:
- Identity is dual:
panel_path+panel_id(session op id first, path fallback via_resolveByIdOrPath) so renames and moves heal. source_registry+source_registry_idrecord the publishing host — ownership checks stop a copied template from clobbering the original's entry, and healing can ask the source to republish.- Optional:
menu_order,display,callback_path/callback_id, plus surface-specific flags (pane recall flags, etc.). - Strings only, never enum members — TD pickles storage into the
.toe, and enum members defined in a DAT module fail pickle's identity check after a reinit.
Persistence model: the global table is deliberately ephemeral (/sys
is NOT saved with the project). The source of truth is the publishers: on
every open each Autoregistered host republishes, and manager edits
write BACK to host pars (Menuorder, Displayed, Barwidth —
compare-before-set so host callbacks don't storm). Entries a UI component
owns (the configurator's dividers, its gear, built-in overrides) persist in
that component's own state table, republished on its boot. Virtual
entries (virtual: '1', e.g. dividers) have no backing operator; base
healing skips them, and they exist only as long as some publisher
re-registers them. The global additionally runs a healing watch (every
120 frames): re-resolve moved ops, ask live sources to republish missing
targets, drop entries whose host died, and (surface hook) re-inject anything
the surface lost — this is also what makes a late-arriving surface work.
Internal layout (2026-08-28): the base is organized as seven job mixins (
_RegistryToolPageMixin,_RegistryHostMixin,_RegistryGlobalMixin,_RegistryHealMixin,_RegistryStampMixin,_RegistryGroupsMixin,_RegistryGroupWidgetMixin) flattened intoRegistryBase, which keeps the identity, lifecycle, and the surface hooks. The subclass contract below is unchanged — subclasses derive fromRegistryBasealone; the mixins are organization, not API.
RegistryBase (a textDAT module inside every registry COMP; subclass
imports it with RegistryBase = mod('RegistryBase').RegistryBase) owns
everything above. A new registry supplies:
class MyRegistryExt(RegistryBase):
SHORTCUT = 'MYREGISTRY' # global OP shortcut it claims
EXT_NAME = 'MyRegistryExt' # class AND ext-DAT name (must match)
REGISTRY_NAME = 'MyRegistry' # COMP family name for home discovery
# HOST_PAGE_NAME = 'Registration' (inherited default)
CLONE_EXPR = "op.FNS_X.op('MyRegistry') if hasattr(op, 'FNS_X') else None"
PACKAGE_SHORTCUT = 'FNS_X' # the package shortcut CLONE_EXPR usesWith CLONE_EXPR + PACKAGE_SHORTCUT set, the base provides fleet-wide
plumbing every registry used to duplicate (consolidated 2026-08-12):
_reapplyAutoregisterHosts (the boot re-publish sweep) and
_healHostClones both run from the BASE healing tick automatically;
_setConst/_setExpr compare-before-set helpers live in the base; and
StampHost(target_comp, canonical_name=..., par_values=...) is the ONE
blessed way to stamp a host into a tool -- configurator drops, fleet
rollouts and scripts all route through it so the paid-for copy hazards
(ext-init-during-copy, inherited externaltox/pi_suspect, inherited storage
containers, copied Registration-par binds) stay fixed in one place.
Surface hooks to override (safe no-op defaults in the base):
| Hook | Called when | Responsibility |
|---|---|---|
_preInit() |
start of __init__ |
par plumbing that must exist pre-storage |
_syncSurface(attempts=40) |
promotion, sys postInit, registration | idempotent: prune orphans + (re)inject every entry; self-defer with run(..., delayFrames=30, delayRef=op.TDResources) until the surface exists |
_sanitizeStoredRegistry() |
every postInit | migrate/repair legacy entry shapes |
_ensureSelectionExecuteRole() |
every postInit | enable surface handlers on the global, disable + clear local table on hosts |
_resyncRegisteredMenuRows() |
after ordered registration | default calls _syncSurface() |
_normalize_action(value) |
entry merges | coerce legacy values to strings |
_healRegistryEntries() |
watch tick (extend via super()) |
add surface repair to base healing |
Plus the registry's own public API (RegisterX / UnregisterX + manager
methods). Follow the forwarding guard pattern; base healing calls
self.UnregisterPanel(name) — alias it if your verbs differ. Host
_applyHostRegistration in the base is pane-flavored; override it when your
Registration page differs (ToolbarRegistry does).
Every registry COMP carries a FNS_<RegistryName> tag (2026-08-12:
FNS_ToolbarRegistry, FNS_NavbarRegistry, FNS_OpMenuRegistry,
FNS_MainMenuRegistry, FNS_PaneTypeRegistry, FNS_ConfigRegistry) --
masters, in-tool hosts, and /sys globals alike. Find any registry family
with findChildren(tags=['FNS_ConfigRegistry']), or every registry comp
in the project with findChildren(tags=['FNS_*Registry']). Copies inherit
the tag, so new host stamps stay findable with zero extra work; a new
registry adds its tag to the MASTER before the first host is stamped.
Standard component anatomy (copy an existing registry as the template):
MyRegistry (baseCOMP, initextonstart=1, ext: op('./MyRegistryExt').module.MyRegistryExt(me))
├── MyRegistryExt textDAT (externalized .py, syncfile)
├── RegistryBase textDAT (externalized .py, syncfile — per-package copy)
├── ExtUtils baseCOMP (CustomParHelper + par-callback plumbing)
├── pre_release textDAT (release scrub hook, see §6)
└── custom pages: Registration (host-only; neutralized on the global) + About (Version!)
Registration page baseline: Autoregister (toggle), Register (pulse),
Regstatus (read-only str), Comp (COMP ref, default ..),
Canonicalname (str), Menuorder (int, -1 = append), surface-specific
extras, Callback (DAT). About page must carry Version — promotion
depends on it.
Page ORDER is standardized across every registry (fleet-wide as of
2026-08-09) — five startSection groups reading as the setup flow a tool
author actually follows, rather than the order pars happened to be added:
| Group | Pars |
|---|---|
| What is registered | Comp, Canonicalname |
| How it behaves | Callback (+ Createcallbacks where the registry spawns one) |
| Turn it on | Autoregister, Register, Regstatus |
| How it appears | Menuorder, Displayed, + surface extras (Barwidth, Align, Kind) |
| Meta / shipping | Helpurl, Promotepars |
Regstatus sits under the pars that produce it; a "create" pulse sits
directly under the par it populates. A new registry copied from any master
inherits this. TOOL_PAGE_PARS follows the same flow in its own terms
(on the tool, the "create" pulse comes first — it is the first thing you
do). Re-ordering is par .order + .startSection on the MASTER only,
then enablecloningpulse on every host: no .py edit, so it costs no
extension reinit wave. Verify entry counts against a pre-change baseline
afterwards — the clone churn is the risk, not the ordering.
The global registry is THE manager of its surface. Order, visibility, and placement live in its central store and are applied by it — never by side tables or per-widget expressions. (ToolbarDef is retired for registered widgets; it lingers only for un-migrated legacy toolbar buttons.)
Manager API conventions (ToolbarRegistry reference):
SetWidgetOrder(name, order), SetWidgetDisplay(name, visible),
Widgets (snapshot property). Surface artifacts the registry creates are
tagged and name-prefixed (tbmirror_* + ToolbarRegistryMirror tag) so
pruning touches only what the registry owns. Cross-references resolve
through the registry (op.FNS_TOOLBARREGISTRY.WidgetTarget('Name') as a
parameter expression) rather than hard paths — entries heal, references
follow.
Surfaces must degrade gracefully: ToolbarRegistry injects into TD's stock bookmark bar whether or not FNS_Toolbar is present, so a tool tox dropped into a bare project still self-installs its button.
Surface-specific anchoring matters: bookmark-bar widgets must have their
panel input wired to the bar's emptypanel (_anchorMirror, mirroring the
original FNS_Toolbar installer) or they drop out of the bar's layout flow.
When adopting a surface, read its original installer for wiring like this —
copy/create alone is rarely the whole contract.
FNS_CommandRegistry breaks the pattern on purpose, and an auditor will
flag it every time unless this paragraph is read first: it has no master
under /FNSTools and no Version parameter — it exists only as the
/sys copy, carrying just Rescan and Status.
That is because it is a pure aggregator, not a publisher target. Every
other registry holds a central store that stamped hosts publish into; the
command registry holds nothing — it discovers fnscommands-tagged tools
(42 at last count) by scanning, so there is no entry data to own, no host
to stamp, and no state whose version could drift. The costs are accepted:
it cannot be version-checked and cannot ship through the update rail as a
package. This is an exception for this one registry, not a statement
about registries — the ten versioned registries are ordinary packages
and update standalone through the rail (subject to the version-arbitration
caveat in §2: an update only takes over the /sys global when its version
is bumped past the incumbent's — arbitration reads Pkgversion first,
falling back to Version only for components that predate it; the two are
never maintained as separate numbers).
Its code does not even live on the FNS side: the /sys/FNS_CommandRegistry
copy is promoted from TDXLauncherUtility (TDXL), so updating the
registry's behaviour means shipping a new TDXL launcher. What FNS ships is
FNS_CommandKit — the drop-in publisher kit — which is itself a normal,
rail-updatable package. If the registry ever grows owned state (an entry
store, per-command overrides), that is the moment it must be brought into
the full pattern — master, Pkgversion, stamped hosts — not patched
around.
- Master vs copies — in-project hosts are CLONES. Every tool host's
clonepar is an EXPRESSION on the stable global shortcut —op.FNS_TOOLBAR.op('ToolbarRegistry') if hasattr(op, 'FNS_TOOLBAR') else None— never a relative path (paths bake in hierarchy assumptions and break on re-parenting; note par OP paths resolve from the PARENT network, sibling-based). The guard makes cloning vanish silently where the toolbar package is absent. Master edits propagate live; host-specific Registration par VALUES are untouched by cloning. Two hazards paid for: cloning breaksme.dockreferences inside the clone (the ExtUtilsextParameterPages expr was rewritten dock-free), and a clone re-sync transiently rebuilds children. The/sysglobal stays unclonled (it is disposable).RegistryBase.pyis ONE shared dev file (scripts/shared/RegistryBase.py): every master'sRegistryBaseDAT points at it via plain TDfile+syncfile, and hosts +/syscopies follow automatically through clone sync / promotion -- a base fix edits one file and hot-propagates to every registry. Releases stay standalone because release flows strip external file references and ship the text embedded. The base DATs carry NO Embody tag or tsv row (an Embody-tracked DAT identity on a much-copied module is how the tracker once adopted a stray copy and dragged files away). PaneTypeRegistry additionally distributes via the TD Palette, and is tracked exactly like the others: api_suspectwith its ownmodules/suspects/PreviewPanel25/PaneTypeRegistry.toxplus apre_releasehook. No unbind-save-rebind dance (the older rule) — the dev copy KEEPS its file bindings so edits hot-reload, andpre_releasestrips file/syncfile + tracker tags off every bound DAT at release time, so only the shipped artifact is standalone. That is exactly what the hook is for; a save is justcomp.save(externaltox). - Release scrubbing of clones: shipped copies must NOT carry the clone
par. The registry's own
pre_releasescrubs it for standalone releases; every HOSTING TOOL'spre_releasecarries an auto-added scrub block for nested hosts (nested components' own hooks do NOT run when a parent is released — by Embody, and releases also go through Private Investigator, which supports the samepre_releaseconvention). Belt-and-suspenders: the global's healing tick re-asserts host cloning in-project (_healHostClones), so even a live-comp scrub self-repairs. - Release artifact:
op.Embody.ExportPortableTox(target, save_path)→modules/release/<Name>.tox. The component'spre_releasehook runs on the staged copy and scrubs all host state (Registration pars,PaneRegistry/HostCanonicalstorage,opshortcut) so releases ship inert: first load installs/upgrades the/sysglobal, registers nothing. Because masters are Embody-tracked AND hook-bearing,op.Embody.ReleaseAllrebuilds them automatically. - A tool that publishes ships: its widget/panel + a configured registry
host copy (
Comp,Canonicalname,Autoregister=on). SeeVSCodeTools/ToolbarRegistryfor the canonical example. - The host lives INSIDE the component it registers (
Comp='..'), for chrome exactly like for tools:widgets/gridshow/ToolbarRegistry,containers/parent_hierarchy/NavbarRegistry, drop-to-register stamps -- one shape everywhere, and any single widget dragged into a foreign project self-registers. Requires the component to COOK (a host inside a cook-disabled subtree cannot compile its extension -- un-park sources, guard anypanelParent()sizing exprs with... if me.panelParent() else N). - Every registered tool gets a standardized
Registrycustom page, created PROGRAMMATICALLY by the host on each successful registration (RegistryBase._ensureToolRegistryPage): key Registration pars mirrored on the parent tool (Auto-Register, Register, Status, Order, Displayed + per-surface extras like the navbar's Side or the toolbar's Bar Width). The TOOL pars are the bind MASTERS -- they hold and persist the values with the tool -- and the host's Registration pars BIND to them (new tool pars are seeded from the host's current values before the host is bound, so nothing snaps to defaults). Par names carry a per-registry prefix (Tb/Nb/Pt) so a tool with multiple registries (CustomParPromoter has toolbar + navbar) shares the one page without collisions. The page is kept ORDERED ahead of the meta pages (sortCustomPages: tool pages first, then Registry, then About / Common / Version Ctrl). Promotion is OPT-OUT per host: the Registration page'sPromoteparstoggle (default on) -- turn it off before shipping a tool that should not expose registry controls; the section withdraws and the host pars fall back to constants with their values intact. The fleet self-standardizes -- no per-tool work, and drop-to-register packages inherit the page automatically. Two framework hazards paid for here: clone sync initializes a NEWLY-added master par to the TYPE default (not the master's value) -- sweep the fleet after appending pars to a master; and ExtUtils' callback exec DATs resolved CustomParHelper viamod(me.dock.name), which cloning breaks -- par callbacks were silently dead on every clone host until all ExtUtils exprs AND DAT texts were rewritten dock-free (mod('CustomParHelper')). Hardening paid for once:onDestroyTDALSO fires on extension REINIT, so the page is only removed when the host COMP is genuinely being destroyed (not ownerComp.valid); and_repairDanglingHostBindsruns BEFORE CustomParHelper touches the pars at init, falling dangling BINDs back to CONSTANT (detected viabindMaster is None-- a dangling PULSE bind does not raise on eval). - Management UI is the HUB (2026-08-23). The per-surface Configurators
(Toolbar / Navbar / MainMenu) are TABS of
FNSTools/FNS_Hub, the core package behind the FNS main-menu button; the per-bar gear buttons are gone. Each configurator still owns itsstatetable (roamed by the hub's oneconfig_callbacks, keyed by configurator name) and its lister UI, and sizes itself to the hub's tab area (w/hexpressions onparent.Hub.op('panel/tabs')-- a Select mirror draws its source at the source's own size, so a fixed-size source stays small, andfillwith no panel parent collapses to nothing). The bootstrap role the gear host used to play is moot: promotion runs from ANY host at init and the registry masters are core at the root. Drop-to-register is the hub's: drop a panel COMP on the FNS button (its main-menu Select mirror forwards drops) or on the hub window;HubExt.RouteDropdefers a frame, offers every tab whose component implementsAcceptsDrop/PackageDropin a popMenu (a single target skips the menu), and the chosen configurator's_stampPackagestamps the registry MASTER's host throughStampHost(comp, canonical_name=comp.name, par_values={'Menuorder': max+1, ...}). The configurators carry no template host of their own any more -- they did, dormant, and addressed it by its pre-v3 name (op('ToolbarRegistry')), so drop-to-register had been silently dead since theFNS_rename. Everything the hub renders comes fromFNS_HubRegistry; the hub itself holds no tab knowledge (see HubContract.md). - Entries carry the tool's wiki page (
help_url, 0.6.0). On registration the host resolves it from itsHelpurlpar when set, else auto-discovers from the registered panel or its parent: adocsHelperCOMP (itsUrlpar), or aUrl/Helpurl/Wikipagecustom par on the panel itself -- both pre-registry self-reporting conventions keep working with zero tool changes.RegisterWidgetacceptshelp_url=;OpenDocs(canonical)opens it (ui.viewFile). Configurator surface: right-click the Name cell. With host cloning, anything inside the registry replicates into every host and every tool's tox -- so the registry ships NO widgets at all, and no management UI: the editor is the hub's tab.op.FNS_TOOLBARREGISTRY.OpenConfigurator()remains as a convenience API, resolving the editor via itsTOOLBARCONFIGglobal shortcut (nowFNSTools/FNS_Hub/ToolbarConfigurator), whoseOpen()opens the hub on the Toolbar tab.
Both moved to /fns-registry (the skill), so they load when someone is about
to build or copy one rather than when someone is reading the model. The skill
carries the full hazard list (CustomParHelper EXT_SELF, externaltox
inheritance, the clone-host copy crash, root-tox carriage, Execute DAT toggle
gating, …) and the eight-step new-registry checklist.
What the toolbar port replaced: a monolith whose installer copied REAL
button COMPs into /ui/dialogs/bookmark_bar on every launch, a
ToolbarDef table owning order/visibility, widget-based dividers, a
chrome-publisher script, and per-button self-reported wiki URLs. The
registry scheme replaces all of it with host publishers + /sys manager +
mirrors. The order below is the one that worked; the lessons were paid for.
- Extract the base from the most mature LIVE registry (RegistryBase came from the working PaneTypeRegistry, not from a blank page), then build the new surface registry on it.
- Prove the contract on ONE pilot tool (drop the package into a bare project, watch it self-install) before touching the fleet.
- Retire the definition table only when the registry owns order/visibility — never run two managers over the same surface.
- Fleet rollout, one tool at a time: copy the master host in, clone
par as an EXPRESSION (never a relative path), set Registration pars
(panel = sibling name),
Autoregisteron, deferred force-apply. Tools stay standalone packages — do NOT fold them into the surface package. - Chrome pieces get real hosts too. A publisher script has no pars to write back to — every entry needs a live host or persistence breaks for it.
- Dividers become virtual entries owned by the manager UI's state table — widgets that exist only to be gaps should not be operators.
- Metadata rides discovery, not migration. For self-reported data
(help/wiki URLs) teach
_hostHelpUrl-style discovery to read the legacy conventions in place instead of copying values into the new scheme — copies go stale the moment the source is edited. Survey first: the field had TWO conventions (docsHelperchild withUrlpar, and bareUrl/Wikipagepars on the button itself). - Parity-audit against the legacy artifact before retiring it. Load the old tox in a scratch spot, build the legacy name→value map, diff byte-for-byte against the registry entries (toolbar port scored 20/20 on URLs only because two discovery conventions were implemented).
- Only then delete legacy remains: the bar's installed button copies, definition-table relics, and the legacy comp itself.
-
The legacy installer re-installs on EVERY launch. Loading the old tox anywhere — even "just to check" — repopulates the bar with real buttons next to the mirrors. After ANY contact with the legacy comp, audit
emptypanel's output connections, and never let the comp survive into a project save. -
The legacy .toe carries zombie shells (Embody save-strip leftovers) that reinfect the bar after restarts — hunt
*1-suffixed shells and stale externalization tags inside the legacy container too, not just in the bar. -
Mixed-era menu orders drift the sequence. Legacy 1-based table orders meeting normalized sequence indices shift dividers by one on every republish. After the whole fleet publishes, renormalize ONCE with
SetWidgetSequence— write-back then stamps consistent contiguous orders into every host par and state row, and the drift cannot recur. -
Never strip user-tooling tags (
pi_suspect,FNS_externalized) in legacy-cleanup sweeps; they belong to Private Investigator and Embody, not to the old toolbar. -
External-tox reload trumps the .toe on boot. The surface package reloads from its suspects
.toxon start, so a stale tox silently reverts live-only migration work after a crash or restart — re-save the suspects tox as part of EVERY landing, not just the .toe. -
ENUMERATE nested suspects before saving — never assume you know them. A
pi_suspectat ANY depth is stored in its parent's tox as a REFERENCE STUB, so the parent's tox cannot carry that child's content. Saving the tool and forgetting a nested one loses everything added inside it at the next boot. Proven the hard way: an unplanned restart revertedFNS_OpMenu/IOFilter(its own suspect, tox 20 hours stale) and took its registry host, callbacks DAT and parexec with it — while the siblingOpMenuRegistry.tox, which HAD been saved, survived intact. The FNS_OpMenu + OpTemplates scope has SEVEN nested suspects; a "save the toxes I edited" habit covered four. Walk them and save DEEPEST-FIRST:def nested_suspects(root_path): r = op(root_path) found = [o for o in [r] + r.findChildren() if getattr(o.par, 'externaltox', None) and o.par.externaltox.eval() and 'pi_suspect' in o.tags] return sorted(found, key=lambda o: o.path.count('/'), reverse=True)
Only the
.toxreverts — externalized.pyfiles have their own file bindings and survive, which is what makes recovery cheap: recreate the operators, rebind them to the surviving files, re-register. -
Then save the ROOT tox, every landing. The sweep above finds only suspects whose
externaltoxis ON. Three tools (QuickMarks,QuickParCustom,paste_from_clipboard) haveenableexternaltox=False— their own.toxfiles are dead at boot and their content is CARRIED BYmodules/suspects/FunctionStore_tools_2025.tox. So a landing is: nested suspects deepest-first, then the root tox, thensave_project. (FNS_PaneTypeRegistrymoved under/FNSToolswith the FNS_PreviewPanel package on 2026-09-02, so there is no second sweep root.) -
Same-frame verification lies. Connector lists and par-callback effects read stale in the frame that mutated them — verify a destroy or re-anchor only after real frames pass.
-
Keep the legacy artifact until the parity audit passes. It is the only authoritative record of per-tool metadata (URLs, orders, widths); delete it only after the diff comes back clean.