Skip to content

TASK-1.2: Implement Real Frame Payloads

Status update 2026-04-27: Passes 1 and 2 are merged (Frame fields, SimulatorProfile fields, HighFrameRate seed, SimulatedCamera real byte[] allocation, MainViewModel.CurrentFrame WriteableBitmap, Get-GcPauseP95 / Get-LohAllocRateAvg extraction helpers). Pass 3 (10-minute HighFrameRate capture) was deferred pending SLICE-1.6 FlaUI rig. Pass 3 captured 2026-04-27 under the SLICE-1.6 FlaUI rig — see the slice-1-2-real-frame-payloads row in docs/reviews/phase-1-measurements.md and docs/captures/slice-1-2-high-fps-2026-04-27.csv.

Objective

Make Frame.PreviewPayload a real byte[] sized by SimulatorProfile.FrameWidth × FrameHeight × BytesPerPixel, allocated and filled per frame in SimulatedCamera. Bind a WriteableBitmap in the UI so the preview actually renders. Add a HighFrameRate simulator profile (2 MP × 8-bit @ 30 fps), extend the measurement extraction with gc-pause-p95 and LOH-alloc-rate avg, and capture the slice-1-2-real-frame-payloads row.

Scope

  • extend Frame with Width, Height, BytesPerPixel
  • extend SimulatorProfile with the same three fields and validate them
  • add the HighFrameRate profile to seed appsettings.json
  • update SimulatedCamera.ProduceFramesAsync to allocate the per-frame byte[] and fill it with the deterministic gradient pattern from the spec
  • expose WriteableBitmap CurrentFrame on MainViewModel; bind it from MainWindow.xaml; marshal frame updates onto the dispatcher
  • add Get-GcPauseP95 and Get-LohAllocRateAvg helpers to tools/MeasurementExtraction.psm1; have ConvertTo-MeasurementRow emit an 18-metric block when the captured CSV contains the new metrics
  • add §4.3 (HighFrameRate soak) to docs/runbook/capturing-measurements.md
  • run a 10-minute HighFrameRate capture under MultiTagSoakScenario --profile HighFrameRate, append the 18-metric row block to docs/reviews/phase-1-measurements.md
  • add tests for: Frame field round-trip, SimulatedCamera payload size, SimulatorProfilesValidator rejecting each invalid case, MeasurementExtraction helpers against a fixture CSV

