Skip to content

SLICE-1.2: Real Frame Payloads

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

In Scope

  • Frame.PreviewPayload semantics change:
    • field type stays byte[]? (no domain-shape change), but the simulator now always populates it; the existing byte[]? 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.BytesPerPixel bytes exactly
  • Frame record gains three readonly fields:
    • int Width
    • int Height
    • byte BytesPerPixel (valid range: 1, 2, 3, 4 — covers 8-bit grayscale, 16-bit grayscale, 24-bit RGB, 32-bit RGBA)
  • SimulatorProfile gains 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 int overflow on payload size); width and height ≥ 1; BytesPerPixel ∈
  • New HighFrameRate profile in seed appsettings.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 mirrors MultiTag defaults (50 tags at heterogeneous rates from SLICE-1.1)
  • SimulatedCamera.ProduceFramesAsync allocates a fresh byte[] per frame (new byte[Width * Height * BytesPerPixel]), fills it with deterministic synthetic content (see Runtime Behavior), and assigns to frame.PreviewPayload. No pooling in this slice — exposing the unfiltered LOH pressure is the slice's whole purpose
  • UI rendering:
    • MainViewModel exposes a WriteableBitmap CurrentFrame property
    • MainWindow.xaml binds an Image control's Source to CurrentFrame
    • on each consumed frame, the view-model resizes the WriteableBitmap to match the frame's W/H/format if changed, then WriteableBitmap.WritePixels copies the payload in
    • dispatcher marshalling stays in MainViewModel (no infrastructure-layer WPF dependency)
  • Metrics:
    • the existing frames.ingested and frames.dropped counters in AppMetrics continue unchanged (snapshot semantics)
    • capture procedure (runbook §5 metric extraction) gains two new metrics: dotnet.gc.pause.time p95 (extracted from the existing System.Runtime provider — already in the CSV, just not yet read) and LOH-allocation-rate avg (B/s) (computed from dotnet.gc.heap.size[generation=loh] deltas)
  • A new measurement scenario §4.3 High-frame-rate soak — slice-1.2, HighFrameRate profile added to docs/runbook/capturing-measurements.md. Reuses the existing MultiTagSoakScenario shape but with the HighFrameRate profile selected
  • Before/after rows in docs/reviews/phase-1-measurements.md against 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 / IFrameBuffer lift-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.dropped channel-coalesce policy — capacity = 3 / DropOldest stays
  • 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 MultiTagSoakScenario is reused with --profile HighFrameRate

Runtime Behavior

Frame allocation and content

  • SimulatedCamera.ProduceFramesAsync reads _profileProvider.CurrentProfile per tick to pick up FrameWidth, 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.Sequence plus uniform noise. Pseudocode:
    for (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;
        }
    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 patterns
  • 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.dropped stays at 0 for the duration)

UI binding

  • MainViewModel adds:
    csharp
    public WriteableBitmap? CurrentFrame { get; private set; }
    with a private _currentFrameBuffer that holds the underlying WriteableBitmap. The property setter raises INotifyPropertyChanged
  • On each AppState.StateChanged event whose payload includes a new LatestFrame.PreviewPayload, MainViewModel:
    1. Captures the frame off the state snapshot
    2. Marshals to the dispatcher (Application.Current.Dispatcher.InvokeAsync)
    3. If CurrentFrame is null or its dimensions don't match the frame's, allocates a new WriteableBitmap of the right shape (PixelFormats.Gray8, Gray16, Bgr24, or Bgra32 based on BytesPerPixel)
    4. Calls CurrentFrame.WritePixels(rect, payload, stride, 0) to copy the new frame into the bitmap
    5. Raises PropertyChanged
  • In scenario mode (--scenario flag), 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 to IAppStateStore.StateChanged. To keep scenario captures honest about UI cost, the dispatcher work runs in scenario mode too
  • MainWindow.xaml adds 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. The HighFrameRate profile is the only one that exercises 2 MP

