You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
The ring shows ZBrush's state: lit toggles, current slider values, dimmed slots #188
Today the ring sends picks and reads nothing back. As the ring opens, the app would ask the plugin for a small snapshot and draw it: a Toggle slot lit when its switch is on, a Set slot showing the slider's current value, a slot dimmed when ZBrush would ignore it right now (everything under Tool:Masking with no mesh in Edit mode, which today fails with "no such interface item" after the pick), and the active tool's name in the hub.
The plugin has the calls for all of it (get, get_status, exists, get_active_tool_path). The Python API has no event or timer hook, so the snapshot is asked for when the ring opens, with a short timeout, and the last one is used when ZBrush is busy. ZBrush 2022 to 2025 can only answer when Apply runs, so there the ring would show the state as of the last pick.
A subtools fan and menus that change with the tool or with Transpose would build on the same channel.
Measured in ZBrush 2027.0 on 6 Nov 2026, with a read-only probe loaded through ZScript > Python Scripting > Load:
Reads are cheap: about 900 calls (get, get_status, get_title, get_hotkey, get_min, get_max and the rest over 48 paths) took 11 ms in all. A snapshot of a ring's items on open costs nothing noticeable.
The current brush is readable: get_title("Brush:Current Brush") gives the brush's .zbp path, get_title("Brush:ItemInfo") gives Standard., and get("Brush:Standard") is 1.0 while it is selected. The same works for Tool:ItemInfo and Material:ItemInfo.
get_status and is_enabled tell a greyed item apart: with a 2.5D tool on the canvas, Transform:Edit exists but is disabled, and everything under Tool:Masking and Tool:Geometry does not exist, as the docs say.
get_hotkey returns the full combination (CTRL+SHIFT+z, CTRL+ALT+c), not the one-modifier form its docstring describes.
A Python thread started by the plugin does not run while ZBrush is idle: it wrote one tick and never woke again. So the snapshot has to be taken on ZBrush's main thread, from the listener's window message, as the bridge already does for picks. There is no event or timer hook in the API, and the zbrush.auto_zcmds name found in the exe is not an importable module.
Worked out 7 Nov 2026 from the measurements above. Decisions taken from the open questions at the end: the switch defaults to on; a dimmed slot can still be picked; the tool name on the hub waits for #193; a pip on the current brush's slot is a follow-up.
1. Protocol
Two new window messages:
WM_APP_STATE = WM_APP+2, app to the plugin's listener. wparam is the query id, lparam the app's control window.
WM_APP_STATE_READY = WM_APP+19, plugin to the app's control window. wparam is the snapshot id. +19 is the free number in radiald's 10 to 23 range.
The plugin posts the reply only when GetClassNameW(lparam) is ZOrbitControl, so it never posts to a foreign window.
The app writes %LOCALAPPDATA%\ZOrbit\zstate_query.json atomically (tmp file, then os.replace, as request.json is written):
s is ok, disabled or missing, from exists and is_enabled; error when a read raised, drawn neutral.
v only for set and toggle; min and max only for set.
Keys are the exact query strings, so the app never normalises paths.
Tool and brush names drop ZBrush's trailing dot (get_title gives SimpleBrush.).
pick is the id of the last request the plugin applied before this snapshot.
The app accepts a snapshot when its id is the newest asked (or it is the refresh after a pick), its pid is the ZBrush the query went to, and v == 1. The plugin answers the id it finds in the file, not the wparam, so a newer query that replaced the file is the one answered.
_publish_listener adds "state": 1 to listener.json. The app asks only a listener that publishes it, so an old plugin is never asked.
No waiting, one question in flight.ask() runs after ring.show(), so the ring is on screen before the query is written. While a question has no answer, a new ring only rewrites the query file and posts nothing; if ZBrush is busy, the one queued message answers the newest query when it is free. After 2 s without an answer the question counts as lost: the next open posts again and radiald.log gets one line per session. The ring never waits.
Mid-stroke. The handler does not check primary_down(): reads change nothing, so they are not held back the way picks are. (Risk 1 below.)
Cache (zstate.Cache): entries merged across replies, one per path with its time. An entry is drawn only when it is from the same pid and session as the ZBrush in front, under 30 s old, and not older than the last pick: send() calls zstate.picked(seq), and a snapshot with pick >= seq makes the cache drawable again. After apply_request the plugin answers its last query again, unasked, so the cache is current before the next open. A restarted ZBrush changes pid and session and drops the cache.
2. What is queried
zstate.paths_for(menu, resolve) collects, in order: the menu's own press, set and toggle items; the items of fan submenus down to depth 3 (fans open inside the same ring and never ask on their own); the items of ring submenus one level down (they ask again when they open, this only warms their first frame). Paths are deduplicated, merging kinds. steps, asset, capture, file and tag items are not queried, and a steps item is never dimmed.
Extras on every snapshot: Tool:ItemInfo, Brush:ItemInfo, get_title("Brush:Current Brush"), Material:ItemInfo, and Transform:Edit with its enabled state.
Cost cap: at most 128 paths; the plugin also stops after 20 ms and sets truncated. At about 4 reads per path and 10 us a read, 128 paths take about 5 ms.
3. Drawing
Overlays, not new slot pictures. ring.py keeps each slot's picture between opens; the state does not touch those pictures or their cache key. The ring takes marks=callable, where marks(menu) gives a zstate.Mark(lit, value_text, dim) or None per item, and Ring.refresh_marks() re-runs it for the ring and its open fans. When a slot's mark changes, the ring copies its idle and hover pictures with the overlay drawn in (under 0.1 ms a slot, kept per slot key and mark), clears that slot's blend cache and redraws on the next tick. Geometry, hit testing and animation are unchanged.
Toggle. A pip (0.13 of the slot width) at the upper right, the badge corner mirrored upwards; toggles are never submenus, so it never meets the submenu badge. On: filled in the slot's hover colour (its glow_color, else the accent) with a rim in the background colour, and the slot gets an outline in that colour, max(2, border_width) wide. Off: a hollow pip in the text colour at 35 %. No snapshot: no pip. Unknown, off and on all look different.
Set. A value chip inside the disc, centred at 0.55 radius below the centre, drawn like the label pill (text colour, 9 px by scale, bold). The label under the slot does not change. A Set slot whose target equals the current value (within half the last digit shown) gets the filled pip too. When one menu has several Set slots for one slider (a row of presets), no chip is shown and only the matching slot lights.
zstate.format_value(v, lo, hi, target): span 100 or more gives no decimals, 10 or more one, 1 or more two, else three; at least as many decimals as the slot's own value; trailing zeros removed; 10000 or more written 12.5k; at most 5 characters; with no range, at most 2 decimals.
Dimmed. Missing and disabled look the same: slot and label at 40 % opacity, 70 % under the cursor, as an alpha factor to _draw_disc and _draw_label. ZBrush reports items as missing in some contexts (Tool:Masking with a 2.5D tool), so missing does not mean a typo; radiald.log notes each missing path once per session. A dimmed slot can still be picked: the snapshot can be 30 s old and a wrong snapshot must never break a menu. Picking an item that really is gone gives the existing message.
Empty, auto, tag and asset slots never get a mark. Tag colours, looks and shapes are untouched. The overlay is in both the idle and the hover picture, so hover, scaling and fans behave as now.
4. Stale or missing state
With no plugin, an old plugin, ZBrush 2022 to 2025, the setting off, or no listener for the ZBrush in front, nothing is asked and the ring is exactly as now. ZBrush busy: the cached marks if drawable, else a neutral ring, updated in place if the answer comes while the ring is open. Right after a pick: neutral until the refresh arrives, usually before the next open. Never a spinner or a stale marker.
5. ZBrush 2022 to 2025
Left out of the first version. The plugin runs only on Apply, the moment the state changes, so changes made in ZBrush's own interface would never be seen and a toggle would often be wrong, which is worse than nothing. It would also need a second, line-based format in the Windows code page with 255-character lines. The v field and the Snapshot model leave room for a "source": "zscript" writer later.
6. Studio
A new switch "bridge": {"show_state": true}, default on, per profile: Show ZBrush's state on the ring under Settings > ZBrush bridge (Full mode). Off means nothing is asked. The preview stays neutral: Studio passes no marks. Ring.preview(..., marks=None) serves tests and tools/ring_shots.py, which gets a new state.png.
Docs: bridge.md (the channel, the version note, what Status shows), menus.md (Press, Set, Toggle), appearance.md (the picture), studio.md (the setting), files.md (the two files), troubleshooting.md (dimmed slots, slots that show no state), CHANGELOG.md, the README feature list.
7. Tests
New tests/test_zstate.py: the parser (valid; wrong v, id or pid; bad JSON; NaN, bools and strings as values; keys not asked; a file over 256 KB refused); paths_for (order, dedupe, fan depth, steps left out, the cap); the cache rules (pick, age, session); format_value from a table; the preset rule; Channel with PostMessageW monkeypatched (one in flight, no ask without the capability or with the setting off, a late or foreign reply ignored).
tests/test_bridge.py: the stub gains is_enabled, get_min, get_max, get_title, zbrush_info; the snapshot contents and statuses; an answer while primary_down is True; the time budget and truncated; a raising read gives error; a foreign hwnd refused; listener.json has state: 1; the refresh after apply_request.
tests/test_ring.py: marks=None gives a picture identical pixel for pixel to today's; pip lit and off; the chip; a dimmed slot's alpha; hover keeps the overlay; the slot-picture cache key unchanged and refresh_marks never calls _slot_sprites.
By hand in ZBrush 2026 or 2027: flip Symmetry in ZBrush and reopen the ring; change Draw Size; Masking items dimmed with a 2.5D tool; open the ring during a stroke and during a long Dynamesh (no delay); two instances; the timing line in radiald.log. In ZBrush 2022: the ring unchanged, no zstate*.json, no new log lines.
8. Phases
#
Work
Model
Cold review
1
Plugin: the WM_APP_STATE handler, _snapshot(), zstate.json, the reply, the capability flag, the refresh after a pick, the Status line, tests
ring.py: marks, refresh_marks, the overlays, dimming, preview(marks=), tests
Opus
Yes
4
radiald (about 20 lines): _listener_info, the constant, the _ctrl_proc dispatch, ask in open_menu, picked in send; config.py parses show_state
Opus
Yes
5
Studio switch, the docs, the CHANGELOG
Sonnet
Yes
6
state.png in ring_shots.py, retaken and checked
Haiku
No
Phases 1, 2 and 3 can run in parallel against this schema; 4 waits for all three; 5 and 6 follow.
9. Risks
Reads during a stroke, or no messages pumped during a stroke: measure in phase 1. If either fails, answer once the pen lifts and draw the cache meanwhile.
The answer arrives later than the open animation: measure the round trip. Under about 50 ms the marks land while the slots are still fading in.
Antivirus slowing the two small file writes: the ms field and the app's timing line will show it. WM_COPYDATA was rejected because sending it would block ZBrush on the app.
Toggles that are not plain on and off switches: lit means v != 0; check a few in ZBrush 2027.
The design above was written before #210 (ring-perf), which changes how the ring keeps its pictures. Read against that branch, the design stands. These are the points each phase needs to know.
The refresh after a pick has a gap. apply_request can leave items in S.select_pending, which TIMER_SELECT presses up to a second later. A refresh sent straight after apply_request then reports the brush from before the load in extras. Send the refresh once more when that queue empties. Toggles and sliders are not affected.
Stub in tests/test_bridge.py has exists, press, set, toggle and get. The rest is to add, as the design lists.
Phase 3, ring
self.sprites is the very list kept in _sprite_cache, shared by every open of that menu and by the warm-up. refresh_marks has to copy the list before it puts a marked picture in it, or the mark stays for the next open. fan.sprites is already a new list for each ring (_fan); self._art holds the cached tuples and is left alone.
Marked copies are kept on the ring, or in a small cache of their own by slot key and mark. Not in _slot_cache: its 160 entries are shared with the warm-up's budget.
Marks stay out of _sprite_key, _slot_key and _static_key. The static layer's key holds each slot's kind and rim colour only, and the toggle outline is drawn in the slot's picture, so a change of state never redraws the static layer.
Dimming: _draw_disc and _draw_label both take alpha. The factor goes in at their four call sites in _compose and _draw_fan.
Ring.preview now also takes open_path, held and open_age; marks=None joins them. Ring.for_warmup passes no marks: the warm-up draws neutral pictures and the marked copies are made on open.
Two more tests: after refresh_marks, the entries of _sprite_cache and _slot_cache are the same objects with the same pixels; and a second open of the menu without marks is identical to today's picture.
Phase 4, daemon
WM_APP+19 is still free (10 to 18 and 20 to 23 are taken).
The function that reads listener.json is _listener(pid) and returns only the window. _listener_info replaces it or sits beside it, and gives the state flag from the same read, behind the same pid and exe checks.
open_menu works on the expanded menu (self.expander.menu(menu)). paths_for takes that one, with self.expander.resolve.
The warm-up needs nothing: _warm_busy is already true while a ring is open or a pick has no answer, so no warm-up picture comes between a question and its answer while the ring shows.
BRIDGE_KEYS in config.py is ("mode", "key"). show_state has to join it, or the parser reports it as an unknown setting.
Phase 5, Studio
The card is card('ZBrush bridge', ...) in app.js, and its id set-zbrush-bridge is in FULL_CARDS in mode.js. The check beside it in mode.js, which names the card when the mode or the key is not the default, should count show_state: false too.
Still to measure in phase 1
Unchanged: reads during a stroke, and the round trip against the open animation.
Activity
combwizard commentedon Nov 6, 2026
Measured in ZBrush 2027.0 on 6 Nov 2026, with a read-only probe loaded through ZScript > Python Scripting > Load:
get,get_status,get_title,get_hotkey,get_min,get_maxand the rest over 48 paths) took 11 ms in all. A snapshot of a ring's items on open costs nothing noticeable.get_title("Brush:Current Brush")gives the brush's.zbppath,get_title("Brush:ItemInfo")givesStandard., andget("Brush:Standard")is 1.0 while it is selected. The same works forTool:ItemInfoandMaterial:ItemInfo.get_statusandis_enabledtell a greyed item apart: with a 2.5D tool on the canvas, Transform:Edit exists but is disabled, and everything under Tool:Masking and Tool:Geometry does not exist, as the docs say.get_hotkeyreturns the full combination (CTRL+SHIFT+z,CTRL+ALT+c), not the one-modifier form its docstring describes.zbrush.auto_zcmdsname found in the exe is not an importable module.combwizard commentedon Nov 7, 2026
Design
Worked out 7 Nov 2026 from the measurements above. Decisions taken from the open questions at the end: the switch defaults to on; a dimmed slot can still be picked; the tool name on the hub waits for #193; a pip on the current brush's slot is a follow-up.
1. Protocol
Two new window messages:
WM_APP_STATE = WM_APP+2, app to the plugin's listener.wparamis the query id,lparamthe app's control window.WM_APP_STATE_READY = WM_APP+19, plugin to the app's control window.wparamis the snapshot id. +19 is the free number in radiald's 10 to 23 range.The plugin posts the reply only when
GetClassNameW(lparam)isZOrbitControl, so it never posts to a foreign window.The app writes
%LOCALAPPDATA%\ZOrbit\zstate_query.jsonatomically (tmp file, thenos.replace, asrequest.jsonis written):{"v": 1, "id": 42, "time": 1762430000.1, "paths": [{"p": "Transform:Activate Symmetry", "k": "toggle"}, {"p": "Draw:Draw Size", "k": "set"}, {"p": "Tool:Masking:Clear", "k": "press"}]}The plugin writes
%LOCALAPPDATA%\ZOrbit\zstate.jsonatomically:{"v": 1, "id": 42, "pick": 17, "pid": 9120, "session": "429428", "time": 1762430000.13, "ms": 1.9, "truncated": false, "extras": {"tool": "SimpleBrush", "brush": "Standard", "brush_file": "C:/.../Standard.zbp", "material": "MatCap Red Wax", "edit": true, "edit_enabled": true}, "items": {"Transform:Activate Symmetry": {"s": "ok", "v": 1.0}, "Draw:Draw Size": {"s": "ok", "v": 64.0, "min": 1.0, "max": 1000.0}, "Tool:Masking:Clear": {"s": "missing"}}}sisok,disabledormissing, fromexistsandis_enabled;errorwhen a read raised, drawn neutral.vonly forsetandtoggle;minandmaxonly forset.get_titlegivesSimpleBrush.).pickis the id of the last request the plugin applied before this snapshot.The app accepts a snapshot when its
idis the newest asked (or it is the refresh after a pick), itspidis the ZBrush the query went to, andv == 1. The plugin answers the id it finds in the file, not thewparam, so a newer query that replaced the file is the one answered._publish_listeneradds"state": 1tolistener.json. The app asks only a listener that publishes it, so an old plugin is never asked.No waiting, one question in flight.
ask()runs afterring.show(), so the ring is on screen before the query is written. While a question has no answer, a new ring only rewrites the query file and posts nothing; if ZBrush is busy, the one queued message answers the newest query when it is free. After 2 s without an answer the question counts as lost: the next open posts again andradiald.loggets one line per session. The ring never waits.Mid-stroke. The handler does not check
primary_down(): reads change nothing, so they are not held back the way picks are. (Risk 1 below.)Cache (
zstate.Cache): entries merged across replies, one per path with its time. An entry is drawn only when it is from the samepidandsessionas the ZBrush in front, under 30 s old, and not older than the last pick:send()callszstate.picked(seq), and a snapshot withpick >= seqmakes the cache drawable again. Afterapply_requestthe plugin answers its last query again, unasked, so the cache is current before the next open. A restarted ZBrush changespidandsessionand drops the cache.2. What is queried
zstate.paths_for(menu, resolve)collects, in order: the menu's ownpress,setandtoggleitems; the items of fan submenus down to depth 3 (fans open inside the same ring and never ask on their own); the items of ring submenus one level down (they ask again when they open, this only warms their first frame). Paths are deduplicated, merging kinds.steps, asset, capture, file and tag items are not queried, and a steps item is never dimmed.Extras on every snapshot:
Tool:ItemInfo,Brush:ItemInfo,get_title("Brush:Current Brush"),Material:ItemInfo, andTransform:Editwith its enabled state.Cost cap: at most 128 paths; the plugin also stops after 20 ms and sets
truncated. At about 4 reads per path and 10 us a read, 128 paths take about 5 ms.3. Drawing
Overlays, not new slot pictures.
ring.pykeeps each slot's picture between opens; the state does not touch those pictures or their cache key. The ring takesmarks=callable, wheremarks(menu)gives azstate.Mark(lit, value_text, dim)or None per item, andRing.refresh_marks()re-runs it for the ring and its open fans. When a slot's mark changes, the ring copies its idle and hover pictures with the overlay drawn in (under 0.1 ms a slot, kept per slot key and mark), clears that slot's blend cache and redraws on the next tick. Geometry, hit testing and animation are unchanged.Toggle. A pip (0.13 of the slot width) at the upper right, the badge corner mirrored upwards; toggles are never submenus, so it never meets the submenu badge. On: filled in the slot's hover colour (its
glow_color, else the accent) with a rim in the background colour, and the slot gets an outline in that colour,max(2, border_width)wide. Off: a hollow pip in the text colour at 35 %. No snapshot: no pip. Unknown, off and on all look different.Set. A value chip inside the disc, centred at 0.55 radius below the centre, drawn like the label pill (text colour, 9 px by scale, bold). The label under the slot does not change. A Set slot whose target equals the current value (within half the last digit shown) gets the filled pip too. When one menu has several Set slots for one slider (a row of presets), no chip is shown and only the matching slot lights.
zstate.format_value(v, lo, hi, target): span 100 or more gives no decimals, 10 or more one, 1 or more two, else three; at least as many decimals as the slot's own value; trailing zeros removed; 10000 or more written12.5k; at most 5 characters; with no range, at most 2 decimals.Dimmed. Missing and disabled look the same: slot and label at 40 % opacity, 70 % under the cursor, as an alpha factor to
_draw_discand_draw_label. ZBrush reports items as missing in some contexts (Tool:Masking with a 2.5D tool), so missing does not mean a typo;radiald.lognotes each missing path once per session. A dimmed slot can still be picked: the snapshot can be 30 s old and a wrong snapshot must never break a menu. Picking an item that really is gone gives the existing message.Empty, auto, tag and asset slots never get a mark. Tag colours, looks and shapes are untouched. The overlay is in both the idle and the hover picture, so hover, scaling and fans behave as now.
4. Stale or missing state
With no plugin, an old plugin, ZBrush 2022 to 2025, the setting off, or no listener for the ZBrush in front, nothing is asked and the ring is exactly as now. ZBrush busy: the cached marks if drawable, else a neutral ring, updated in place if the answer comes while the ring is open. Right after a pick: neutral until the refresh arrives, usually before the next open. Never a spinner or a stale marker.
5. ZBrush 2022 to 2025
Left out of the first version. The plugin runs only on Apply, the moment the state changes, so changes made in ZBrush's own interface would never be seen and a toggle would often be wrong, which is worse than nothing. It would also need a second, line-based format in the Windows code page with 255-character lines. The
vfield and the Snapshot model leave room for a"source": "zscript"writer later.6. Studio
A new switch
"bridge": {"show_state": true}, default on, per profile: Show ZBrush's state on the ring under Settings > ZBrush bridge (Full mode). Off means nothing is asked. The preview stays neutral: Studio passes nomarks.Ring.preview(..., marks=None)serves tests andtools/ring_shots.py, which gets a newstate.png.Docs:
bridge.md(the channel, the version note, what Status shows),menus.md(Press, Set, Toggle),appearance.md(the picture),studio.md(the setting),files.md(the two files),troubleshooting.md(dimmed slots, slots that show no state),CHANGELOG.md, the README feature list.7. Tests
tests/test_zstate.py: the parser (valid; wrongv,idorpid; bad JSON; NaN, bools and strings as values; keys not asked; a file over 256 KB refused);paths_for(order, dedupe, fan depth, steps left out, the cap); the cache rules (pick, age, session);format_valuefrom a table; the preset rule;ChannelwithPostMessageWmonkeypatched (one in flight, no ask without the capability or with the setting off, a late or foreign reply ignored).tests/test_bridge.py: the stub gainsis_enabled,get_min,get_max,get_title,zbrush_info; the snapshot contents and statuses; an answer whileprimary_downis True; the time budget andtruncated; a raising read giveserror; a foreign hwnd refused;listener.jsonhasstate: 1; the refresh afterapply_request.tests/test_ring.py:marks=Nonegives a picture identical pixel for pixel to today's; pip lit and off; the chip; a dimmed slot's alpha; hover keeps the overlay; the slot-picture cache key unchanged andrefresh_marksnever calls_slot_sprites.radiald.log. In ZBrush 2022: the ring unchanged, nozstate*.json, no new log lines.8. Phases
WM_APP_STATEhandler,_snapshot(),zstate.json, the reply, the capability flag, the refresh after a pick, the Status line, testszorbit/zstate.py: Query, Snapshot, Cache, Mark,paths_for,marks_for,format_value, Channel, testsring.py:marks,refresh_marks, the overlays, dimming,preview(marks=), tests_listener_info, the constant, the_ctrl_procdispatch,askinopen_menu,pickedinsend;config.pyparsesshow_statestate.pnginring_shots.py, retaken and checkedPhases 1, 2 and 3 can run in parallel against this schema; 4 waits for all three; 5 and 6 follow.
9. Risks
msfield and the app's timing line will show it.WM_COPYDATAwas rejected because sending it would block ZBrush on the app.v != 0; check a few in ZBrush 2027.combwizard commentedon Nov 13, 2026
Checked against the code, 13 Nov 2026
The design above was written before #210 (
ring-perf), which changes how the ring keeps its pictures. Read against that branch, the design stands. These are the points each phase needs to know.Order
zstate.py) touch no file The ring opens faster: pictures kept between opens and drawn ahead of the first #210 touches (it changesring.py,radiald.py,config.py,expand.pyandwin32.py). They can start frommainnow.Phase 1, plugin
WM_APP+2is free in the plugin: it hasWM_APP_PICK = +1and three timers._publish_listenerwriteshwnd,pid,threadandverified;"state": 1goes beside them.apply_requestcan leave items inS.select_pending, whichTIMER_SELECTpresses up to a second later. A refresh sent straight afterapply_requestthen reports the brush from before the load inextras. Send the refresh once more when that queue empties. Toggles and sliders are not affected.Stubintests/test_bridge.pyhasexists,press,set,toggleandget. The rest is to add, as the design lists.Phase 3, ring
self.spritesis the very list kept in_sprite_cache, shared by every open of that menu and by the warm-up.refresh_markshas to copy the list before it puts a marked picture in it, or the mark stays for the next open.fan.spritesis already a new list for each ring (_fan);self._artholds the cached tuples and is left alone._slot_cache: its 160 entries are shared with the warm-up's budget._resized(new in The ring opens faster: pictures kept between opens and drawn ahead of the first #210) checks the picture by identity, so a swapped slot picture needs no clearing there._blend_cachehas no such check and is cleared for that slot, as the design says._sprite_key,_slot_keyand_static_key. The static layer's key holds each slot's kind and rim colour only, and the toggle outline is drawn in the slot's picture, so a change of state never redraws the static layer._draw_discand_draw_labelboth takealpha. The factor goes in at their four call sites in_composeand_draw_fan.Ring.previewnow also takesopen_path,heldandopen_age;marks=Nonejoins them.Ring.for_warmuppasses no marks: the warm-up draws neutral pictures and the marked copies are made on open.refresh_marks, the entries of_sprite_cacheand_slot_cacheare the same objects with the same pixels; and a second open of the menu without marks is identical to today's picture.Phase 4, daemon
WM_APP+19is still free (10 to 18 and 20 to 23 are taken).listener.jsonis_listener(pid)and returns only the window._listener_inforeplaces it or sits beside it, and gives thestateflag from the same read, behind the same pid and exe checks.open_menuworks on the expanded menu (self.expander.menu(menu)).paths_fortakes that one, withself.expander.resolve._warm_busyis already true while a ring is open or a pick has no answer, so no warm-up picture comes between a question and its answer while the ring shows.BRIDGE_KEYSinconfig.pyis("mode", "key").show_statehas to join it, or the parser reports it as an unknown setting.Phase 5, Studio
card('ZBrush bridge', ...)inapp.js, and its idset-zbrush-bridgeis inFULL_CARDSinmode.js. The check beside it inmode.js, which names the card when the mode or the key is not the default, should countshow_state: falsetoo.Still to measure in phase 1
Unchanged: reads during a stroke, and the round trip against the open animation.