Phase 1: Reuse survey and shared draw dispatch¶
Master plan: PLAN-app-split.md.
Planning effort¶
Planned at medium effort; the survey was the expensive part and is recorded below. Step 1b should be reviewed at high effort: it touches the GUI's hot path, and a texture that stops refreshing is easy to miss in tests and obvious to a user.
Scope¶
In:
- Make
SurfaceMirror::apply_eventthe single draw-op dispatch for all three modes, by having it report what it did, so the GUI can hang its GUI-only reactions (auto-fit, resolution notification, frame statistics) off that report instead of re-implementing the dispatch. - Replace the GUI's
HashMap<(u8, u32), GuiSurface>with aSurfaceMirrorplus a separate egui texture cache. - Answer the master plan's open questions 1 and 2.
Out:
- Any other movement of code out of
app.rs(phases 2-4). - The
frames_receiveddivergence between GUI and headless (see finding 4): recorded as an issue, not fixed here.
What the survey found¶
- The draw dispatch is duplicated, and the copies have already
diverged.
SurfaceMirror::apply_event(shakenfist-spice-renderer/src/surface_mirror.rs) and the display arms ofRyllApp::process_events(ryll/src/app.rs) handle the same eight events --SurfaceCreated,SurfaceDestroyed,ImageReady,ImageReadyChroma,ImageReadyAlpha,FillRect,CopyBits,Invert-- with the sameDisplaySurfacecalls. The mirror serves web mode and the headless control socket (ryll/src/main.rs, theSurfaceMirror::new()passed to the session, andshakenfist-spice-renderer/src/session.rs, which feeds every event throughapply_event), so headless and web already share one dispatch and the GUI is the odd one out. - A bug the divergence caused. The
ImageReadyauto-create path computes the new surface's size asleft + widthandtop + heightin the GUI, but withsaturating_addin the mirror.[profile.dev]in the rootCargo.tomlleaves overflow checks on, so a server sending anImageReadyfor an unknown surface withleft + width > u32::MAXpanics a debug GUI build; a release build wraps to a tiny surface instead. Sharing the dispatch fixes it by construction. Recorded under Bugs fixed during this work in the master plan. - The GUI-only reactions are small and separable. Beyond the
shared calls, the GUI arms do four things: log creation,
destruction, auto-creation and draws to unknown surfaces; queue
an auto-fit resize and a resolution notification when the
primary surface
(0, 0)is created or auto-created (is_primary_surface,auto_fit_size_acceptable); and countstats.frames_receivedfor every draw that lands on a known surface. All four can be driven by a report of whatapply_eventdid. frames_receivedmeans different things per mode. The GUI counts all six draw events that land; headless (HeadlessStatsinsession.rs) countsImageReadyonly. Out of scope here; an issue is filed in step 1c.GuiSurfaceis only a texture cache.ryll/src/display_gui.rswraps aDisplaySurfaceto add a lazily allocatedTextureHandlerefreshed viaconsume_dirty(). The GUI owns its own surfaces, so nothing else consumes their dirty bits, and the cache can be keyed by(display_channel_id, surface_id)beside a mirror rather than wrapping each surface.- Open question 2: the reconnect state machine stays in ryll.
docs/multi-mode-parity.mdrecords SPICE reconnect as GUI-only: headless exits on main-channel disconnect, and web mode reconnects only the WebRTC peer while holding the SPICE session. No other mode needsReconnectState, so phase 2 moves it toryll/src/app/reconnect.rsas planned. - No other duplication worth sharing.
HeadlessStatsand the GUI'sStatisticsoverlap in three counters, but the GUI's carries FPS and latency state headless does not track; the resize helpers,NotificationSnapshotStore, and the bug-report flow are GUI-only. Nothing else inapp.rshas a second copy.
The master plan's claims about phase 1 held. Its open questions 1 and 2 are answered by decisions 1 and 5 below; this commit updates the master plan's text to say so.
Decisions¶
- The GUI holds a
SurfaceMirror; textures live beside it.RyllApp::surfacesbecomessurfaces: SurfaceMirror, andGuiSurfaceis replaced by aTextureCacheindisplay_gui.rs: aHashMap<(u8, u32), TextureHandle>with atexture(&mut self, ctx, key, &mut DisplaySurface) -> &TextureHandlemethod that keeps today's lazy-allocate and refresh-on-dirty behaviour, and aremove(key)/clear(). This keeps the renderer crate egui-free (AGENTS.md) and makes the GUI a consumer of the same type the other two modes use. The alternative -- a genericSurfaceMirror<S>over a surface-like trait -- was rejected: it adds a trait with one non-trivial implementation, which is the over-engineering the master plan argues against. apply_eventreturns aDrawOutcome. Variants:NotDisplay;Created { key, width, height };AutoCreated { key, width, height };Destroyed { key };Drawn { key };UnknownSurface { key }. The type is not#[must_use]: web and headless callers legitimately ignore it, and the dozen test call sites would otherwise need noise. ASurfaceCreatedthat replaces an existing key reportsCreated, and the GUI drops that key's texture so the new size is allocated fresh.- Logging moves into the mirror. The info-level
create/destroy/auto-create lines and the debug-level
unknown-surface line move from
process_eventsintoapply_event, so web and headless gain the same diagnostics. Messages keep their text; theapp:prefix becomessurface_mirror:. Surface lifecycle events are rare, so the extra info lines in web mode are not a volume concern. - The per-blit
debug!in the GUI'sImageReadyarm moves too, at debug level. The survey checked whether the display channel already logs draw ops and it does not, so dropping the line would lose the only per-draw trace. - The reconnect state machine stays in ryll (finding 6).
frames_receivedis not unified in this phase (finding 4). Unifying it changes a statistic that bug reports and the status bar both show, so it deserves its own decision about what the number should mean.
The decision most likely to be argued with is 1: deleting
GuiSurface rather than keeping it as a thin wrapper around a
mirror entry. Keeping it would mean the mirror stores something
other than DisplaySurface, which is decision 1's rejected
generic in disguise.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | medium | opus | none | Renderer: add DrawOutcome and return it from SurfaceMirror::apply_event; move logging into the mirror. Detail below. |
| 1b | high | opus | worktree | GUI: replace the surface map with SurfaceMirror + TextureCache; collapse the eight display arms. Detail below. |
| 1c | low | sonnet | none | Docs, issue, and master plan updates. Detail below. |
Step 1a: DrawOutcome in the renderer¶
In shakenfist-spice-renderer/src/surface_mirror.rs:
- Add
pub enum DrawOutcomeper decision 2, derivingDebug,Clone,Copy,PartialEq,Eq.keyis(u8, u32). Re-export it from the crate root besideSurfaceMirror(find the existingpub useinsrc/lib.rs). apply_eventreturns it.ImageReadyreportsAutoCreatedwhen the entry was vacant (keep the existingsaturating_addsizing) andDrawnotherwise. The other five draw events reportDrawnorUnknownSurface. Unmatched events reportNotDisplay.- Move the log lines per decisions 3 and 4, copying their text
from the arms of
process_eventsinryll/src/app.rs. - Rewrite the doc comments that say the mirror "mirrors
ryll/src/app.rs::process_events" (module comment,apply_event, theImageReadyarm) to say it is the single dispatch all modes use. - Extend the existing
#[cfg(test)]module with one test per outcome variant, plus one for the overflow case: anImageReadyon an unknown surface withleft = u32::MAX - 1, width = 4must not panic and must reportAutoCreated. - Existing callers (
session.rs,main.rs,ryll/src/web/,encoder/frame_source.rstests) ignore the return value and need no change; confirm they still build.
Commit: "Report what SurfaceMirror::apply_event did."
Step 1b: the GUI consumes the mirror¶
In ryll/src/display_gui.rs, replace GuiSurface with
TextureCache per decision 1. Keep the existing texture options
(Nearest magnification, Linear minification) and the
surface_{id} texture name.
In ryll/src/app.rs:
surfaces: HashMap<(u8, u32), GuiSurface>becomessurfaces: SurfaceMirrorplustextures: TextureCache. Bothself.surfaces.clear()sites (inreconnectandhandle_critical_disconnect) clear both.- The eight display arms in
process_eventscollapse to one arm that callsself.surfaces.apply_event(&event)and matches the outcome: Created/AutoCreatedwithis_primary_surface(key): the existingauto_fit_size_acceptablecheck, thenpending_resizeandpending_resolution_notify, or the existing oversized-surfacewarn!. Both also drop the key's texture.AutoCreatedandDrawn:stats.frames_received += 1.Destroyed: drop the key's texture.UnknownSurface,NotDisplay: nothing.process_eventscurrently matches on the event by value in places; the new arm must borrow, becauseapply_eventtakes&ChannelEvent. The lag-recordingmatch &eventat the top of the loop is unchanged.- The remaining
self.surfacesusers (the bug-report capture, the screenshot functions, the central panel draw and the primary-surface lookup for region selection) readself.surfaces.surfacesor useprimary_key()/primary_surface(). Where a site needs a texture, callself.textures.texture(ctx, key, surface); borrowsurfacesandtexturesas separate fields so the borrow checker accepts it. Note thatprimary_key()falls back to any surface when(0, 0)is absent; where the GUI today looks up(0, 0)exactly, keep exact lookup rather than silently adopting the fallback. - No change to
is_primary_surface,auto_fit_size_acceptable, or their tests.
Verify with make lint and make test (both run in the
devcontainer), and by running the GUI against a guest: the display
paints, resizing the guest resolution refits the window and raises
one resolution notification, and F8 screenshots still work.
Commit: "Use SurfaceMirror for the GUI's draw dispatch."
Step 1c: documentation and follow-ups¶
docs/rendering-pipeline.md: the code sketch near the top iteratesself.surfacesand callssurface.texture(...); update it to the mirror plus texture cache, and say that all three modes apply draw ops throughSurfaceMirror::apply_event.- File a GitHub issue: "frames_received counts different events in GUI and headless mode", citing finding 4, and add it to the master plan's close-out notes.
- Master plan: mark open questions 1 and 2 answered, pointing at this phase's decisions 1 and 5, and add the overflow bug from finding 2 to the close-out notes.
Commit: "Document the shared draw dispatch."
Risks and mitigations¶
- Textures stop refreshing. The dirty bit is now consumed via
the cache rather than inside
GuiSurface. Mitigation: the management session readsTextureCache::textureagainst the oldGuiSurface::textureline by line, and the operator runs the GUI against a guest before the PR is opened. - A replaced surface keeps a stale-sized texture. Mitigation:
decision 2's rule that
Createddrops the texture; the reviewer checks theCreatedarm does so. - Primary-surface fallback changes behaviour. Mitigation: the
step 1b brief requires exact
(0, 0)lookup where the GUI uses it today; the reviewer greps the diff forprimary_keyand checks each use.
Definition of done¶
grep -n 'GuiSurface' -r ryll/srcfinds nothing.grep -n 'blit\|fill_rect\|copy_bits\|invert_rect' ryll/src/app.rsfinds nothing: no draw-op call remains in the GUI.grep -rn 'mirrors.*process_events' shakenfist-spice-renderer/srcfinds nothing.- The overflow test from step 1a exists and passes.
make lintandmake testpass, andpre-commit run --all-filespasses.- The GUI paints, refits and screenshots against a live guest (operator check).
- The
frames_receivedissue exists and is linked from the master plan.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.