Skip to content

Fix out-of-bounds boundary reflection for tiny frames in motion feature - #1582

Open
kjg0724 wants to merge 12 commits into
Netflix:masterfrom
kjg0724:fix-motion-mirror-oob
Open

kjg0724 wants to merge 12 commits into
Netflix:masterfrom
kjg0724:fix-motion-mirror-oob

Conversation

@kjg0724

@kjg0724 kjg0724 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #1580.

Bug

mirror(int idx, int size) reflects row/column indices at frame boundaries for the
5-tap (radius-2) motion filter. It's a single-bounce reflect-101 implementation:

static inline int mirror(int idx, int size)
{
    if (idx < 0) return -idx;
    if (idx >= size) return 2 * size - idx - 2;
    return idx;
}

For size < 3 this returns indices outside [0, size) — e.g. mirror(-2, 1) = 2,
out of range for a 1-row/1-column plane. The identical function was independently
duplicated in 4 places (integer_motion.c, x86/motion_avx2.c, x86/motion_avx512.c,
arm64/motion_neon.c), plus a differently-structured CUDA version, plus the shared
float convolution helpers used by float_motion and (via convolution_f32_avx_*)
vif_tools.c.

The float convolution path additionally has an out-of-bounds write: with radius 2,
convolution_x_c_s's borders_right = width - 3 goes negative for width < 3, so the
trailing edge loop starts before dst[0]. The equivalent bug exists in
convolution_avx.c's vertical pass (i_vec_end = height - radius goes negative,
writing tmp[-tmp_stride + j]).

This isn't only a 1×1-frame issue. vif_tools.c calls convolution_f32_avx_s with
filter widths up to 17 (radius 8), so on any AVX2 host the same OOB write triggers for
any dimension below 8, at every VIF scale — not just degenerate frames.

Fix

  • motion_mirror() (new, replacing 4 duplicated mirror() copies): reflects
    repeatedly instead of once, so any size >= 1 is safe. size == 1/size <= 0
    degenerate to 0 (the unique valid extension of a 1-sample signal — matches
    OpenCV's BORDER_REFLECT_101 and scipy's mode='mirror').
  • Renamed from mirror to motion_mirror and consolidated into integer_motion.h
    (all 4 CPU files already include it). The rename is required, not stylistic: the CUDA
    translation unit also includes this header and already owns a mirror symbol with
    different (intentionally unrelated, untouched) reflect-even semantics — reusing the
    bare name would collide under nvcc.
  • convolution_mirror() (new, in convolution_internal.h): same fix, kept separate
    from motion_mirror since this header serves other float features with their own
    style and no existing dependency on integer_motion.h.
  • CUDA's own mirror(): added if (sup == 1) return 0; — one line, its sup >= 2
    formula is untouched.
  • convolution.c / convolution_avx.c: clamped the edge-loop bounds so tiny
    width/height can't write out of bounds, without changing anything for normal sizes.

Non-regression is structural, not just tested. For the real call contract
(idx in [-2, size+1], 5-tap/radius-2), a negative overshoot lands in [1,2] and a
positive one in [size-3, size-1] whenever size >= 3 — both already valid, so the
new multi-bounce loop takes exactly one iteration and computes the exact same
expression
as the old code. This is proven exhaustively in tests (below), not just
argued in the abstract.

Testing

  • Exhaustive old-vs-new formula proofs for all three reflection implementations
    (motion_mirror, convolution_mirror, and a host-side transcription of the CUDA
    formula), comparing every size in [3, 8192] against every in-contract offset —
    mechanically proves zero behavior change for size >= 3.
  • Pipeline tests at 8 tiny sizes (1×1 through 8×2) across multiple bit depths,
    run through the real motion/float_motion feature extractors, asserting success,
    finite scores, and — for each size — that the score obtained via forced-scalar
    dispatch matches the score obtained via best-available SIMD dispatch (bit-exact for
    the integer path; a documented, generously-tight tolerance for the float path, since
    AVX's -mfma can contract the shared edge-filter's multiply-add into an FMA and
    change the last bit or two versus the non-FMA scalar build — unrelated to this fix).
    This is the only runtime coverage the AVX2/AVX512 motion kernels get from this test
    suite, since no x86 hardware was available anywhere in developing this fix (see
    below).
  • Canary-buffer bounds tests for the OOB-write fix: guard regions before/after
    the real output buffer, asserted intact after the call, for the scalar convolution
    path (verified with a negative control — temporarily removing the clamps reproduces
    the failure) and, #if ARCH_X86-gated, the AVX path.
  • Live ASan red-then-green demonstration, reproduced independently on two hosts/
    toolchains (macOS/Apple Clang arm64, Ubuntu 22.04/GCC 11.4.0 aarch64): built with
    -Db_sanitize=address,undefined, confirmed clean on the fix, then temporarily
    reverted just the two mirror function bodies to the old formula and confirmed a
    real heap-buffer-overflow abort in both the integer path
    (motion_score_pipeline_8) and the float path (convolution_y_c_s) — then restored
    and reconfirmed clean.
  • Full meson test suite: 25/25 on both hosts.
  • Python end-to-end golden-score regression suite (quality_runner_test.py): 67/67,
    run twice, on Ubuntu/GCC — no VMAF score on real video content moved.

