Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Per-frame profiling

For an illustrated IDE workflow, see VS Code CPU profiling and Bartman frame profiling, including installation of the Copperline fork independently of upstream.

The profile.start method on the control protocol captures per-frame performance data for external profilers, analysis tools, and scripts. Only one profile capture can run at a time; call profile.stop on an active session before starting a new one.

For static footprint rather than runtime cost, copperline-ctl size-report PROG [--elf PROG.elf] [--out FILE] emits a .cpuprofile weighted in bytes. Its hierarchy is hunk, section, and function, with unattributed bytes kept as an explicit node. The VS Code extension’s Profile File Size command runs the same converter and opens the result.

profile.start {"path": "out/profile", "frames": 500, "slots": true,
               "memory": true, "screenshots": "last", "pc_samples": true}

# A deferred trigger starts without a RAM baseline:
profile.start {"path": "out/triggered", "frames": 500, "slots": true,
               "trigger": {"busy_cck_over": 60000}}

# Precise instruction sampling (registers are optional):
profile.start {"path": "out/profile", "frames": 60, "samples": true,
               "registers": true,
               "unwind": {"base": "0x123400", "table": "<base64>"},
               "relocation_bases": ["0x123400", "0x20a000"],
               "code_ranges": [{"base": "0x123400", "size": "0x6000"}]}
# Guest code coverage (no call stacks, no per-frame sidecars):
profile.start {"path": "out/coverage", "frames": 100000, "coverage": true,
               "relocation_bases": ["0x123400", "0x20a000"],
               "code_ranges": [{"base": "0x123400", "size": "0x6000"}]}
profile.stop
profile.status

Each committed emulated frame appends a JSON object to profile.jsonl in the output directory. When the capture completes or profile.stop is called, a profile.json summary is written beside it. Streaming to profile.jsonl ensures that recorded data is preserved even if emulation stops unexpectedly. The frames parameter defaults to 500 (approx. 10 seconds in PAL) and is capped at 100,000. When the frame budget is reached, capture halts automatically (profile.status reports done).

An optional trigger keeps the profiler armed but does not write records until an absolute emulated frame is reached or a completed frame’s busy colour-clock count exceeds busy_cck_over. Busy clocks are the frame length minus uaelib idle markers when the guest supplies them; otherwise the traced frame length is used. profile.status reports triggered and triggered_at. Because a deferred trigger omits the preceding slot writes, memory: true cannot be combined with trigger: an offline consumer would not have a RAM baseline aligned with the first recorded frame.

Running a profile capture activates the Frame Analyzer’s cheap owner tracing. "slots": true promotes it to the full per-colour-clock record level, which temporarily suspends run-ahead input latency reduction. Tracing is shared with the Frame Analyzer UI pane: closing the UI pane does not interrupt an active profile capture, and stopping a profile capture leaves tracing enabled if the UI pane remains open.

profile.jsonl

One JSON object per committed frame:

FieldMeaning
frame, secondsEmulated timeline position.
idle_cckColour clocks declared idle by guest code via uaelib trap markers (null if unused).
retired68k instructions retired during the frame.
pcProgram counter sample at the frame boundary (pc_samples: true only).
tracedTrue if chip-bus tracing covered the frame.
rows, line_cck, cck_lengthRaster geometry: scanline count, clocks per line, and total clocks per frame.
owner_cckClocks granted per chip-bus owner (refresh, bitplane, sprite, disk, audio, copper, blitter, cpu, idle).
blitterbusy_cck (clocks blitter requested bus) and starve_cck (breakdown of owners that stalled it).
blitsList of blits started during the frame (max 64): control words, size, pointers, and start/end beam positions.
cpuThe CPU’s side of the arbitration: wait_cck (colour clocks the CPU asked for the chip bus and was denied), wait_by (those clocks by denier: refresh, bitplane, sprite, disk, audio, copper, blitter with BLTPRI clear, blitter_nasty with BLTPRI set including its warm-up fence, and port for the 020+ chip port’s own turnaround), wait_by_kind (by the pending access: read, which includes the 68000’s opcode prefetches since the CPU core issues them as plain word reads; fetch for immediate and extension words read outside the prefetch queue; write; custom for custom-register accesses), stall_pcs (up to 16 {"pc", "cck"} entries, the instructions that waited longest, longest first), stall_pcs_distinct and stall_pcs_other (clocks pooled once 4096 distinct PCs are kept). Zero entries are omitted from the maps.
partialTrue if tracing was enabled mid-frame.
registersFrame-start snapshot: all 256 custom-register words, chipset_flags, and the AGA palette’s 256 high and low nibbles. Present on every traced frame.
slotsWhen "slots": true: run-length encoded per-clock owner grid for each scanline ("12R3B497."). Codes match vAmiga DMA debugger (R refresh, B bitplane, S sprite, D disk, A audio, C copper, L blitter, P CPU, . idle).
cpu_waitWhen "slots": true: the CPU wait grid per scanline in the same run-length encoding. Codes are the denier’s owner letter (R, B, S, D, A, C, L for the blitter with BLTPRI clear), N for the blitter with BLTPRI set, p for the port turnaround, and . where the CPU was not waiting.
slots_file, slots_record_bytesWhen "slots": true: raw full-record sidecar filename and its fixed 24-byte stride.
instantaneous_slotsWhen "slots": true and zero-time floppy turbo DMA occurred: ordered {vpos, hpos, ...record} entries that cannot share the one-record-per-clock sidecar slot.
screenshot, digestWhen "screenshots": "every": frame screenshot PNG filename and FNV-1a64 hash digest.
samples, samples_metaWith "samples": true, the filenames of this frame’s compact instruction stream and Copperline timing metadata.
sample_count, samples_total, irq_cckEncoded samples in this frame, cumulative encoded samples, and interrupt-dispatch colour clocks in this frame.

stall_pcs names the instruction that was executing when each wait began. On the precise CPU loop that is the current instruction; under [cpu] jit the PC is republished once per batch, so the attribution is per batch.

If timeline position moves backward (via state load or reverse step), a {"marker": "reposition", "frame": N} marker is emitted to rebaseline instruction counters.

profile.json

Written when the profile stops: contains version, machine configuration, capture options, the list of chip-bus owner names (owners) and CPU wait classes (cpu_wait_classes), started/ended timeline points, frames_written, and a snapshot of registered uaelib resources (matching debug.resources) for address labeling. rom_symbols snapshots the running guest’s ROM ranges, resident modules, and live library/device LVO targets at capture stop; this makes conversion deterministic while still reflecting SetFunction() patches. It also records systemStackLower, systemStackUpper, stackLower, and stackUpper, read from ExecBase and ThisTask when a valid AmigaOS task is running. An unreadable ExecBase makes all four fields null; an unavailable or implausible ThisTask leaves the two system fields intact and reports zero for the two task fields.

With "memory": true, chip-ram.bin and slow-ram.bin are written once, at capture start. Together with the per-slot write records these let an offline consumer reconstruct memory at any later colour clock without running the emulator.

Full chip-bus slot records

"slots": true writes one slots-NNNNNN-frame-MMMMMM.bin per recorded frame, where the first number is a monotonic capture sequence and the second is the emulated frame. The sequence keeps repeated frames distinct after rewind or state loading. Records are in raster order (VPOS * line_cck + HPOS) and use this packed, little-endian 24-byte layout:

