SLICE-1.2: Real Frame Payloads
- Status: Proposed
- Date: 2026-04-27
- Depends on: Requirements, Evolution Roadmap, SLICE-006: Observability Baseline, SLICE-1.1: Multi-Tag Telemetry, SLICE-1.5: Automated Measurement Capture
Goal
Replace the placeholder Frame.PreviewPayload with a real byte[] of bytes sized by SimulatorProfile.FrameWidth × FrameHeight × BytesPerPixel. Simulator allocates the buffer per frame and fills it with deterministic synthetic content (gradient + noise). The UI preview converts each frame into a WriteableBitmap and renders it. A new HighFrameRate profile demonstrates 30 fps at 2 MP — the first time the prototype exercises sustained image bandwidth and LOH allocation pressure.
Why This Slice
Today Frame.PreviewPayload is null (or a placeholder) on every frame. The frame channel ticks, the consumer dequeues, but no actual image data flows. The whole frame pipeline is shaped around that emptiness: the channel has capacity = 3 with DropOldest, the UI preview shows a static placeholder, and working-set peak from the demo baseline is dominated by WPF chrome rather than frame buffers.
A real wafer inspection tool produces megapixel-class frames at 10–60 fps. Each frame is 2–10 MB depending on resolution and pixel format. At 30 fps × 2 MP × 1 byte/pixel = 60 MB/s of large-object-heap allocation. That allocation rate is the load-bearing reason the existing single-AppState-snapshot pipeline has to be re-thought before Phase 2 — but it's invisible while PreviewPayload is null.
This slice puts the shape of real frame data through the pipeline and seeds enough of it (configurable resolution per profile, the new HighFrameRate profile at 2 MP × 30 fps) to expose first-time GC pause and working-set behavior under image bandwidth. The store is not refactored here — the per-frame buffer copy through AppState.LatestFrame is what Phase 2's IFrameBuffer lift-out will eventually solve, after this slice produces evidence that it needs solving.
Requirements Coverage
- 04. UI and Technical Requirements: preview must show real frames; bounded streaming with measurable behavior under image bandwidth
- 05. Failure Modes and Workflow Requirements: high-rate frame streams must not destabilize the workflow state machine
- 07. AI Delivery Constraints and Roadmap: each phase ships a measurable before-and-after; this is row
slice-1-2-real-frame-payloadsin the measurements table
In Scope
Frame.PreviewPayloadsemantics change:- field type stays
byte[]?(no domain-shape change), but the simulator now always populates it; the existingbyte[]?nullability remains so future test stubs can still pass null without crashing the consumer - byte layout: row-major, packed, no stride padding.
Frame.Width × Frame.Height × Frame.BytesPerPixelbytes exactly
- field type stays
Framerecord gains three readonly fields:int Widthint Heightbyte BytesPerPixel(valid range: 1, 2, 3, 4 — covers 8-bit grayscale, 16-bit grayscale, 24-bit RGB, 32-bit RGBA)
SimulatorProfilegains three fields:int FrameWidth(default 640 for existing profiles to keep allocations small)int FrameHeight(default 480)byte BytesPerPixel(default 1)- validation: width × height ≤ 2147483647 ÷ BytesPerPixel (no
intoverflow on payload size); width and height ≥ 1; BytesPerPixel ∈
- New
HighFrameRateprofile in seedappsettings.json:FrameIntervalMs: 33(30 fps)FrameWidth: 2048, FrameHeight: 1024, BytesPerPixel: 1(2 MP × 8-bit grayscale, ~2 MB/frame, ~60 MB/s allocation)TelemetryIntervalMs: 100,MotionSpeedUnitsPerSecond: 50.0, otherwise mirrorsMultiTagdefaults (50 tags at heterogeneous rates from SLICE-1.1)
SimulatedCamera.ProduceFramesAsyncallocates a freshbyte[]per frame (new byte[Width * Height * BytesPerPixel]), fills it with deterministic synthetic content (see Runtime Behavior), and assigns toframe.PreviewPayload. No pooling in this slice — exposing the unfiltered LOH pressure is the slice's whole purpose- UI rendering:
MainViewModelexposes aWriteableBitmap CurrentFramepropertyMainWindow.xamlbinds anImagecontrol'sSourcetoCurrentFrame- on each consumed frame, the view-model resizes the
WriteableBitmapto match the frame's W/H/format if changed, thenWriteableBitmap.WritePixelscopies the payload in - dispatcher marshalling stays in
MainViewModel(no infrastructure-layer WPF dependency)
- Metrics:
- the existing
frames.ingestedandframes.droppedcounters inAppMetricscontinue unchanged (snapshot semantics) - capture procedure (runbook §5 metric extraction) gains two new metrics:
dotnet.gc.pause.timep95 (extracted from the existing System.Runtime provider — already in the CSV, just not yet read) andLOH-allocation-rate avg (B/s)(computed fromdotnet.gc.heap.size[generation=loh]deltas)
- the existing
- A new measurement scenario
§4.3 High-frame-rate soak — slice-1.2, HighFrameRate profileadded todocs/runbook/capturing-measurements.md. Reuses the existingMultiTagSoakScenarioshape but with theHighFrameRateprofile selected - Before/after rows in
docs/reviews/phase-1-measurements.mdagainst the row 0b automated baseline, captured under the new scenario, with the existing 16-metric set plus the two new metrics (18 total for this row)
Out of Scope
- frame buffer pooling (
ArrayPool<byte>or similar) — Phase 2 / SLICE 2.x once measurements show LOH pressure is a problem - moving frame payload out of
AppState.LatestFrame(Phase 2 /IFrameBufferlift-out) - per-frame defect-marker pixels in the synthetic content — defects remain a metadata field on
Frame, not a visual artifact - compression, encoding (PNG/JPEG), or any non-raw payload format — raw bytes only
- ROI extraction, crop, downscale, or any image processing — pipeline carries the full payload end-to-end
- changing the existing
frames.droppedchannel-coalesce policy — capacity = 3 /DropOldeststays - new metrics or counters beyond the two CSV-extracted aggregates listed above
- adapting the existing scenarios (
DemoBaseline,MultiTagSoak) to use larger frames — those continue with their current 640×480 defaults so the existing rows 0/0a/0b/slice-1-1 remain comparable - a new scenario class — the existing
MultiTagSoakScenariois reused with--profile HighFrameRate
Runtime Behavior
Frame allocation and content
SimulatedCamera.ProduceFramesAsyncreads_profileProvider.CurrentProfileper tick to pick upFrameWidth,FrameHeight,BytesPerPixel. Profile changes between frames are honored on the next tick- Per frame:
var payload = new byte[width * height * bpp];then a fill loop (see below). The new array goes through whichever heap the size dictates — at the seed defaults, 640 × 480 × 1 = 307 200 bytes is just below the 85 000 byte LOH threshold... wait, 307 KB is well above 85 KB, so even existing profiles' frames will land on LOH. That is the slice's point - Content fill: a deterministic gradient based on
frame.Sequenceplus uniform noise. Pseudocode:This is not "realistic" in any inspection sense — it's deterministic byte-fill that exercises memory bandwidth without making the simulator branch on RNG state per pixel. A future slice can add defect markers or noise patternsfor (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { var v = (byte)((x + y + frame.Sequence) & 0xFF); for (int b = 0; b < bpp; b++) payload[(y * width + x) * bpp + b] = v; } - Per-frame allocation pressure target: at
HighFrameRate(2 MP × 1 byte × 30 fps) ≈ 60 MB/s LOH alloc rate sustained. The capture must not drop frames at the channel (frames.droppedstays at 0 for the duration)
UI binding
MainViewModeladds:csharpwith a privatepublic WriteableBitmap? CurrentFrame { get; private set; }_currentFrameBufferthat holds the underlyingWriteableBitmap. The property setter raisesINotifyPropertyChanged- On each
AppState.StateChangedevent whose payload includes a newLatestFrame.PreviewPayload,MainViewModel:- Captures the frame off the state snapshot
- Marshals to the dispatcher (
Application.Current.Dispatcher.InvokeAsync) - If
CurrentFrameis null or its dimensions don't match the frame's, allocates a newWriteableBitmapof the right shape (PixelFormats.Gray8,Gray16,Bgr24, orBgra32based onBytesPerPixel) - Calls
CurrentFrame.WritePixels(rect, payload, stride, 0)to copy the new frame into the bitmap - Raises
PropertyChanged
- In scenario mode (
--scenarioflag), the dispatcher loop is still spinning but no main window is shown. The view-model's frame conversion code path runs anyway because it subscribes toIAppStateStore.StateChanged. To keep scenario captures honest about UI cost, the dispatcher work runs in scenario mode too MainWindow.xamladds a single<Image Source="{Binding CurrentFrame}" Stretch="Uniform" />element in whatever panel currently shows the placeholder. Existing layout is unchanged
Profile validation
SimulatorProfilesValidator(existing) gains validation for the three new fields per the rules in In Scope. Bad config fails app startup with a clear message naming the offending profile and field- The default profile values for existing profiles (
Normal,MultiTag) stay at 640 × 480 × 1 so SLICE-1.1's row continues to be reproducible byte-for-byte after this slice merges. TheHighFrameRateprofile is the only one that exercises 2 MP
Metrics surfaces
- No new
InspectionPrototypecounters — the slice's metric story is entirely in System.Runtime counters (dotnet.gc.pause.time,dotnet.gc.heap.size[generation=loh]) that are already collected by the existing capture command - The
MeasurementExtraction.psm1module gains two helper functions:Get-GcPauseP95andGet-LohAllocRateAvg. The existingConvertTo-MeasurementRowcalls them and adds the two extra rows to the markdown block. The 18-metric block becomes the canonical shape for SLICE-1.2 onward
Acceptance Criteria
This slice is satisfied only if all of the following are true:
Frame.PreviewPayloadis non-null on every frame produced bySimulatedCameraafter the slice merges. A grep forPreviewPayload = nullundersrc/InspectionPrototype.Infrastructure/returns no production matches (test stubs may still produce null)Frame.Width,Frame.Height, andFrame.BytesPerPixelexist on theFramerecord and equal the producing profile's values for every frameSimulatorProfileexposesFrameWidth,FrameHeight,BytesPerPixel. Configuration that yields a profile with width or height < 1, BytesPerPixel ∉ {1, 2, 3, 4}, or a payload size exceedingint.MaxValuefails app startup with a clear validation error- The seed
appsettings.jsonincludes aHighFrameRateprofile withFrameIntervalMs = 33,FrameWidth = 2048,FrameHeight = 1024,BytesPerPixel = 1. ExistingNormalandMultiTagprofiles ship with640 × 480 × 1defaults MainViewModel.CurrentFrameexists and updates on every received frame. Manual smoke test: launch the app interactively under theHighFrameRateprofile, click Connect → Home → Start Run, observe the preview area showing a continuously updating gradient pattern (not a static placeholder)- A 10-minute continuous run under the new
HighFrameRateprofile completes without an unhandled exception, withoutruns.faultedincrementing for frame-pipeline-caused faults, withframes.dropped == 0at the channel, and withframes.ingested ≥ 17 500(= 30 fps × 600 s × 0.97 to allow 3% startup/shutdown latency) - The capture produces a row block in
docs/reviews/phase-1-measurements.mdtaggedslice-1-2-real-frame-payloadswith all 18 metrics filled (the existing 16 plusgc-pause-p95 (ms)andLOH-alloc-rate avg (B/s)), a CSV underdocs/captures/slice-1-2-high-fps-<date>.csv, and the commit hash under measurement docs/runbook/capturing-measurements.mdgains a §4.3 entry naming theHighFrameRateprofile, the 10-minute scenario configuration, and any post-processing differences (most importantly the 18-metric row vs the 16-metric row)- The
Capture-Measurements.ps1orchestrator works against--scenario MultiTagSoak --profile HighFrameRate --duration 600end-to-end, including the new GC-pause and LOH-alloc-rate extraction - The full existing test suite still passes, plus new tests covering:
Framerecord carries the three new fields;SimulatedCameraproduces a payload of the expected size for each profile;SimulatorProfilesValidatorrejects each invalid case in criterion 3;MeasurementExtraction.psm1's two new helpers compute correctly against a known fixture CSV dotnet testruns in under 60 seconds (the new tests must not introduce 2 MP allocations into the test process — fixture sizes forSimulatedCameratests should be the smallest valid 16 × 16 × 1)
Verification Notes
The implementation task for this spec must include verification for:
- the per-frame allocation hits LOH at the seed defaults (verified by checking
gen-2-gc-countagainst theslice-1-2-real-frame-payloadsrow — non-trivial gen-2 GC activity is the whole point; if gen-2 stays at 0 across the 10-minute run, either the LOH allocations aren't reaching LOH or the GC isn't collecting them, both worth investigating) - the
WriteableBitmapis allocated lazily per dimension change, not per frame — at the steady-state of one profile, only oneWriteableBitmapexists across the entire run. A heap snapshot mid-capture should show a singleWriteableBitmapinstance, not 18 000 of them - frame payload byte layout matches what
WriteableBitmap.WritePixelsexpects (row-major, no stride padding forGray8; native-endianushortforGray16). A round-trip test (write known bytes, read back fromWriteableBitmap.CopyPixels) confirms layout correctness for each supportedBytesPerPixel - the
HighFrameRateprofile's combination of 30 fps frames + 50 tags at heterogeneous rates does not cause anyruns.faultedor telemetry-caused diagnostics warnings beyond whatslice-1-1-multi-tag-telemetryalready produces. If it does, the resolution belongs in this slice (the new profile is the change under test) - the dispatcher work for
CurrentFrameupdates does not deadlock on theIAppStateStore.StateChangedthread under scenario mode (no main window is showing, but the dispatcher is still pumping). A 10-second--scenario Fakesmoke run with theHighFrameRateprofile should exit cleanly even though no UI is visible - the runbook's
powercfg /change standby-timeout-ac 0discipline (added in TASK-1.5.1 follow-up) applies to this slice's 10-min capture too — system sleep during the capture would dilute the LOH-alloc-rate metric the slice exists to expose