What's not verified at runtime

No x86 or CUDA hardware was available anywhere in developing this fix.

  • x86 (AVX2/AVX512): verified only via cross-target clang -target x86_64-... -mavx2/-mavx512f -fsyntax-only/-c checks (proves it compiles and type-checks, not
    that it's correct at runtime) plus the exhaustive formula proofs and mechanical
    rename review. This PR's own test suite gives these kernels real coverage the moment
    CI runs it on an x86 runner.
  • CUDA: the one-line guard is exhaustively proven inert for sup >= 2 via a
    host-side transcription, but the actual .cu file has never been compiled. This
    repo's CI doesn't build the CUDA path, so a maintainer with CUDA access verifying the
    guard would be appreciated.

kjg0724 added 12 commits August 16, 2026 14:00
The 5-tap radius-2 boundary reflection helper used by the integer motion
feature was independently duplicated across four CPU implementations
(scalar, AVX2, AVX512, NEON), all sharing the same single-bounce formula
that reads out-of-bounds indices when width or height is less than 3
(Netflix#1580).

Replace the four local copies with one shared, loop-based reflect-101
implementation in integer_motion.h, which all four TUs already include.
Named motion_mirror rather than mirror to avoid colliding with the
unrelated __device__ mirror() in cuda/motion_score.cu, which also
includes this header transitively and implements different boundary
semantics that are out of scope here.

For size >= 3 with the offsets these callers ever pass (radius 2), the
new implementation takes at most one reflection and is bit-identical to
the old formula; only sizes below 3 change behavior, from out-of-bounds
reads to well-defined reflect-101 indices.

Also removes edge_16(), an unused dead-code helper in the same header
that shared the old broken formula and had no callers.
Exhaustively checks that motion_mirror matches the old single-bounce
formula for every size in [3, 8192] and every offset in the 5-tap
radius-2 call contract, proving the consolidation changed no output
for normal frame sizes. Also pins down the exact reflect-101 values
for size 1 and 2, the cases the fix specifically targets.
The CUDA device-side mirror() in motion_score.cu has the same class of
out-of-bounds bug as the CPU implementation for degenerate 1-pixel
dimensions, but with a different (reflect-even) formula. Add a minimal
guard so sup==1 maps every index to 0, without altering the existing
reflect-even behavior for sup>=2.

Also extends test_motion_mirror.c with a host-side transcription of both
the pre- and post-fix CUDA formulas, proving the fix is a no-op for
sup>=2 and degenerates safely for sup==1. This does not compile or link
any CUDA code.
…ilter

The float convolution path shared by ADM, VIF and float motion assumed
width and height are at least filter_width, and misbehaved below that.

convolution_internal.h reflected boundary taps with a single bounce, which
still lands outside [0, size) once size is smaller than the filter radius.
Replace it with convolution_mirror(), a repeated whole-sample symmetric
reflection that is safe for any size >= 1 and bit-identical to the previous
formula for size >= radius + 1.

convolution.c and convolution_avx.c additionally computed negative edge-loop
bounds for such sizes, so the trailing edge loops started at a negative index
and wrote before the start of dst/tmp. Clamp those bounds. In convolution_avx.c
the clamped horizontal trailing start is kept in a separate variable so that
the j_end argument of convolution_f32_avx_s_1d_h_scanline stays unraised;
raising it would push the scanline's own vector stores past the end of dst.

All clamps are no-ops for sizes at or above the filter width, and for the
sizes in between they only drop redundant writes of identical values, so
the computed output is unchanged.
Exhaustively checks that convolution_mirror matches the single-bounce
reflection it replaces for every size in [3, 8192] and every offset in
the 5-tap radius-2 call contract, proving the float convolution path
produces identical output for normal frame sizes.
The comment claimed the vertical trailing loop's start is i_vec_end's only
other use. It is not: i_vec_end is also the end bound of the vector loop.
State the actual reason the in-place raise is safe, so the comment still
guards against a maintainer collapsing the clamp into a bug.

Comment-only change; no behavior change.
The sizes below the 5-tap filter's radius were excluded because the
single-bounce mirror() went out of bounds there, making both the scalar
and the SIMD score meaningless. Now that the reflection is safe for any
size >= 1, those sizes must agree bit-exactly like every other size.
Drive both the integer and the float motion feature extractor end to end
at 1x1 through 8x2, for 8, 10, 12 and 16 bpc and for both the scalar and
the SIMD dispatch. There is no meaningful reference score at these sizes,
so what is asserted is that extraction succeeds, the score is finite and
two identical runs agree bit for bit; the memory-safety half of the check
comes from running the same binary under AddressSanitizer.

The float sub-test skips itself when the library is built with
-Denable_float=false, in which case the extractor is not registered.
convolution_f32_c_s() delegates to the AVX kernel and returns as soon as
AVX2 is available, so driving the float motion extractor with the CPU
flags left at their default only reached convolution_f32_avx_s() on an
x86 host. The scalar convolution_y_c_s()/convolution_x_c_s() pair went
unexercised there, which is precisely where the boundary reflection had
to be corrected.

Run the float sizes under both a zeroed and an unrestricted CPU mask, as
the integer test already did, and share the mask table between the two.
motion_mirror() and convolution_mirror() special-cased size == 1, where the
reflection period 2*(size-1) is zero and the loop would not terminate. Widen
the guard to size <= 1 so the functions are total rather than merely correct
over their call contract.

Both helpers are only ever reached from loops bounded by the same size
(for i < h / for j < w in integer_motion.c and the AVX2/AVX-512 motion
kernels, and the clamped border loops in convolution.c / convolution_avx.c),
so size is always >= 1 in practice and no reachable result changes.
The tiny-size pipeline tests ran the extractor twice per CPU mask and only
asserted the two runs agreed, which proves determinism but never compares one
dispatch against another. The x86 AVX2/AVX-512 motion kernels therefore had no
runtime verification at all; upstream CI runs meson test on an x86_64 runner,
so a cross-mask comparison is the one place those kernels get exercised.

Capture the cpu_masks[0] (mask 0, forced scalar) score for each (size, bpc) and
assert every other mask reproduces it. The integer path is compared exactly, as
test_motion_neon.c already does for NEON. The float path is compared with a
relative tolerance instead: convolution_avx.c is built with -mfma while
convolution.c is not, so the compiler contracts the shared convolution_edge_s()
accumulation into fused multiply-adds in the AVX translation unit only and the
two results legitimately differ in the last bits. A boundary regression moves
the score by O(1) and is still caught.
The border clamps in convolution.c and convolution_avx.c fix an out-of-bounds
write, not just an out-of-bounds read: below the filter width the unclamped
borders_left/borders_right (and the AVX i_vec_end/j_vec_end) went negative and
the trailing edge loop stored outside dst. Only a sanitizer would have caught a
regression there, and no sanitizer job exists in CI, so a later "simplification"
back to the buggy form would leave CI green.

Surround every destination buffer with guard elements holding a sentinel and
assert the sentinel survives. The scalar test covers convolution_x_c_s() and
convolution_y_c_s() over dimensions 1..4 and runs on every host. The AVX test
covers convolution_f32_avx_s(), _sq_s() and _xy_s(), guarding both dst and the
caller-supplied tmp scratch, and is compiled only under ARCH_X86 so it is inert
elsewhere.

Verified against the pre-fix code by removing each clamp in turn: the scalar
test reports "convolution_x_c_s wrote before dst" and "convolution_y_c_s wrote
before dst" respectively.
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 3, 2026
…holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lusoris added a commit to VMAFx/vmafx that referenced this pull request Sep 4, 2026
…ADR-1166) (#1223)

* fix(core): harvest and fix nine stale upstream Netflix/vmaf reports (ADR-1166)

Verify a batch of long-open Netflix/vmaf issues against this tree — the fork
diverged far enough (ADR-0700's `libvmaf/` -> `core/` rename, several C-to-C++
conversions, four fork-added GPU backends) that an upstream report is neither
automatically applicable nor automatically stale — and fix the subset that
still bites. The full triage table, including the ALREADY-FIXED and
NOT-APPLICABLE verdicts, is in
docs/research/1166-upstream-issue-harvest-2026-09-03.md.

Memory safety, all reachable from the public C API today:

* reported upstream as Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581):
  the reflect-101 fold in convolution_edge_s / _sq_s / _xy_s bounced an
  out-of-range tap exactly once, which only lands in range for
  size >= radius + 1; and convolution_x_c_s / convolution_y_c_s derived the
  trailing border bound as dim - (filter_width - radius), which goes negative
  for a plane narrower than the filter and starts the trailing loop at a
  negative index (heap underflow write). Two live paths reached those sizes:
  `--feature float_vif` on 9..15px frames (the guard admitted >= 9 but the
  four-scale ladder needs >= 16 — the binding constraint is scale 3), and
  `--feature float_motion` with motion_add_uv on a 4x4 YUV420P frame (the
  guard validated luma only while the blur runs at the 2x2 chroma dimensions).
  The fold is now iterative and bit-identical to the single bounce for every
  in-contract size; the borders are clamped; float_vif derives its floor from
  vif_get_min_dim(kernelscale); float_motion validates every plane it convolves.

* reported upstream as Netflix/vmaf#1580: the three fork-added Metal motion
  extractors were written after the Research-0094 sweep and never got the
  min-dim guard, so a 1- or 2-pixel-tall frame read out of bounds on device.

Correctness and contracts:

* reported upstream as Netflix/vmaf#1242: vmaf_model_feature_overload() leaked
  the caller's dictionary on the -ENOMEM path,
  vmaf_model_collection_feature_overload() swallowed the copy error and
  dereferenced *model_collection unchecked, and feature.h / model.h documented
  opposite ownership rules — one of the two readings a latent double free. All
  three public headers now state the implemented contract identically.
  Supersedes ADR-0806.

* reported upstream as Netflix/vmaf#1551, which retracts Netflix/vmaf#1422: the
  MSVC __builtin_clz shim used __lzcnt, which emits LZCNT with no runtime gate
  and silently retires as BSR on any x86-64 without ABM — wrong VIF and ADM
  log2 shifts, no fault, and invisible to CI because every hosted Windows
  runner has LZCNT. Now _BitScanReverse, with an architecture guard so an MSVC
  ARM64 leg compiles.

User-visible surfaces:

* reported upstream as Netflix/vmaf#743: the CLI wrote UTF-8 braille and a CSI
  erase to a Windows console it never configured, so the progress line was
  mojibake under every default code page. The console is switched to UTF-8 + VT
  for the run and restored on exit, with an ASCII fallback.

* reported upstream as Netflix/vmaf#1178: libvmaf.pc omitted the C++ runtime,
  so `pkg-config --static --libs libvmaf` produced a link line that fails with
  hundreds of undefined references — the reason ADR-0198's static FFmpeg
  reproducer had to add -lstdc++ by hand.

* reported upstream as Netflix/vmaf#1573: the nvcc fatbin include list used
  relative paths that stopped resolving at ADR-0700, and three shell-driven
  tool tests declared no `depends`, so a subset run built nothing and exited
  127.

Behaviour changes: float_vif now rejects frames below 16px in either dimension,
and float_motion with motion_add_uv rejects sub-minimum chroma planes. Both
convert previously undefined behaviour into a documented -EINVAL.

Regression tests: core/test/test_convolution_edge_small.c (NaN-poisoned guard
buffers; fails pre-fix), core/test/test_compat_clz.c,
core/test/test_model_feature_overload_ownership.c, core/test/test_spinner.cpp,
scripts/ci/check-msvc-clz-shim.sh (fails pre-fix), plus extended cases in
test_motion_min_dim.c and test_float_vif_min_dim.c, and a real static link in
the libvmaf-build-matrix pkg-config step.

Netflix golden scores unchanged: 76.66744 / 35.070245 / 7.985956
(271 passed, 12 skipped).

Confirmed but not batched, one docs/state.md row each: Netflix/vmaf#1564, #930,
off-by-one found while triaging Netflix/vmaf#1580.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(feature): clamp the SIMD convolution borders and close two guard holes

Three HIGH findings from an independent adversarial review of this
harvest. Each was reproduced before being fixed, and the regression test
fails on the pre-fix tree.

1. The Netflix/vmaf#1582 border clamp landed only on the scalar path.
   convolution_f32_c_s dispatches straight into convolution_f32_avx_s
   whenever AVX2 is present — every CI runner and the dev workstation —
   so the clamp this PR added was dead code on x86. The AVX2 and AVX-512
   twins derive the same `height - radius` split at three sites each and
   kept it unclamped. For a plane shorter than the radius that value is
   negative, so the trailing border loop starts at a negative row and the
   leading one runs past the end. Both are heap WRITES, not reads. All
   six sites now share the scalar clamp, which moved into
   convolution_internal.h as a static inline.

2. motion_filter_size=1 bypassed the minimum-dimension guard entirely.
   motion_check_min_dim gated its check on `effective_filter_size > 1`,
   but motion_blur_plane keeps filter_size = 5 for that value and only
   swaps in the FILTER_5_NO_OP_s coefficients, so the radius is still 2.
   A 1-row plane therefore reached the convolution in (1) through a
   documented public option with range 0..9. The guard now mirrors
   motion_blur_plane exactly: 3 taps only for motion_filter_size == 3,
   otherwise 5.

3. Odd-height 4:2:0 chroma planes were under-allocated by one row.
   motion_chroma_heights used `h / 2` while picture.c and the guard both
   use the ceiling `(h + 1) >> 1`, so motion_copy_and_blur overran ref,
   tmp and every MOTION_BLUR_RING blur buffer for both U and V. Even
   heights were unaffected, which is why neither golden fixture caught
   it.

Also removes a stray `} // namespace` inside the _WIN32 block of
core/tools/vmaf.cpp that closed a namespace never opened. It broke every
Windows build and was invisible on Linux, where the preprocessor drops
the block. This PR is a draft and drafts run no CI here, so nothing had
compiled it. The file now has exactly one namespace opener and one
closer, neither inside any conditional.

New test core/test/test_motion_convolution_oob.c drives float_motion
through the public vmaf_read_pictures entry point, because no existing
test could reach the dispatched SIMD path: test_motion_min_dim only
calls init(), and test_convolution_edge_small calls the scalar kernels
directly.

Verified both ways. The new test fails on the pre-fix tree and passes
after. Under -Db_sanitize=address the pre-fix tree reports
"heap-buffer-overflow ... WRITE of size 4" in convolution_f32_avx_s
reached from vmaf_read_pictures, "0 bytes after 32-byte region" — the
single-row buffer. Post-fix: zero sanitizer reports, the guard returns
-EINVAL, and the odd-height case scores cleanly.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

The twelve MEDIUM and LOW findings from the same review are not
addressed here and remain open on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): state the dictionary ownership contract per function, close two leaks

