TUI-via-DAP migration pattern¶
Toward the v2.0 ambition — the TUI as a generic DAP client, the 6502 emulator
as library + DAP server — panels are migrated one at a time off direct
cpu.CPU field access and onto the DAP protocol. The Registers panel
(issue #394) is the proof of concept and the template for the rest.
The shape¶
┌──────────────────────────────┐
regsView() ──┤ m.Regs (RegSnapshot) │ render: pure, reads cache
└──────────────▲───────────────┘
│ m.syncRegs() in Update
┌──────────────┴───────────────┐
│ Source.Registers() │ one DAP `variables` request
├──────────────┬───────────────┤
local ───┤ InprocClient │ wire Client ├─── remote (-dap-attach)
└──────────────┴───────────────┘
- A snapshot type holds exactly what the panel renders —
RegSnapshot(internal/tui/regs.go):A X Y SP P PC Cycles Halted. Source.Registers() (RegSnapshot, error)fetches it with a single DAPvariablesround-trip (the server's Registers scope already returns all seven values). Transport-agnostic viaremarshal, which handles both the wire client's JSON body and the inproc client's Go struct.LocalSourceowns an in-process DAP server attached to the same CPU/RAM (the #393 inproc transport — sub-microsecond), so local mode reads through DAP too. In-process direct field access becomes dead code for this panel.RemoteSourcereuses its existing attach*dap.Client.m.syncRegs()refreshesm.Regsin the Update loop — once per render tick, after key actions, and on seed (New/WithSource). Bubble TeaViewstays pure: it renders the cached snapshot, never issues I/O.regsViewrendersm.Regs— zerocpu.CPUaccess.
Migrating the next panel¶
Repeat the four steps. For panels that need more than registers:
- Add a request (or reuse
variables/readMemory/stackTrace/disassemble) that returns the panel's data in one round-trip. - Add a
Sourcemethod returning a snapshot struct; implement for bothLocalSource(inproc) andRemoteSource(wire). - Cache the snapshot on the Model; refresh it from
Update. - Render from the cache.
Keep both code paths alive until #461 flips the default (v1.7) — the in-process
direct access is the fallback / reference, and the DAP path is what the future
generic client uses. Disassembly and memory panels already have
RefreshMemory plumbing to build on.
Migrated panels¶
- Registers (#394) —
variables(Registers scope). The proof of concept. - Stack (#449) —
stackTrace.Source.Stack()/fetchStack/m.syncStack()/StackSnapshot. The DAPstackTraceresponse gained two additive chippy-extension fields so the snapshot can reproduce the panel's hardware-stack-page layout (not just a flat call list):chippyStackAddr(the$01XXslot of each pushed pair) andchippyCallee(the symbol at the JSR target, distinct from the frameName= symbol at the return address). Frame detection / symbol / source-map lookups now live server-side (cpu.DetectStackFrame+ the server's syms/srcMap); the panel only positions the snapshot frames over the page and renders the gaps as runs, with raw bytes still read from the DAP-fed RAM mirror (#451 formalizes that). Local mode pushes the symbol table + source map into its in-process server viaLocalSource.SetSymbols(the symbols load afterNew). - Flags (#450) —
variables(Flags scope, ref=2).Source.Flags()/fetchFlags/m.syncFlags()/FlagsSnapshot(eight bools). The server decomposesPinto N/V/U/B/D/I/Z/C bit values;flagsViewrenders the cached snapshot instead of bit-testingcpu.CPU.P. During a remote free-run thechippy-stateevent (#395) already carries the rawP, so the handler decomposes it client-side viaflagsFromP— keeping the Flags panel as live as Registers without a per-frame round-trip (the Flags scope stays the authoritative source on stop). - Memory (#451) —
readMemory+ #440dirtyRanges.Source.ReadMemory()/fetchMem/m.syncMem()/m.MemView(a window snapshot anchored atMemViewBase,memWindow= 0x400 bytes).memViewrendersmemByte(a)from the snapshot, neverm.RAM.Read. Local mode fetches the window via an inprocreadMemory(same RAM, but over the protocol); remote serves the window from the DAP-fed RAM mirror (reconciled byRefreshMemoryon stop, updated bydirtyRangesduring a run), so a remote free-run needs no per-frame round-trip — thechippy-statehandler callsrefreshMemWindowafter applying the deltas. The memory editor write path (memWrite→ WBus/CPU bus) is unchanged; remote writes are #454.m.RAMstays the render-backing mirror; the change is the panel reads its window through the Source. - Disassembly (#452) —
disassemble.Source.Disassemble()/fetchDisasm/m.syncDisasm()/DisasmSnapshot(instruction lines + anchor).disasmViewrenders text/symbol from the snapshot, applying its own PC/breakpoint markers and styling; thewalkBack/disasmAddrsAroundrender path and its cache are gone. The server'sdisassemblenow renders source-map data ranges as.byte $XX(step 1) so it matches the panel for any client. Both Sources own an inproc DAP server — LocalSource on the live core, RemoteSource on its mirror — so disasm follows the streamed PC during a remote run with no per-tick wire round-trip (the mirror is current via chippy-state regs + #440 dirtyRanges). Symbols/source-map are pushed into both via thesymbolSinkinterface. - Render/nav residuals (#461) — the last direct
cpu/RAMreads in the render + navigation paths: the Stack panel's raw byte/run rows now read a DAP-sourced stack-page snapshot (StackSnapshot.PageviareadMemory), anddisasmScrollmoves its anchor by stepping the DAP disasm snapshot instead ofcpu.WalkBack/cpu.DisasmWithSyms.walkBack+Model.isDataAddrdeleted.
Control path: server-driven run (#471)¶
Run + step enforcement is owned by the DAP server. Server.RunBudget(maxSteps,
step, stopAt) advances the CPU on the TUI goroutine (so the rewind ring keeps
filling — step is the TUI's own m.step) while enforcing breakpoints / data
breakpoints / halt / BRK plus an optional caller predicate. All run paths route
through it: free-run (r), step-×16 (S), step-over (n, predicate =
return-PC), run-to-line (f, predicate = line change). The TUI's own
shouldBreakAt is gone; processMemHits/WBus are vestigial (the access hook
enforces watchpoints now). Breakpoint + watchpoint sets are forwarded to the
server via setInstructionBreakpoints / setDataBreakpoints (#453) at run
start.
Chosen over the async continue+events path used over the wire: the inproc
dispatch self-locks cpuMu, so an async run goroutine would deadlock with
m.step's lock + the :dap co-running server (non-reentrant mutex). The
synchronous RunBudget runs on the TUI goroutine — no run goroutine, no
cross-goroutine CPU race, TargetHz throttle preserved. Zero per-access
overhead when no watchpoints are set (the data-bp hook is armed only then).
Status: complete¶
All five panels + nav are DAP-sourced (#461), and run/step/breakpoint/
watchpoint enforcement is server-owned (#471). The rich TUI rewind ring is
kept as the one local engine exception — < restores from m.Rewind
(budget/keyframes/deep-rewind), strictly more capable than DAP stepBack.
Remaining for full local DAP purity (lower value, deferred): single-step /
mem-edit could route through stepIn / writeMemory, and WBus could be
unwired entirely.
Cost¶
The local DAP round-trip is the inproc transport from #393: ~0.34 µs for a
variables request — negligible next to a render tick. Remote is the wire
cost (~30 µs over a unix socket), still well inside a frame.