Non-Scope

  • buffer pooling (ArrayPool<byte>, slab allocator, etc.) — Phase 2
  • moving frame data out of AppState.LatestFrame
  • compression, encoding, or any non-raw payload format
  • ROI / crop / downscale / image processing
  • defect-marker pixels in the frame content (defects remain a metadata field)
  • new InspectionPrototype counters (the slice's metric story uses existing System.Runtime counters only)
  • a new IScenarioMultiTagSoakScenario with --profile HighFrameRate is the capture path
  • adapting Normal or MultiTag profiles to use larger frames — they ship 640 × 480 × 1 so existing rows 0/0a/0b/slice-1-1 remain reproducible

Touched Projects

  • src/InspectionPrototype.DomainFrame record fields
  • src/InspectionPrototype.Application — nothing (the consumer doesn't care about size)
  • src/InspectionPrototype.InfrastructureSimulatorProfile, SimulatorProfilesOptions, SimulatorProfilesValidator, SimulatedCamera.ProduceFramesAsync
  • src/InspectionPrototype.Appappsettings.json (HighFrameRate profile + new fields on existing profiles)
  • src/InspectionPrototype.PresentationMainViewModel.CurrentFrame, dispatcher marshalling, frame→bitmap conversion
  • src/InspectionPrototype.AppMainWindow.xaml adds the <Image> element
  • tests/InspectionPrototype.TestsFrame field tests, SimulatedCamera payload-size tests, validator tests, MainViewModel bitmap-update test
  • tools/MeasurementExtraction.psm1 — two new helpers, ConvertTo-MeasurementRow emits 18-metric block
  • tests/Tools/MeasurementExtraction.Tests.ps1 — Pester tests for the new helpers
  • docs/runbook/capturing-measurements.md — §4.3
  • docs/reviews/phase-1-measurements.md — new row block
  • docs/captures/ — the new CSV evidence

AI Tool Guidance

Three Copilot passes; one-pass-per-session protocol as in TASK-1.5.

  1. Domain + simulator — Frame fields, SimulatorProfile fields + validator, HighFrameRate seed config, SimulatedCamera payload generation. Tests for the simulator path. NO UI work, NO measurement extraction work.
  2. UI + measurement extraction — MainViewModel CurrentFrame property, MainWindow Image binding, dispatcher marshalling. Pester-tested MeasurementExtraction helpers + 18-metric ConvertTo-MeasurementRow output. NO captures.
  3. Capture + row block + runbook — Run the 10-min HighFrameRate scenario with sleep disabled, append the row block, write §4.3, update CLAUDE.md / roadmap-progress.md. NO code changes.

Acceptance Criteria Mapping

The implementation must satisfy all acceptance criteria from SLICE-1.2:

  • Pass 1 covers criteria 1, 2, 3, 4, 6 (frames.dropped, frames.ingested counts) and the simulator/validator portions of 10 and 11
  • Pass 2 covers criterion 5, criterion 9, and the extraction-helper portion of 10
  • Pass 3 covers criteria 7 and 8

Copilot Agent Prompts

Pass 1 — Domain + simulator

You are implementing Pass 1 of TASK-1.2 in this repository: extend Frame and
SimulatorProfile with Width/Height/BytesPerPixel, add the HighFrameRate seed
profile, and update SimulatedCamera.ProduceFramesAsync to allocate and fill
real byte[] payloads. NO UI work, NO measurement-extraction work.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.2-real-frame-payloads.md          (the requirements)
- docs/tasks/TASK-1.2-implement-real-frame-payloads.md (this task)
- src/InspectionPrototype.Domain/Contracts/Frame.cs
- src/InspectionPrototype.Application/State/SimulatorProfile.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesOptions.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesValidator.cs (or wherever profile validation lives)
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedCamera.cs
- src/InspectionPrototype.App/appsettings.json

Spec acceptance criteria 1, 2, 3, 4, parts of 6, and the simulator/validator
portions of 10 and 11 are the definition of done for this pass.

## Scope of this pass

Domain record extension, profile extension + validation, seed config, simulator
allocation/fill, simulator tests. NO MainViewModel changes, NO MainWindow.xaml
changes, NO MeasurementExtraction.psm1 changes.

## Deliverables

1. Frame record (src/InspectionPrototype.Domain/Contracts/Frame.cs):
   - add three properties to the record:
       int Width
       int Height
       byte BytesPerPixel
   - keep PreviewPayload as byte[]? (no nullability change)
   - update any existing test or stub that constructs Frame inline; pick small
     defaults (16 x 16 x 1) so tests don't allocate large arrays

2. SimulatorProfile (src/InspectionPrototype.Application/State/SimulatorProfile.cs):
   - add three properties:
       int FrameWidth (default 640)
       int FrameHeight (default 480)
       byte BytesPerPixel (default 1)
   - update the corresponding SimulatorProfilesOptions / serialization shape
     so JSON binding picks them up
   - existing profiles in appsettings.json (Normal, MultiTag) inherit the
     defaults (640 x 480 x 1) — explicit values are fine but not required

3. Validator (SimulatorProfilesValidator):
   - reject any profile where:
       FrameWidth < 1 OR FrameHeight < 1
       BytesPerPixel not in {1, 2, 3, 4}
       checked((long)FrameWidth * FrameHeight * BytesPerPixel) > int.MaxValue
   - validation messages name the offending profile name and field
   - register via services.AddOptions<SimulatorProfilesOptions>().Validate(...)
     with ValidateOnStart() — match the existing pattern

4. appsettings.json:
   - add a new profile entry "HighFrameRate":
       FrameIntervalMs: 33
       FrameWidth: 2048
       FrameHeight: 1024
       BytesPerPixel: 1
       TelemetryIntervalMs: 100
       MotionSpeedUnitsPerSecond: 50.0
       DefectProbabilityPerFrame: 0.05
       ConnectionFailureProbability: 0.05
   - existing Normal and MultiTag profiles do NOT need to be edited — defaults
     of 640 / 480 / 1 will apply

5. SimulatedCamera.ProduceFramesAsync:
   - read FrameWidth/FrameHeight/BytesPerPixel from
     _profileProvider.CurrentProfile per tick (so profile changes are picked
     up on the next frame)
   - allocate: var payload = new byte[width * height * bpp]
   - fill via the deterministic gradient described in the spec:
       for (int y = 0; y < height; y++)
           for (int x = 0; x < width; x++) {
               byte v = (byte)((x + y + (int)frame.Sequence) & 0xFF);
               int o = (y * width + x) * bpp;
               for (int b = 0; b < bpp; b++) payload[o + b] = v;
           }
   - construct Frame with Width/Height/BytesPerPixel and PreviewPayload set
   - the existing channel write path is unchanged

6. Tests under tests/InspectionPrototype.Tests/:
   - FrameTests.cs (or extend if exists): a Frame constructed with
     Width=16, Height=16, BytesPerPixel=1 reports those values; payload of
     length 256 round-trips
   - SimulatorProfilesValidatorTests.cs (or extend): each of the four
     reject cases produces ValidateOptionsResult.Fail naming the profile
   - SimulatedCameraPayloadTests.cs (new):
       * Construct a SimulatedCamera with a FakeSimulatorProfileProvider
         returning a profile with width=8, height=8, bpp=1
       * Start streaming, await one frame off the channel
       * Assert frame.Width == 8, frame.Height == 8, frame.BytesPerPixel == 1
       * Assert frame.PreviewPayload is non-null with Length == 64
       * Assert the gradient pattern: payload[0] == (byte)(0 + 0 + sequence),
         payload[7] == (byte)(7 + 0 + sequence) (where sequence is the
         frame's actual Sequence value)

## Constraints

- Do NOT introduce ArrayPool, MemoryPool, or any pooling. The allocation
  pressure is what this slice exists to expose.
- Do NOT add WPF references to the Domain or Infrastructure projects.
- Do NOT rename PreviewPayload or change its type from byte[]?.
- Do NOT touch MainViewModel, MainWindow.xaml, or any view-side code.
- Do NOT touch tools/ or docs/ in this pass.
- Test fixture sizes must be tiny (<=16 x 16) — do not introduce 2 MP
  allocations into the test process; that is what criterion 11 asserts.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Manual smoke test:
  - launch interactively (no --scenario flag)
  - in a fresh dev window, attach dotnet-counters monitor and watch
    frames.ingested tick over once you click Connect → Start Run
  - Confirm: app does not crash, frames.ingested increments. Preview is still
    blank — UI rendering lands in Pass 2.
  - Confirm: switching to the HighFrameRate profile via the existing profile
    selector does not throw. frames.ingested rate jumps from ~2 fps (Normal,
    FrameIntervalMs=500) to ~30 fps (HighFrameRate, FrameIntervalMs=33).

## Report format when finished

- files created and modified
- confirmation that all existing tests pass plus new payload/validator tests
- a single commit hash
- commit message: "feat(sim): add Frame.{Width,Height,BytesPerPixel}, HighFrameRate profile, real byte[] payloads (pass 1/3 of TASK-1.2)"

Pass 2 — UI + measurement extraction

You are implementing Pass 2 of TASK-1.2. Pass 1 (Frame fields, SimulatorProfile
fields, HighFrameRate seed, SimulatedCamera payload generation) is already
merged. This pass adds the UI render path (MainViewModel.CurrentFrame +
MainWindow.xaml binding) and the measurement-extraction helpers
(Get-GcPauseP95, Get-LohAllocRateAvg) so captures produce 18-metric blocks.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.2-real-frame-payloads.md     (criteria 5, 9; verification on dispatcher work)
- src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs
- src/InspectionPrototype.App/MainWindow.xaml
- src/InspectionPrototype.Application/Abstractions/IAppStateStore.cs
- tools/MeasurementExtraction.psm1   (existing ConvertTo-MeasurementRow)
- tests/Tools/MeasurementExtraction.Tests.ps1   (existing Pester pattern)
- docs/captures/demo-baseline-2026-04-23.csv   (a fixture CSV containing dotnet.gc.pause.time and dotnet.gc.heap.size rows for the helpers' test)

Pass 1 must be merged. Confirm by inspecting Frame and a fresh build of
SimulatedCamera before starting.

## Scope of this pass

UI binding + dispatcher marshalling, measurement-extraction helpers, Pester
tests. NO captures, NO scenario or simulator changes.

## Deliverables

1. MainViewModel.cs:
   - add a property:
       public WriteableBitmap? CurrentFrame
       {
           get => _currentFrame;
           private set { _currentFrame = value; OnPropertyChanged(); }
       }
       (use the existing OnPropertyChanged pattern in the file)
   - subscribe to IAppStateStore.StateChanged in the constructor (or wherever
     other state-driven properties are projected). On every state change with
     a non-null state.LatestFrame.PreviewPayload, marshal to the dispatcher:
       Application.Current.Dispatcher.InvokeAsync(() => UpdateCurrentFrame(frame));
   - UpdateCurrentFrame(Frame frame):
       * Compute PixelFormat from BytesPerPixel:
           1 -> PixelFormats.Gray8
           2 -> PixelFormats.Gray16
           3 -> PixelFormats.Bgr24
           4 -> PixelFormats.Bgra32
       * If CurrentFrame is null OR its PixelWidth/PixelHeight/Format does not
         match the new frame, allocate a NEW WriteableBitmap with the right
         shape and assign to CurrentFrame
       * Otherwise reuse the existing WriteableBitmap
       * Lock the bitmap, call WritePixels(new Int32Rect(0, 0, w, h),
         frame.PreviewPayload, stride: w * bpp, offset: 0), unlock
       * Do NOT call OnPropertyChanged after WritePixels — WriteableBitmap
         marks itself dirty and the Image control rebinds automatically
   - in scenario mode (no main window) the dispatcher is still spinning;
     subscriptions must NOT be skipped — the dispatcher work is the cost
     this slice exists to measure

2. MainWindow.xaml:
   - locate the existing preview area (placeholder or empty panel)
   - add (or replace the placeholder with):
       <Image Source="{Binding CurrentFrame}" Stretch="Uniform" />
   - if the area has fixed dimensions, leave them — the bitmap will scale
   - DO NOT add any other XAML changes. Existing layout is preserved exactly.

3. tools/MeasurementExtraction.psm1:
   - add and export Get-GcPauseP95:
       function Get-GcPauseP95 {
           [CmdletBinding()]
           param([Parameter(Mandatory)][object[]] $Csv)
           $pauseRows = $Csv | Where-Object {
               $_.'Counter Name' -match 'dotnet\.gc\.pause\.time'
           } | ForEach-Object { [double]$_.'Mean/Increment' }
           if ($pauseRows.Count -eq 0) { return 0.0 }
           $sorted = $pauseRows | Sort-Object
           $idx = [int][math]::Ceiling($sorted.Count * 0.95) - 1
           if ($idx -lt 0) { $idx = 0 }
           return [math]::Round($sorted[$idx] * 1000.0, 2)   # seconds -> ms
       }
   - add and export Get-LohAllocRateAvg:
       function Get-LohAllocRateAvg {
           [CmdletBinding()]
           param([Parameter(Mandatory)][object[]] $Csv)
           # dotnet.gc.last_collection.heap.size[gc.heap.generation=loh] is a Metric (gauge),
           # not a Rate. Average byte/sec is computed from successive deltas
           # over their inter-sample times.
           $samples = $Csv | Where-Object {
               $_.'Counter Name' -match 'dotnet\.gc\.last_collection\.heap\.size.*generation=loh'
           } | ForEach-Object {
               [pscustomobject]@{
                   Time = [datetime]::Parse($_.Timestamp)
                   Bytes = [double]$_.'Mean/Increment'
               }
           } | Sort-Object Time
           if ($samples.Count -lt 2) { return 0.0 }
           $totalBytes = 0.0; $totalSec = 0.0
           for ($i = 1; $i -lt $samples.Count; $i++) {
               $delta = $samples[$i].Bytes - $samples[$i-1].Bytes
               # only count GROWTH; collections cause negative deltas which are
               # not "allocations"
               if ($delta -gt 0) { $totalBytes += $delta }
               $totalSec += ($samples[$i].Time - $samples[$i-1].Time).TotalSeconds
           }
           if ($totalSec -le 0) { return 0.0 }
           return [math]::Round($totalBytes / $totalSec, 0)
       }
   - update ConvertTo-MeasurementRow to call both helpers and append two rows
     to the markdown table:
       | gc-pause-p95 (ms)       | <Get-GcPauseP95 result>     |
       | LOH-alloc-rate avg (B/s)| <Get-LohAllocRateAvg result>|
   - guard against absent rows: if neither helper finds matching CSV rows
     (older captures predate this work), emit "—" instead of "0" so the
     reader can tell "absent" from "actually-measured-zero"

4. tests/Tools/MeasurementExtraction.Tests.ps1:
   - extend the existing test file with five new tests:
       Test "GcPauseP95_OnFixtureWithKnownPauses_ReturnsCorrectP95":
         use a synthetic Csv array with 100 dotnet.gc.pause.time rows whose
         Mean/Increment values are 0.001..0.100 in 0.001 increments. Expect
         P95 = 0.095 seconds = 95 ms. Allow ±0.5 ms tolerance for ceiling.
       Test "GcPauseP95_OnEmptyCsv_ReturnsZero":
         pass [], expect 0.0.
       Test "LohAllocRateAvg_OnGrowingHeap_ReturnsAverageGrowthPerSecond":
         synthetic 10 rows over 10 s with heap growing 1 MB/s. Expect ~1 048 576.
       Test "LohAllocRateAvg_OnShrinkingHeap_ReturnsZero":
         synthetic 10 rows where heap value strictly decreases. Expect 0.0
         (no growth = no allocations counted).
       Test "ConvertTo-MeasurementRow_AppendsTwoNewMetrics_WhenCsvHasGcRows":
         feed a fixture CSV that has both dotnet.gc.pause.time and the LOH
         heap size counter; assert the markdown output contains both
         "gc-pause-p95" and "LOH-alloc-rate avg" rows
       Test "ConvertTo-MeasurementRow_EmitsDashWhenGcMetricsAbsent":
         feed a fixture with only InspectionPrototype rows; assert the two
         new rows show "—" rather than "0"

## Constraints

- Do NOT change the IAppStateStore subscription pattern. MainViewModel already
  subscribes to StateChanged; this pass adds work to that subscription, not
  a new subscription.
- Do NOT capture frames in any field other than the per-update local; do NOT
  hold a reference to Frame.PreviewPayload after WritePixels. The byte[]
  is owned by the producer once-written; the consumer copies into the
  WriteableBitmap and lets the byte[] be garbage-collected.
- Do NOT introduce a new InspectionPrototype counter. The slice's metric
  surface is entirely System.Runtime.
- Do NOT modify Capture-Measurements.ps1 in this pass beyond what's
  required for ConvertTo-MeasurementRow's new output. The orchestrator
  already calls the function; an updated function automatically flows
  through.
- Tests must NOT actually launch the WPF app or instantiate Application.
  WriteableBitmap can be constructed in xUnit with [Fact] decorated as
  [STAThread] only if absolutely required — otherwise just unit-test
  the byte[] -> bitmap-stride math separately and skip the WriteableBitmap
  step in tests.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Plus:
  - Pester: Invoke-Pester tests/Tools/MeasurementExtraction.Tests.ps1
    All five new tests pass plus the five existing partial-CSV tests.
  - Manual smoke test: launch the app interactively, switch to the
    HighFrameRate profile, click Connect → Home → Start Run, observe the
    preview area showing a continuously updating gradient pattern. The
    pattern should visibly change frame-to-frame (because the Sequence
    offset shifts the gradient).
  - Run a 60-second smoke capture under the existing automation:
      tools/Capture-Measurements.ps1 -Scenario MultiTagSoak `
        -Duration 60 -Profile HighFrameRate `
        -OutputCsv docs/captures/_smoke.csv `
        -CommitHash $(git rev-parse --short HEAD) -AllowDirty
    Verify the printed row block has 18 metrics, including gc-pause-p95
    and LOH-alloc-rate. Delete the smoke CSV before commit.

## Report format when finished

- files created and modified
- confirmation all C# tests + Pester tests pass
- the smoke-capture stdout (the 18-metric markdown block included as evidence)
- a single commit hash
- commit message: "feat(ui,tools): bind WriteableBitmap preview, add gc-pause-p95 + LOH-alloc-rate to capture (pass 2/3 of TASK-1.2)"

Pass 3 — Capture + row block + runbook §4.3

You are implementing Pass 3 of TASK-1.2, the final pass. Passes 1 and 2 are
merged. This pass runs the 10-min HighFrameRate capture, appends the row
block, writes runbook §4.3, and updates session-handoff documents. NO code
changes — Passes 1 and 2 own those.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.2-real-frame-payloads.md   (criteria 7, 8)
- docs/runbook/capturing-measurements.md        (existing §3a, §4.1, §4.2 to mirror for §4.3)
- docs/reviews/phase-1-measurements.md          (slice-1-1-multi-tag-telemetry row to mirror)
- CLAUDE.md, docs/reviews/roadmap-progress.md
- tools/Capture-Measurements.ps1                (the orchestrator that runs the capture)

## Scope of this pass

Capture, table edit, runbook §4.3, session-handoff updates.

## Deliverables

1. Disable system sleep BEFORE capturing (TASK-1.5.1 follow-up runbook
   discipline). On the capture machine:
       powercfg /change standby-timeout-ac 0
       powercfg /change monitor-timeout-ac 0
   Note the previous values in the session-log entry so they can be restored
   after the capture.

2. Run the 10-minute HighFrameRate capture:
       $date = Get-Date -Format 'yyyy-MM-dd'
       tools/Capture-Measurements.ps1 -Scenario MultiTagSoak `
         -Duration 600 -Profile HighFrameRate `
         -OutputCsv "docs/captures/slice-1-2-high-fps-$date.csv" `
         -CommitHash $(git rev-parse --short HEAD) `
         -OperatorDelayMs 0 `
         -SliceTag slice-1-2-real-frame-payloads
   Confirm:
       * app exit code 0
       * frames.dropped == 0 (any non-zero is a failure of criterion 6)
       * frames.ingested >= 17 500 (criterion 6)
       * 18 metrics in the printed row block, including gc-pause-p95 and
         LOH-alloc-rate avg (criterion 7)
       * tags.active == 50 in the CSV (regression check vs the SLICE-1.1
         workdir bug)

   If any of these fails, STOP — do not proceed to the table edit. Capture
   the failure mode, file an issue, hand off.

3. Append row block to docs/reviews/phase-1-measurements.md:
   - new "### Row — slice-1-2-real-frame-payloads" header under "## Phase 1 rows"
     and AFTER the existing "### Row — slice-1-1-multi-tag-telemetry" subsection
   - mirror the SLICE-1.1 row's header (Scenario / Capture / Per-tag rate /
     Commit / Date), but the per-tag-rate file is irrelevant for this slice;
     replace that line with a one-line "**Profile:** HighFrameRate (2 MP × 8-bit grayscale @ 30 fps)"
   - 18-metric table:
       Slice = "slice-1-2-real-frame-payloads"
       Baseline = row 0b values for the 16 metrics in row 0/0a/0b's
                  set + "—" for gc-pause-p95 and LOH-alloc-rate avg (row 0b
                  predates these metrics)
       After = the captured values
       Delta = after − baseline (or after ÷ baseline for rates), "—" where
              Baseline is "—"
   - "### Notes on slice-1-2-real-frame-payloads" subsection with three points:
       (a) Why row 0b is the baseline reference and what the deltas isolate.
       (b) The new gc-pause-p95 and LOH-alloc-rate-avg metrics — establishing
           the LOH-pressure baseline that future allocation-pooling slices
           will delta against.
       (c) Whether frames.dropped stayed at 0 throughout. If anything else
           surprised — runs.completed throughput change, working-set growth
           pattern under sustained 60 MB/s LOH, etc — note it here.

4. Add §4.3 to docs/runbook/capturing-measurements.md:
   - title: "### 4.3 High-frame-rate soak — slice-1.2, `HighFrameRate` profile"
   - placement: between the existing §4.2 "Multi-tag soak" and "§4.3+ — pending
     Phase 1 scenarios". Update §4.3+'s entry to remove "real frame payloads"
     since this slice now occupies that slot; the placeholder for §4.4
     (encoder-rate motion) and §4.5 (storm/soak) stays
   - content covers:
       * one-paragraph rationale (links back to SLICE-1.2 spec)
       * 10-minute step list mirroring §4.2 but with profile = HighFrameRate
         and an explicit "10:00" stopwatch mark instead of 30 minutes
       * sanity check: tags.active == 50 (the workdir bug regression check),
         frames.ingested rate ≈ 30 fps in the steady-state portion of the
         run, frames.dropped == 0
       * the row block is 18-metric, not 16 — name the two new fields and
         where they come from in the CSV (System.Runtime provider, already
         collected)
       * "Implemented by: MultiTagSoakScenario with --profile HighFrameRate"
         (no new IScenario class)

5. Update CLAUDE.md "Current position" block:
   - Phase: 1 (Simulator to scale) — SLICE-1.2 complete
   - Last completed slice: TASK-1.2 Pass 3 — captured 10-min HighFrameRate
     soak (2 MP × 8-bit @ 30 fps), 18-metric row block appended, runbook
     §4.3 added; commit <hash>
   - Next action: SLICE-1.3 (Encoder-rate motion); spec needs to be written
   - Blocked on: nothing
   - Last updated: <today's date>

6. Append a session-log entry to docs/reviews/roadmap-progress.md under
   today's date naming the CSV path, gc-pause-p95 ms value, LOH-alloc-rate-avg
   value, runs.completed count, and the commit hash. Mark SLICE-1.2 as
   Completed in the progress table.

7. Restore powercfg settings after capture finishes:
       powercfg /change standby-timeout-ac <previous_value_in_minutes>
       powercfg /change monitor-timeout-ac <previous_value_in_minutes>

## Constraints

- Do NOT make any code or test changes in this pass.
- Do NOT modify the SLICE-1.2 spec — the row block is the slice's exit gate
  evidence, not an opportunity to amend the spec.
- Do NOT skip the 10-minute capture. A 60-second smoke run does not satisfy
  criterion 6 — that needs frames.ingested >= 17 500, which requires the full
  duration.
- Do NOT capture without disabling system sleep first (deliverable 1). If
  your machine sleeps mid-capture, the capture is invalid (per the
  TASK-1.5.1 follow-up note).

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Plus:
  - the docs/captures/slice-1-2-high-fps-<date>.csv file exists and is committed
  - the row block is in docs/reviews/phase-1-measurements.md with all 18
    metrics filled, frames.dropped = 0
  - §4.3 renders correctly (no broken markdown tables or links)
  - CLAUDE.md current-position block reflects SLICE-1.2 closure

## Report format when finished

- files created and modified
- the captured row block (the 18-metric markdown table) included in the report
- gc-pause-p95 and LOH-alloc-rate-avg values, with one-sentence interpretation
  (e.g. "p95 GC pause was X ms across the 10-min run; LOH alloc rate averaged
  Y MB/s, consistent with 2 MB × 30 fps = 60 MB/s")
- a single commit hash
- commit message: "feat(measurements): close SLICE-1.2 with high-fps row block; runbook §4.3 (pass 3/3 of TASK-1.2)"

Operator notes

  • One pass per Copilot session. Same protocol as TASK-1.5.
  • Pass 1's allocation pattern is intentionally unflattering. Plain new byte[] per frame, no pooling. The agent may "helpfully" suggest ArrayPool<byte> — refuse. The slice's whole point is to expose unfiltered LOH pressure so a future slice has evidence justifying the pool.
  • Pass 2's WriteableBitmap reuse is the load-bearing optimization. A naive implementation re-allocates the bitmap on every frame; that creates a parallel allocation stream that masks the simulator's allocation rate in the captured metrics. Verify by inspecting MainViewModel.UpdateCurrentFrame — exactly one new WriteableBitmap(...) per profile change, not one per frame.
  • Pass 3 must run with system sleep disabled. Without that discipline, the LOH-alloc-rate-avg metric is diluted by any sleep duration, masking what the slice is trying to measure. Skip the capture and re-run if the machine sleeps mid-run.
  • The HighFrameRate profile is not a "production" profile — it's a measurement profile. Existing scenarios (DemoBaseline, MultiTagSoak) continue with their 640 × 480 × 1 defaults so rows 0/0a/0b/slice-1-1 stay reproducible.
  • Update the index files only at the end of the phase, not per-slice. Same rationale as earlier tasks.

Docs-first project memory for AI-assisted implementation.