Fourth MEDIUM finding from the adversarial review of this harvest. The
Netflix/vmaf#1242 contract was still stated three different ways, and one
of them was a double free.

<libvmaf/feature.h> and docs/api/index.md both claimed that an unknown
feature_name never consumes the dictionary. That is true of
vmaf_use_feature, which resolves the name against the global extractor
registry and returns -EINVAL before touching it. It is NOT true of
vmaf_model_feature_overload, which matches feature_name against the
features of one particular model: a name that matches nothing there is
not an error, it is a successful no-op returning 0, and the tail
vmaf_dictionary_free consumes the dictionary anyway. A caller following
the old wording would free it a second time.

<libvmaf/libvmaf.h> already described vmaf_use_feature correctly.
<libvmaf/model.h> described the overloads correctly but then claimed its
rule "matches vmaf_use_feature", which is exactly the case where they
differ. All four surfaces now state the asymmetry explicitly and say why
it exists rather than papering over it.

vmaf_use_feature also leaked the caller's dictionary on two failure
paths: a failed vmaf_dictionary_copy returned without releasing the
source, and a failed vmaf_feature_extractor_context_create returned
without releasing the copy — that function frees only what it allocated
itself. Both leaked precisely when the documented contract told the
caller not to free, so nothing else could have released them.