Metrics surfaces

  • No new InspectionPrototype counters — 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.psm1 module gains two helper functions: Get-GcPauseP95 and Get-LohAllocRateAvg. The existing ConvertTo-MeasurementRow calls 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:

  1. Frame.PreviewPayload is non-null on every frame produced by SimulatedCamera after the slice merges. A grep for PreviewPayload = null under src/InspectionPrototype.Infrastructure/ returns no production matches (test stubs may still produce null)
  2. Frame.Width, Frame.Height, and Frame.BytesPerPixel exist on the Frame record and equal the producing profile's values for every frame
  3. SimulatorProfile exposes FrameWidth, FrameHeight, BytesPerPixel. Configuration that yields a profile with width or height < 1, BytesPerPixel ∉ {1, 2, 3, 4}, or a payload size exceeding int.MaxValue fails app startup with a clear validation error
  4. The seed appsettings.json includes a HighFrameRate profile with FrameIntervalMs = 33, FrameWidth = 2048, FrameHeight = 1024, BytesPerPixel = 1. Existing Normal and MultiTag profiles ship with 640 × 480 × 1 defaults
  5. MainViewModel.CurrentFrame exists and updates on every received frame. Manual smoke test: launch the app interactively under the HighFrameRate profile, click Connect → Home → Start Run, observe the preview area showing a continuously updating gradient pattern (not a static placeholder)
  6. A 10-minute continuous run under the new HighFrameRate profile completes without an unhandled exception, without runs.faulted incrementing for frame-pipeline-caused faults, with frames.dropped == 0 at the channel, and with frames.ingested ≥ 17 500 (= 30 fps × 600 s × 0.97 to allow 3% startup/shutdown latency)
  7. The capture produces a row block in docs/reviews/phase-1-measurements.md tagged slice-1-2-real-frame-payloads with all 18 metrics filled (the existing 16 plus gc-pause-p95 (ms) and LOH-alloc-rate avg (B/s)), a CSV under docs/captures/slice-1-2-high-fps-<date>.csv, and the commit hash under measurement
  8. docs/runbook/capturing-measurements.md gains a §4.3 entry naming the HighFrameRate profile, the 10-minute scenario configuration, and any post-processing differences (most importantly the 18-metric row vs the 16-metric row)
  9. The Capture-Measurements.ps1 orchestrator works against --scenario MultiTagSoak --profile HighFrameRate --duration 600 end-to-end, including the new GC-pause and LOH-alloc-rate extraction
  10. The full existing test suite still passes, plus new tests covering: Frame record carries the three new fields; SimulatedCamera produces a payload of the expected size for each profile; SimulatorProfilesValidator rejects each invalid case in criterion 3; MeasurementExtraction.psm1's two new helpers compute correctly against a known fixture CSV
  11. dotnet test runs in under 60 seconds (the new tests must not introduce 2 MP allocations into the test process — fixture sizes for SimulatedCamera tests 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-count against the slice-1-2-real-frame-payloads row — 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 WriteableBitmap is allocated lazily per dimension change, not per frame — at the steady-state of one profile, only one WriteableBitmap exists across the entire run. A heap snapshot mid-capture should show a single WriteableBitmap instance, not 18 000 of them
  • frame payload byte layout matches what WriteableBitmap.WritePixels expects (row-major, no stride padding for Gray8; native-endian ushort for Gray16). A round-trip test (write known bytes, read back from WriteableBitmap.CopyPixels) confirms layout correctness for each supported BytesPerPixel
  • the HighFrameRate profile's combination of 30 fps frames + 50 tags at heterogeneous rates does not cause any runs.faulted or telemetry-caused diagnostics warnings beyond what slice-1-1-multi-tag-telemetry already produces. If it does, the resolution belongs in this slice (the new profile is the change under test)
  • the dispatcher work for CurrentFrame updates does not deadlock on the IAppStateStore.StateChanged thread under scenario mode (no main window is showing, but the dispatcher is still pumping). A 10-second --scenario Fake smoke run with the HighFrameRate profile should exit cleanly even though no UI is visible
  • the runbook's powercfg /change standby-timeout-ac 0 discipline (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

Docs-first project memory for AI-assisted implementation.