OffsetTypeField
0u16reg: custom-register offset; bit 0x1000 marks a CPU access (0x1000 alone is ordinary CPU memory, while `0x1000
2u8kind: refresh 1, CPU 2, Copper 3, audio 4, blitter 5, bitplane 6, sprite 7, disk 8, conflict 9
3u8subtype: CPU code/data; Copper move/wait/skip; audio 0-3; bitplane 1-8; sprite 0-7; blitter A-D plus fill bit 0x10 and line bit 0x20
4u8size: transferred bytes (1 through 4 for CPU/CIA, 2 for ordinary DMA, 4 or 8 for grouped AGA fetches; zero when no data transfer is attached)
5u8ipl: CPU-visible interrupt level at this slot
6u16flags: bit 0 write; bit 1 CIA access valid; bit 2 CIA-B; bits 8-11 CIA register; bits 12-14 E-clock phase
8u32addr: chip/CPU address
12u64data: transfer data, including 32/64-bit AGA FMODE fetch groups
20u32events: hardware-edge bits listed below

The same fields are available live from frame.slots {"row": V} and in the Frame Analyzer’s selected/hovered-slot readout. On that JSON surface, data is a fixed-width hexadecimal string so all 64 bits remain exact.

Each frame’s blits array also carries a stable blit ID, start/end frame and beam positions, direction, fill/line mode, enabled channels, all four pointers/modulos, A/B shifts, A masks, minterm and formula, effective A/B/C constant inputs (including BLTBDAT’s write-time-shifted hold latch), captured-word counts, and clocks used versus stalled. An in-flight blit is referenced from both adjacent frame records and finalised in both when it ends. The full DMA words remain in the live trace for blit.render; the profile keeps their bounded counts while the slot sidecar is the lossless offline transfer stream.

Floppy turbo DMA deliberately consumes no emulated time, so several transfers can occur at one beam position and cannot occupy the single raster record for that colour clock. Those transfers remain zero-time while tracing and are exported in order as instantaneous_slots in the frame’s JSONL record, with vpos, hpos, and the same record fields above. Apply them after the ordinary record at that position when replaying memory. frame.slots exposes the same ordered entries for its requested row as instantaneous_records.

BitEvent
0blitter interrupt request
1blitter final D write
2blitter start or finish (the streamed event name distinguishes the edge)
3bitplane fetch/update
4Copper WAIT wake
5CPU interrupt recognition
6INTREQ set
7Copper wanted a fixed-DMA-owned slot
8no requester received the slot
9blitter denied by CPU
10CPU denied by BLTPRI blitter
11Copper SKIP taken
12-14DDFSTRT, DDFSTOP, hard DDF stop
15CIA register access
16-26VB, VS, LOF, LOL, HBS, HBE, HDIWS, HDIWE, VDIW, HSS, HSE
27-28CIA-A and CIA-B IRQ pin assertions
29-30CPU STOP entered and STOP IPL wake

VB, VS, and VDIW are signal-state markers on the first refresh slot of every line; LOF and LOL similarly mark the second refresh slot. HBS/HBE, HSS/HSE, HDIWS/HDIWE, and DDF events mark their programmable comparator edges.

registers.chipset_flags uses bit 0 for AGA, bit 1 for ECS Agnus or newer, bit 2 for NTSC, bit 3 for interlace, and bit 4 for the LOF field.

When precise sampling is enabled, the summary also records cck_per_cpu_cycle, samples_total, irq_cck, every loaded hunk base and executable range, the unwind text base and size, and the sidecar layouts. Samples use colour clocks (CCK), Copperline’s native chipset time unit; cck_per_cpu_cycle converts the configured CPU clock to that unit.

Precise CPU samples and unwinding

"samples": true moves a JIT-configured CPU temporarily onto the precise per-instruction path. It does not change the emulated timeline. Every retired instruction records its PC and colour-clock cost. "registers": true appends D0-D7, A0-A7 and SR. Interrupt entry is a distinct [IRQ] sample; its metadata contains the interrupt level and exception vector rather than inferring them from the cost.

Each samples-SSSSSS-frame-NNNNNN.bin is a little-endian u32 stream compatible with vscode-amiga-debug/WinUAE: leaf-to-root call-stack PCs, 0xffffffff - cck, then the 17 optional register words. PCs in the supplied text range are relative to its base; Kickstart PCs in $F80000..$FFFFFF remain absolute. Samples longer than 65535 CCK are split so their cost word cannot be mistaken for a PC by existing parsers.

The optional live unwind table has one six-byte row for every two bytes of text: (cfa_register << 12) | cfa_offset, saved-A5 offset, return-address offset, all little-endian i16 words. CFA register 13 is A5 and 15 is A7. The emulator keeps expanded offsets as i32, follows the return address at sample time, and stops when it leaves the supplied text. copperline-ctl --dap builds this table directly from the already-loaded DWARF call-frame information; no objdump process is involved.

samples-SSSSSS-frame-NNNNNN.meta starts with CLSM, u32 version 1, and a u32 row count. Each row is five little-endian u32 values: total CCK, instruction CCK, chip-bus-wait CCK, IRQ level, and IRQ vector. The latter two are 0xffffffff for ordinary instructions. This parallel file lets Copperline reports expose [Bus wait] below the responsible function while leaving the main stream compatible with Bartman’s reader.

SSSSSS is a monotonic capture sequence, so revisiting the same emulated frame through reverse execution or state loading never overwrites an earlier sidecar. relocation_bases is ordered by hunk and lets the offline converter map absolute samples from every code hunk. code_ranges lets the live compact unwinder retain a leaf or caller from a code hunk outside its hunk-0 table while still stopping at external code. Older captures without relocation data fall back to the unwind table’s hunk-0 base.

Converting to a CPU profile

Convert an offline capture with the same hunk executable and, when applicable, its ELF debug sibling:

copperline-ctl profile-report out/profile --program hello \
  --elf hello.elf --out hello.cpuprofile

The default is one merged Chrome DevTools .cpuprofile. Add --per-frame for one numbered file per captured frame, --format bartman for Bartman’s $amiga annotations, or repeat --source-map FROM=TO to rewrite recorded build paths. Functions, source lines, and optimized inline frames come from Copperline’s native debug-info reader. VS Code opens .cpuprofile files directly; its CPU profile flame-chart extension adds the graphical flame view. ROM samples use the captured live names, for example [Kick]exec/AllocMem; older captures without rom_symbols retain the generic [Kickstart] label.

Guest code coverage (lcov)

Line and function coverage of a guest program is written in lcov’s .info format, so genhtml, VS Code’s coverage extensions and CI coverage services consume it directly. See VS Code for the editor workflow.

Collecting

Coverage is the lightweight sibling of precise sampling. "coverage": true on profile.start keeps one saturating hit counter per instruction word inside the capture’s code_ranges (the program’s code hunks) and a single total for everything retired outside them: Kickstart, libraries, other tasks. Nothing is written per frame; the histogram is written once as coverage.bin when the capture stops (or reaches its frame budget), and profile.json summarises it under coverage. relocation_bases is recorded as for samples, so the offline converter can relocate the program’s debug information. Without code_ranges every address is counted in a bounded sparse map (1M distinct addresses), which is enough for a small program launched by hand. "coverage": true and "samples": true combine freely; a coverage-only capture needs no unwind table.

Like the sampler, the counter observes retired instructions and feeds nothing back: the emulated timeline is identical with and without it. It forces the precise per-instruction loop while armed (a [cpu] jit machine logs its fallback once) and blocks run-ahead, whose speculative frames would be counted twice. Reverse steps and state loads are not undone: an instruction re-executed after a rewind counts again.

coverage.bin starts with CLCV and a little-endian u32 version (1), then a u32 range count and, per range, base, byte size, word count and that many u32 counters; then a u32 sparse-entry count and (address, count) pairs; then u64 outside and total instruction counts.

Converting

copperline-ctl profile-report out/coverage --program hello \
  --elf hello.elf --format lcov --out hello.info \
  --source-map /build/src=/home/me/src
genhtml hello.info -o coverage-html

--format lcov reads coverage.bin; a capture made with "samples": true but without coverage is converted from its precise samples’ leaf PCs instead. Executed addresses map through the program’s own debug information (vasm LINE hunks, amiga-gcc DWARF, an ELF sibling; see Debug information) to one record block per source file: FN/FNDA for functions (DWARF subprograms, or the code hunks’ symbols when there is no DWARF), DA for every line that has code, including lines never executed, and the LF/LH/FNF/FNH totals. --source-map FROM=TO rewrites the recorded paths. TN names the program.

A line’s count is gcov-like: over each contiguous run of line-table rows for the line, the highest hit count of any instruction in the run, summed across the runs, so a for header split into an initialisation run and a test run counts each pass once. A function’s count is the hit count of its entry instruction (or of its most executed instruction when control only entered mid-body).

Instructions that map to no source line are never dropped silently. The file starts with # comment lines, which every lcov reader ignores, that account for every retired instruction, and the converter prints the same summary:

# Copperline guest coverage: hello
# 1234567 instruction(s) retired: 4321 on 26 source line(s) in 1 file(s)
# 12 instruction(s) at 3 program address(es) without line information
# 1230234 instruction(s) outside the program (Kickstart, libraries, other tasks)
# lines 24/26 hit, functions 3/3 hit

“Without line information” covers a startup stub assembled without -linedebug, code in a hunk the debug information does not describe, and data hunks executed by mistake. A program built without any line information yields only these totals.

--run PROG --coverage FILE

The whole loop in one flag:

copperline --factory --noaudio --run build/hello --coverage build/lcov.info

Copperline boots, watches the guest’s LoadSeg() results for the program, relocates its debug information by the segments the loader reports (a PROG.elf beside the executable is used automatically), counts from the program’s first instruction, and writes the lcov file when the boot script’s completion marker shows the program exited. On its own the flag is a headless capture run like --screenshot-after: unpaced, windowless, ending with the program (or after 60 emulated seconds if it never loads, leaving an all-zero file that still lists every line and function). Combine it with --screenshot-after and friends to bound a program that does not exit; the file is then written when the run ends, or, with --control-gui/--gdb-gui, when the windowed session closes. While the program runs the file is rewritten every five emulated seconds, so an interrupted run leaves a current one behind. --coverage-source-map FROM=TO (repeatable) rewrites source paths as --source-map does. The DAP adapter’s coverage launch argument passes the same flag.

Storage overhead

Enabling slots keeps one 24-byte record per colour clock live (about 1.7 MiB for a 313x227 PAL frame) and writes about the same amount per frame, plus the roughly 2-20 KB run-length encoded grids. Setting "memory": true adds one copy each of chip and slow RAM. Setting "screenshots": "every" produces 50 PNG images per emulated second in PAL. Precise sampling is larger: without registers each sample is the call stack plus one word; registers add 68 bytes per sample. Coverage keeps four bytes per instruction word of the code ranges in memory and writes them once. All four options are disabled by default. Captures up to 100,000 frames are accepted.

Profiling a saved machine offline

copperline-ctl profile boots no guest volume and needs no running server:

copperline-ctl profile scene.clstate --frames 2 --out out/scene
copperline-ctl profile scene.uss --rom kickstart.rom --frames 2 --out out/scene-uss
copperline-ctl profile scene.uss --rom kickstart.rom --frames 2 \
  --format bartman --out out/scene.profile

The default writes the native capture directory with instruction/register samples, DMA slots, replay memory and screenshots. --frames accepts 1-100. A native state supplies its own ROM; a USS file requires the matching ROM and discards a reconstructed frame before profiling. See USS coverage and limitations.

Here --format bartman selects the legacy binary file documented under GDB compatibility. The separate profile-report --format bartman command still writes an annotated JSON .cpuprofile from an existing native capture.

For interoperability checks, compile the upstream extension (npm ci, npx tsc -p .) and run its real parser against the exported file:

node tools/check-bartman-profile.cjs /path/to/vscode-amiga-debug scene.profile