Two cases added to test_model_feature_overload_ownership.c pin the
asymmetry from both sides: the model overload returning 0 and consuming
on an unknown name, and vmaf_use_feature returning -EINVAL and handing
the dictionary back. The suite is 8 tests and passes clean under
-Db_sanitize=address, which is where a regression would surface as a
double free rather than a silent contract violation.

meson test --suite=fast: 111 Ok, 0 Fail. Netflix golden gate: 271
passed, 12 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(compat): cover MSVC ARM64 in the __builtin_clz allowlist

The MSVC shim's architecture test was `_M_X64 || _M_IX86`, justified in
both the header comment and scripts/ci/check-msvc-clz-shim.sh by the
claim that `_BitScanReverse` is x86-only. Per the MSVC intrinsics
reference that is wrong: `_BitScanReverse` is available on x86, ARM, x64
and ARM64, and only `_BitScanReverse64` is restricted (to x64 and ARM64).
`__lzcnt` is the x86-only one, and it is not used here.

The header is the sole definition of `__builtin_clz` for integer_adm.c
and integer_vif.h, which sit on the generic scalar path and are compiled
for every target, so MSVC ARM64 matched no branch and failed to compile
outright rather than falling back to anything. The fork runs no MSVC
ARM64 CI leg, so the break was latent.

The allowlist now enumerates every architecture MSVC targets, selects
`_BitScanReverse64` on x64/ARM64 and keeps the two-step 32-bit
reconstruction elsewhere. The gate now joins the guard's continuation
lines before matching (the guard legitimately spans several lines) and
asserts the ARM64 arm specifically, so the allowlist cannot be narrowed
again; both that narrowing and an `__lzcnt` reintroduction were
negative-tested against it. Header and gate comments corrected to the
documented architecture matrix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(metal): fold the motion mirror iteratively and align the VIF floor

Round-4 review findings; each checked against the code before acting,
and one did not hold up and is recorded as such.

Metal motion kernels — the round-3 guard was insufficient and the real
defect was in the kernel. integer_motion.metal, float_motion.metal and
integer_motion_v2.metal load a TILE_W x TILE_H = 20x20 threadgroup tile
at origin `bid * 16 - 2`, so the mirror helper receives indices up to
`16*bid + 17`, far outside the 5-tap neighbourhood it appears to serve,
and a single bounce only lands in range when `idx <= 2 * (sup - 1)`.
Enumerated over the real tile span, the single-bounce form read out of
bounds for every dimension in 1..9 AND for exactly 17 — at 17 the last
workgroup reaches idx 33 while 2*(17-1) = 32, folding to -1. A 3x3
floor closes neither 4..9 nor 17, so all three kernels now fold
iteratively, as the CPU scalar path already does in
convolution_internal.h. Verified over dims 1..299 across the full tile
span: always in range, always terminating, and bit-identical to the
single bounce wherever one bounce sufficed, so no in-contract score
moves. The host-side guard comments claimed the 3x3 floor was what kept
the kernel in bounds, which was wrong; corrected.

integer_motion_v2.metal was also the last backend still using the wrong
reflection convention: `2 * sup - idx - 1` repeats the boundary row
where reflect-101 skips it. CPU, CUDA (PR #120 / T7-15), SYCL and HIP
all carry `- 2`, and the SYCL fix records the old form as a systematic
~2.6e-3 motion drift vs CPU on every frame after the first. Metal now
matches (ADR-0214 places=4). The ADM kernels' `- 1` was checked and
deliberately left alone — ADM legitimately uses whole-sample
reflection, matching adm_tools.c::dwt2_src_indices_filt_s, CUDA's
calculate_indices() and the SYCL twin.

float_vif — all four GPU backends sat below the CPU floor. The CPU
requires vif_get_min_dim() = 16 at the default kernelscale (the binding
constraint is scale 3: max(9,10,12,16)). Metal checked only
`scale_w[FVIF_SCALES-1] == 0`, i.e. `w >> 3 == 0`, an effective floor
of 8; CUDA, HIP and SYCL had no dimension floor at all, halving to
scale 3 unchecked. All four now derive the floor from
vif_get_min_dim(), the CPU's own source of truth, so the 8..15px range
that walks the reflect-101 mirror out of the plane at scale 3 is
rejected uniformly. vif_tools.h gained an `extern "C"` guard — without
it the C++ (SYCL) and Objective-C++ (Metal) callers demand mangled
symbols against the C vif_tools.c. It was previously included only by
C translation units.

vmaf.cpp — `--help` and `--version` left the Windows console in UTF-8 +
VT mode. WindowsConsoleGuard was an automatic local whose comment
claimed it restored on every exit path. It did not: cli_parse
terminates via usage_exit(), which is [[noreturn]] and calls exit(),
and exit() does not destroy objects with automatic storage duration.
Objects with static storage duration ARE destroyed by exit()
([basic.start.term]), so the guard is now static and the restore runs
on the exit() paths, the `goto cleanup` spine and a normal return
alike. POSIX is unaffected (the block is #ifdef _WIN32).

check-msvc-clz-shim.sh was evadable by macro indirection: rules (1) and
(4) keyed on the call syntax `__lzcnt(`, so `#define LZ __lzcnt`
followed by `LZ(x)` reintroduced the instruction while still passing
the gate that exists to prevent exactly that. Both rules now match the
bare identifier, and rule (4) is scoped to source extensions because
core/src/feature/AGENTS.md legitimately discusses __lzcnt in prose.
Negative-tested: macro indirection, a narrowed architecture allowlist,
and a direct __lzcnt reintroduction all fail the gate.

libvmaf-build-matrix.yml — the static-link smoke test linked with bare
`cc` while the matrix builds with `ccache gcc-14` / `ccache clang-22`,
so it exercised a toolchain the archive was not produced with; now
${CC:-cc}. The accompanying LTO concern does not apply: b_lto is
meson-default false here and explicitly false on the SYCL/CUDA legs, so
the archive holds plain objects rather than LTO IR.

NOT a defect — the Libs.private libc++ detection. The review held that
keying on _LIBCPP_VERSION ignores an explicit -stdlib=libc++. Tested
against the installed meson: a probe project reading
cxx.get_define('FOO') under -Dcpp_args=-DFOO=42 reports 42, so compiler
checks do observe the project's cpp_args and the _LIBCPP_VERSION probe
therefore sees -stdlib=libc++ exactly as its comment claims. No change.

Verified: CPU build + fast suite 111 Ok / 0 Fail; CUDA lane rc=0 with
float_vif_cuda.c.o built; SYCL lane rc=0 under icpx with
float_vif_sycl.o built and no undefined vif_get_min_dim, confirming the
extern "C" linkage resolves. clang-tidy exit=0 on both files CI's
changed-files job globs (core/tools/vmaf.cpp,
core/src/feature/vif_tools.h); .mm and .metal are not in that glob and
cuda/ hip/ sycl/ are excluded by path. The Metal kernels are not
buildable on Linux — CI's macOS legs compile them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): bring this PR's own files back to zero clang-tidy warnings

The whole-tree ratchet exited 2 on this branch — four files were ABOVE
their baseline, which ADR-1142 treats as the PR's own regression to fix
in code, never to baseline away:

  core/src/feature/common/convolution_avx.c            0 -> 1
  core/src/feature/common/convolution_avx512.c         0 -> 1
  core/test/test_model_feature_overload_ownership.c    0 -> 1
  core/test/test_motion_convolution_oob.c              0 -> 11

convolution_avx.c / convolution_avx512.c —
readability-function-size on convolution_f32_avx{,512}_xy_s: "62 lines
including whitespace and comments (threshold 60)". The threshold counts
comments, and the clamp this PR added carried a five-line rationale
block duplicated at all six call sites while the same explanation
already lives on convolution_clamp_borders() in
convolution_internal.h. Replaced with a three-line pointer to that
definition at every site: the explanation is not lost, it is no longer
copied six times, and both functions drop back under the threshold. No
code changed.

test_model_feature_overload_ownership.c — readability-function-size on
run_tests. Each mu_run_test expands to several statements, and the two
cases added for the ownership asymmetry took it to eight, crossing
StatementThreshold 120. Split into run_guard_tests() and
run_consumption_tests(), grouped the way core/test/test_iqa_helpers.c
and test_cli_parse.c already group theirs.

test_motion_convolution_oob.c — eleven modernize-use-nullptr. This is a
C translation unit, and ADR-1138 keeps NULL in C TUs because MSVC's
documented /std:clatest C23 feature set has no `nullptr` while the
required Windows build compiles it with cl.exe. Wrapped in
NOLINTBEGIN/NOLINTEND(modernize-use-nullptr) with the ADR-1138
citation inline, matching the pattern test_model.c and test_output.c
already use.

Verified: clang-tidy reports 0 warnings on all four files, the CPU fast
suite is 112 Ok / 0 Fail, and both new tests pass individually. The
three stale-high entries the same run reported (convolution.c 2 -> 0,
test_float_vif_min_dim.c 8 -> 0, test_motion_min_dim.c 15 -> 0) are
left for CI's next measurement to be committed as the tightened
baseline, since the previous measurement was taken with these
regressions still present and so is not a usable baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): make the fold invariant portable and unregister the shell gate on Windows

Two platform failures on this branch, neither reproducible on Linux x86-64.

macOS arm64 — test_convolution_edge_small::test_large_plane_bit_identical
failed with "iterative fold moved an in-contract result". The fold was
NOT the cause. It is integer-only, so it cannot vary by platform; the
failure was floating-point contraction. The test's reference_convolve()
reads the file-scope `kFilter5`, which the compiler can constant-fold
and vectorize, while the library kernel receives an opaque
`const float *filter`. On any target with FMA in its baseline — every
arm64 — clang contracts `accum += filter[k] * src[...]` to an fma in one
and not necessarily the other, so the last bit legitimately differs.
x86-64 agreed only because FMA is not in its baseline. Two
separately-compiled float accumulations are not a portable bit-identity
claim.

Rather than loosen the invariant, this states it where it actually
lives. test_fold_matches_single_bounce_exactly asserts the real claim
directly and exhaustively: for every size 2..64 and every in-contract
index, convolution_reflect101() must return exactly what a single bounce
returns, and out of contract it must still land inside the plane. That
is integer-only and platform-independent, and it is a stronger statement
than the float comparison ever made. The end-to-end 24x24 cross-check is
kept but compared within 8 ULP, with the contraction reasoning recorded
on it; 8 ULP is far below anything score-visible while a genuine fold
divergence changes which sample is read and moves results by O(1e-2).
The now-unused bit-identity helpers are removed.

Windows MinGW64 — check_msvc_clz_shim failed, and my first reading of it
was wrong: it is unrelated to the rule changes in this PR. meson invokes
the script through its shebang interpreter, and on the MinGW64 runner
`bash` resolves to Windows' own WSL bash.exe, which has no installed
distribution. The leg printed "Windows Subsystem for Linux has no
installed distributions" and exited 1 before the script ever ran. The
gate is a static source-content check, so it is now registered on
non-Windows hosts only — Linux and macOS both run it in the fast suite
(macOS passes it today) and the lint lane runs it as well, so no
coverage is lost.

Also replaced rule (4)'s `grep -vF "$HDR"` self-exclusion with grep's
own --exclude on the basename. This is a robustness cleanup, not the
Windows fix: comparing grep's walked path against a separately
constructed absolute path is fragile, and the basename form has no path
dependency. Negative-tested that macro indirection, a narrowed
architecture allowlist, and an __lzcnt reintroduction in another file all
still fail the gate, and that a clean tree passes.

Verified: fast suite 112 Ok / 0 Fail, test_convolution_edge_small passes
with the new exhaustive case, clang-tidy 0 warnings on the changed test,
and the gate is still registered and passing on this Linux host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): drop strtok_r from the motion OOB test for MSVC portability

The Windows MSVC + oneAPI SYCL leg failed to compile this file:

  test_motion_convolution_oob.c(70,22): error: call to undeclared
  function 'strtok_r'; ISO C99 and later do not support implicit
  function declarations
  test_motion_convolution_oob.c(70,16): error: incompatible integer to
  pointer conversion initializing 'char *' with an expression of type
  'int'

strtok_r is POSIX; the MSVC runtime ships strtok_s instead, so the call
went undeclared and its int return was then assigned to a char *. Plain
strtok is on the fork's banned-function list (docs/principles.md S1.2
rule 30), so neither variant is available here.

The option string this test parses is a fixed "k=v:k=v" form under its
own control, so it now splits with strchr in a small loop: portable
everywhere, no reentrancy question, and no banned call. Behaviour is
identical for every input the test uses.

Verified: test_motion_convolution_oob passes, the fast suite is green,
and clang-tidy reports 0 warnings on the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Lusoris <lusoris@pm.me>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lusoris

lusoris commented Sep 8, 2026

Copy link
Copy Markdown

I filled in part of the x86 runtime-verification gap on the current PR head, e06467aa994081c0cd808bfee16154b663789747.

On an AMD Ryzen 9 9950X3D (x86-64, AVX2/AVX-512 available), Linux, GCC 15.2, Meson 1.10.1, with assembly and floating-point features enabled:

meson setup build-sanitize libvmaf \
  -Denable_cuda=false -Denable_docs=false -Denable_float=true \
  --buildtype=debug -Db_sanitize=address,undefined
ninja -C build-sanitize
meson test -C build-sanitize \
  test_motion_mirror test_motion_blend test_vif_tools --print-errorlogs

All three targets passed under ASan/UBSan/LSan. test_motion_mirror reported all eight cases passing, including the scalar-vs-best-available motion comparisons and test_convolution_avx_write_bounds; the latter's ARCH_X86 body was compiled and executed here. I used this PR's source directly, not our modified VMAFx motion implementation.

This is targeted CPU validation only: it does not validate the CUDA device guard, replace a full model/golden regression run, or establish a performance result. In our fork we also guard the smallest unsupported convolution-plane sizes at initialization, so that policy is separate from this PR's goal of scoring tiny inputs.

@kjg0724

kjg0724 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for verifying this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mirror()-style boundary reflection reads out-of-bounds for width/height < 3 (motion feature, all backends)

2 participants