Revision history for Notcurses::Native

0.7.0  2026-10-04T22:20:33+01:00
    - BUGFIX: [2026-10-04] a source build no longer links a Homebrew
      ncurses of the wrong architecture. The build added /opt/homebrew's
      ncurses keg whenever /opt/homebrew existed, so an x86_64 Raku under
      Rosetta on an Apple Silicon Mac linked arm64 ncurses and failed
      ("found architecture 'arm64'", every terminfo symbol undefined). A
      keg -- from $HOMEBREW_PREFIX, /opt/homebrew or /usr/local -- is now
      used only when its library carries a slice for the architecture Raku
      runs as, read from the Mach-O header; one that doesn't is reported
      and passed over. The keg goes after any PKG_CONFIG_PATH the user set
      rather than ahead of it, and notcurses' own architecture-blind
      /usr/local/opt/ncurses fallback is switched off.
    - BUGFIX: [2026-10-04] a source build whose dependencies sit in a
      prefix named on LIBRARY_PATH now loads. CMake treats LIBRARY_PATH
      directories as implicit and recorded no run path for them, so a
      dependency with an @rpath install name (a CMake-built libdeflate,
      say) failed with "Library not loaded: @rpath/libdeflate.0.dylib",
      and on Linux one outside the loader's default directories went
      unfound the same way. Those directories are now handed to CMake as
      CMAKE_BUILD_RPATH. Windows has no run paths and is unaffected.
    - BUGFIX: [2026-10-04] every source build configures from scratch. The
      build directory lives in the per-commit source cache, and CMake kept
      a failed attempt's find results there, so correcting PKG_CONFIG_PATH
      or CMAKE_PREFIX_PATH and installing again still built against the
      cached, wrong libraries. The reset is what `cmake --fresh` does, done
      by hand for CMake 3.21-3.23, and costs a full recompile per install.
    - FEATURE: [2026-10-04] Bump the pinned notcurses fork (b2dadeb4e) and
      prebuilts to binaries-notcurses-3.0.17-r16: Windows gets
      win32-input-mode, so Ctrl+Enter, Shift+Enter, Alt+Enter and every
      other chord reach the app with their modifiers under ConPTY, exactly
      as a kitty terminal delivers them elsewhere, and the bracketed-paste
      markers already bound above (2026-09-07) start arriving; the input
      queue no longer drops events when it is full but waits for the client
      to drain it, and the queue and read buffers are 8192 on every platform
      instead of the platform's BUFSIZ (512 on Windows), so a
      24,000-character paste arrives whole; notcurses_stop no longer hangs
      on a redirected stdin whose writer stays open; sixel graphics no
      longer leave a ghost when a plane is moved and hidden in one frame;
      frames go out through a binary-mode console descriptor, which turns
      second-long pane toggles into milliseconds; and the shim's permanent
      ABI table and C-level terminal restore guard (both above) reach a
      toolchain-less install for the first time, alongside the copy_cells
      fix, the Windows ncsubproc/ncfdplane ports, correct wcwidth/wcswidth
      and whole-code-point text handling, the sextant/octant fixes, the
      ncmenu overrun fix, and notcurses_poll_geometry (all above).
    - BUGFIX: [2026-10-01] the build no longer decides on Windows whether
      the staged perf shim is current by file time. A pack without the
      shim's .srchash sidecar fell back to comparing mtimes, and on
      Windows IO::Path.modified puts every file within seconds of 1970 (a
      MoarVM overflow), so the answer was noise. There a shim with no
      sidecar is now refreshed from the cache or the compiler, and kept
      only when neither can. The shim is compiled to a temporary name and
      renamed into place once the compiler succeeds, so a failed compile
      never replaces a working shim with a broken one; a shim a running
      application holds open says so rather than failing the install.
    - BUGFIX: [2026-10-01] on Windows the build's caches -- downloads,
      fetched notcurses sources and compiled shims -- live under
      %LOCALAPPDATA%. Windows sets no HOME, so the default came out as
      ./.cache, inside the directory zef ran the build from: the dist's
      own source tree for a local `zef install .`. Old stages and a
      stale source fetch are removed in Raku rather than with `rm -rf`,
      which a stock Windows lacks, and a link inside is removed as a
      link, never followed.
    - NEW: [2026-10-01] ncsubproc works on Windows (fork). It returned NULL
      there (a FIXME in place of CreateProcess). It now launches with
      CreateProcessW: arguments converted to UTF-16 and quoted so the
      child's C runtime gets them back unchanged, the program looked up on
      the search path (adding .exe) for the createvp/createvpe forms with
      argv[0] kept as given, and createvpe's environment passed as a
      sorted UTF-16 block. The child's stdout and stderr share one pipe and
      its stdin is inherited, as on POSIX, and only those handles reach it
      (an explicit inheritance list, so another child's pipe is never
      leaked into it). A waiter reports the exit code through the done
      callback once the output has drained, and ncsubproc_destroy ends a
      process that is still running and returns 0 only for exit code 0. A
      program that is nowhere on the search path is refused at once.
    - BUGFIX: [2026-10-01] ncfdplane reads files and pipes on Windows
      (fork). Its reader polled with WSAPoll, which takes sockets and
      nothing else, so it failed at once on a file, a pipe or the console.
      It now blocks in read(); a destroy from another thread cancels that
      read (CancelSynchronousIo, since winpthreads' read() is no
      cancellation point) and joins, and a following plane at end of file
      waits briefly before reading again rather than spinning.
    - BUGFIX: [2026-10-01] ncsubproc's lock is initialised (fork, every
      platform). It was only ever zeroed, which is a valid mutex where
      PTHREAD_MUTEX_INITIALIZER happens to be all zeroes (glibc) and not on
      macOS or winpthreads.
    - NEW: [2026-10-01] notcurses_ucs32_width() and notcurses_wcswidth()
      (fork): wcwidth() for any code point and wcswidth() that takes a
      UTF-16 surrogate pair as one character, on every platform. On MinGW,
      whose C library has neither and whose wchar_t is 16 bits, notcurses.h
      maps wcwidth and wcswidth to them, replacing ncport.h's stubs that
      answered 1 for every character and the code-unit count for every
      string -- which the header's own NCCELL_INITIALIZER and
      ncplane_putwstr_aligned, and any program including it, were using.
    - BUGFIX: [2026-10-01] Character widths are right on Windows (fork).
      notcurses measured every glyph as one column under MinGW: ncport.h
      stubs wcwidth() to 1 there, and wchar_t is 16 bits, so the UCRT's
      mbrtowc() decoded every code point beyond the BMP to U+FFFD. Emoji
      and CJK took one column instead of two, ncstrwidth() accepted control
      characters, and a 😀 written by ncplane_putstr left the cursor one
      column short. The fork now decodes, measures and encodes whole code
      points through libunistring on MinGW (uc_width(), u8_mbtoucr(),
      u8_uctomb()) wherever it took one character at a time: EGC
      segmentation, puttext wrapping, menus, the reader's word breaks,
      keyboard input (a typed or pasted non-BMP character no longer
      arrives as U+FFFD), and the terminal capability probes, which still
      enable braille, sextants and octants. macOS and Linux keep their C
      library calls, unchanged. t/40 pins it from Raku. The r16 prebuilts
      carry it; a toolchain-less install now gets it without a rebuild.
    - BUGFIX: [2026-10-01] Sextant and octant plots and pixel read-back
      work on Windows (fork). The blitters' glyph tables are wchar_t
      strings, in which every sextant and octant is a surrogate pair under
      MinGW: ncuplot/ncdplot indexed them one code unit at a time and
      wctomb() refused the halves, so a sextant or octant plot failed to
      draw wherever the terminal offers them (WezTerm does), and
      ncplane_as_rgba could not find those glyphs at all. The tables are
      now walked glyph by glyph where wchar_t is 16 bits.
    - BUGFIX: [2026-10-01] Octant plots draw octants (fork, every
      platform). blit.c spelled the first entry of the octant plot table
      L"\0x20" -- a NUL followed by 'x', '2', '0' -- instead of a space,
      which pushed every glyph three levels up and drew a literal x, 2 and
      0 for the lowest levels. Present upstream too.
    - BUGFIX: [2026-10-01] NCSEXBLOCKS has all 64 sextants (fork, every
      platform). U+1FB09 was missing, so every entry from pattern 10 on
      named the glyph for the pattern before it, and ncplane_as_rgba read
      54 of the 64 sextants back as the wrong pixels. Image blitting was
      unaffected (it uses its own table). Present upstream too.
    - BUGFIX: [2026-10-01] ncmenu no longer overruns its allocation for a
      shortcut of three or more UTF-8 bytes (fork, every platform). The
      description was sized with wcrtomb(NULL, ...), which encodes L'\0'
      and so always answered one byte: a CJK, emoji or NCKEY_* shortcut
      wrote one or two bytes past the buffer. Present upstream too.
    - MAINT: [2026-10-01] The fork's doctest suite builds and runs on
      Windows (USE_CXX and USE_DOCTEST, in a build directory of its own;
      the install-time build still turns both off). Three tests that
      assumed a 32-bit wchar_t or the C library's ctype are portable now
      (EGCpool ForceReallocation, Plane DenyControlASCII, Wide
      PlaneAtCursorInsane), and a new Codepoints case covers the helpers,
      both glyph tables against Unicode's pattern order, read-back of every
      sextant and octant, and both plot glyph sets. 62 of 63 cases pass on
      Windows, each run in its own process; FdsAndSubprocs needs
      subprocess support there.
    - MAINT: [2026-10-01] t/43 binds `_strdup` on Windows, where the UCRT
      exports no `strdup`. t/36 no longer depends on how much of a frame
      ncpile_render_to_buffer emits: the fork from 2026-09-30 emits the
      whole frame where the pinned one emitted only damage, so the
      "short frame after a long one" case it relies on never arose. Its
      long frames give every cell its own colour, and the plane is erased
      before each in-memory frame.
    - BUGFIX: [2026-09-30] The printf family no longer goes through
      NativeCall's variadic support, which cannot call a C variadic
      function safely: any call with no arguments after the format died
      ("cannot unbox to an unsigned native int") on every platform, and
      on Windows every integer vararg travelled as a 32-bit `long`, so
      `%lld` of anything past 2**32 printed garbage there.
      `ncplane_printf`, `ncplane_printf_yx`, `ncplane_printf_aligned`,
      `ncplane_printf_stained` and `ncdirect_printf_aligned` keep their
      names, argument order and return values, but are Raku subs now:
      they format with Raku's sprintf and write through the fixed-arity
      call notcurses's own vprintf_* use after formatting (putstr_yx,
      putstr_aligned, putstr_stained). `ncdirect_printf_aligned`
      rebuilds ncdirect_vprintf_aligned from public calls (ncdirect_align
      is static in direct.c): ncstrwidth, the same column arithmetic,
      ncdirect_cursor_move_yx, then puts(3), whose value it answers;
      -1 when the text has no width, the cursor cannot move or puts
      fails. C formats work unchanged: length modifiers (hh h l ll j z t
      L q) are accepted and dropped, `%n`, `%p` and the wide-character
      conversions die with a message saying what to do instead, a
      format/argument mismatch dies instead of printing garbage, and
      widths count characters rather than bytes. An undefined plane or
      ncdirect handle dies instead of reaching C. This also removes the
      dist's only `**@args` native declaration, which Rakudo before
      2025.12 refused to precompile. The masking fallback in
      xt/12-plane-exhaustive, which quietly swapped in ncplane_putstr
      when the zero-argument printf died, is gone. Pinned by t/40.
    - BUGFIX: [2026-09-30] The option structs own their strings. Every
      `const char*` field of NotcursesOptions, NcplaneOptions,
      NcselectorItem, NcselectorOptions, NcmselectorItem,
      NcmultiselectorOptions, NcmenuItem, NcmenuSection, NctabbedOptions
      and NcplotOptions is a private buffer the struct keeps alive for
      exactly its own lifetime. They used to go through set-cstruct-str,
      which wrote a raw pointer and pushed the buffer onto a global list
      that was never emptied, so every name, title or separator ever set
      leaked for the life of the process. `.new(field => 'str')` and the
      accessors are unchanged; each field gains a `set-<field>` method
      (answering the struct; `Str` clears it) so one struct can be
      reused, and a string with an embedded NUL dies, naming the field.
      Pinned by t/41, including 10,000 rebinds of ~11 KiB with RSS flat
      and real widgets reading the strings back through the C API.
    - FEATURE: [2026-09-30] Item arrays the options structs own.
      `NcselectorOptions.new(:items(...))` and
      `NcmultiselectorOptions.new(:items(...))` take a list of items and
      build the NULL-terminated C array themselves; `NcmenuSection` takes
      a list of NcmenuItems (setting itemcount) and `NcmenuOptions` a
      list of sections (setting sectioncount), nested item arrays
      included. The struct owns the array and every string in it, so the
      item objects may go. A raw `Pointer` is still accepted for an array
      the caller manages, and `.items` / `.sections` still answer the
      address. Items notcurses would misread die up front: an item with
      no option (a NULL option ends a C item array, silently dropping the
      rest) and a multiselector item with no desc (ncmultiselector_create
      dereferences it unconditionally and crashes).
    - NOTE: [2026-09-30] `set-cstruct-str` is deprecated (`is
      DEPRECATED`), its behaviour unchanged for code outside the dist
      that still calls it; nothing in the dist does.
    - FEATURE: [2026-09-30] A permanent ABI guard. The shim exports its
      ABI table through three fixed-arity calls,
      `notcurses_native_abi_count`, `notcurses_native_abi_key` (static
      strings) and `notcurses_native_abi_value`: the size of every struct
      a Raku CStruct mirrors, the offset and width of every mirrored
      field, and the value of every mirrored constant, all computed by
      the compiler from the headers the shim is built against.
      t/42-abi-guard discovers every CStruct class and exported integer
      constant in the dist, measures each field from the Raku side, and
      fails on any difference from C, on any struct, field or constant
      the table does not cover, and on any table entry nothing accounts
      for. With no shim carrying the table installed it compares against
      t/fixtures/abi-table.tsv, printed from the same source by
      scripts/abi/abi-probe.c (regenerate with
      scripts/abi/regenerate-fixture.raku; the table is identical on
      every supported target except C `long` widths, which the test
      reads per platform); it never skips. xxt/02 checks the committed
      fixture against a freshly compiled probe.
    - BUGFIX: [2026-09-30] `NCOPTION_CLI_MODE` is 0x0252, as notcurses.h
      defines it: NO_ALTERNATE_SCREEN | NO_CLEAR_BITMAPS |
      PRESERVE_CURSOR | SCROLLING. It was 0x0600: SCROLLING plus 0x0400,
      a bit notcurses does not define. Found by the new ABI guard, which
      found no struct layout problem.
    - FEATURE: [2026-09-30] `NCDIRECT_OPTION_*` constants for
      ncdirect_init / ncdirect_core_init flags.
    - CI: [2026-09-30] Every build lane now asserts all ten shim exports
      by name — the ABI accessors, and the owned terminal-guard calls
      (reserve, arm_owned, disarm_owned, release) the lists had been
      missing. xt/20 fails if a lane's list and the shim source's exports
      ever differ, and t/15 checks, from the same source, that the staged
      shim resolves every export (required under
      NOTCURSES_NATIVE_REQUIRE_SHIM; reported otherwise).
    - BUGFIX: [2026-09-30] Notcurses::Native::Test::Helper chose the null
      device by matching the kernel name against /win/, which "darwin"
      satisfies: on macOS the xt/ suite redirected its stdout into, and
      handed notcurses, a file literally named NUL in the working
      directory. It uses `$*DISTRO.is-win` now, and dies if the null
      device cannot be opened rather than passing notcurses a NULL
      FILE* through a variable (a NativeCall site that sees an undefined
      value that way first passes NULL for good). Pinned by t/43.
    - BUGFIX: [2026-09-30] `ncplane-as-rgba` answers the `buf32` type
      object and a 0x0 geometry for an undefined plane instead of handing
      notcurses a NULL plane to dereference.
    - MAINT: [2026-09-30] A sweep of every native call site in lib/ for
      the NativeCall NULL-pin trap (see the NOTE below) found the
      Test::Helper sites above and nothing else: every other site takes
      parameters, return values, native-typed lexicals or values
      checked defined first. t/43 pins the pointer-accepting wrappers in
      the order that exposes the trap — undefined through a variable
      first, then real values — with a leak loop on
      strdup-copy-and-free, whose free would silently stop.
    - MAINT: [2026-09-30] Notcurses::Native::Test::Headless gains
      `headless-ncdirect` (direct mode with no terminal, optionally
      writing to the child's stdout), `native-getenv` (the environment
      as C sees it), a `:termtype` for `headless-notcurses` and a `:cwd`
      for `run-headless-script`. Notcurses::Native::Str gains two
      `:INTERNAL` helpers, `c-sprintf` and `copy-blob-into-native`.
    - MAINT: [2026-09-30] xt/04 and xt/15 no longer leak the structs they
      get from notcurses_stats_alloc: they read through
      `notcurses-stats-snapshot`, and free the raw allocation they test
      with `notcurses-stats-free`. xt/04's fade-context subtest no longer
      segfaults on a terminal that cannot fade (headless Linux with an
      8-colour xterm entry): notcurses answers NULL there, which the test
      handed straight to ncfadectx_iterations; it now asserts the NULL
      where fade.c's own capability test says there is nothing to fade.
      The NcmenuItem and NcplotOptions layout comments now give C's
      numbers (ncinput is 52 bytes, the menu item 64; the plot title
      sits at 32 after 4 bytes of padding).
    - NOTE: [2026-09-30] The NativeCall NULL-pin trap described below is
      broader than pointers: an undefined boxed `Int` or `Str` reaching a
      native call site through a variable or attribute on its first call
      pins 0 or NULL the same way. The README's MEMORY OWNERSHIP section
      says so. Two upstream notcurses defects surfaced while testing the
      struct-owned arrays: ncmultiselector_selected never writes flag 0
      (its loop is `while(--count)`), and ncmultiselector_create crashes
      on an item with a NULL description where the selector substitutes
      "".
    - BUGFIX: [2026-09-30] `ncpile-render-to-string` no longer frees
      notcurses's output buffer. `ncpile_render_to_buffer` does not hand
      the caller a fresh allocation, whatever notcurses.h says: it lends
      out `nc->rstate.f.buf`, which notcurses keeps rasterizing into on
      every later frame and frees itself in `notcurses_stop` (on Linux it
      is an mmap, not a malloc block at all). Freeing it here was a
      use-after-free on the next render and a double free at stop —
      confirmed under AddressSanitizer on macOS, a crash on Linux. The
      wrapper also read the frame with strlen, but the buffer carries no
      NUL terminator, so a frame shorter than an earlier one came back
      with the earlier frame's tail attached. It now copies exactly the
      reported length and decodes it strictly as UTF-8; `''` for an empty
      frame, `Str` on failure as before. This corrects the 0.4.0 entry
      below that called the buffer malloc'd and caller-freed, and the raw
      binding's comment that said the same: it is documented as BORROWED
      now, valid only until the next render, rasterize, refresh or stop.
      Pinned by t/36.
    - FEATURE: [2026-09-30] `ncpile-render-to-blob`, the byte-exact
      sibling of `ncpile-render-to-string`: the frame as a Raku-owned
      `buf8`, for diffing, hashing or replaying frames without the
      normalisation a `Str` applies.
    - BUGFIX: [2026-09-30] `notcurses_native_copy_cells` (perf shim) read
      freed memory in two places, both confirmed under AddressSanitizer.
      It held the source plane's base-cell cluster as a pointer into the
      source egcpool for the whole copy, and every cell it read
      duplicates into that pool; once a duplicate grew the pool, the next
      empty cell was written from freed memory (any source whose base
      cell holds a cluster longer than four bytes). And when src == dst
      it wrote each cell straight from its pool pointer, while the write
      stashes into the same pool and `ncplane_putstr_yx` re-reads the
      string afterwards. Every cluster is now copied into shim-owned
      storage (inline up to 127 bytes, a reused heap spill beyond) and
      the pool reference released before the next notcurses call.
      Rendering is unchanged: same calls, same order, same styles and
      channels, same base substitution and wide-glyph handling. The
      function now answers -1 if a heap spill cannot be allocated
      (previously it could only answer 0). Pinned by t/37 and by the
      new ASan driver, xxt/01-asan-copy-cells, which fails on the old
      source and passes on the new. The r16 prebuilts carry the fixed
      shim; a toolchain-less install no longer needs a rebuild to get
      it.
    - BUGFIX: [2026-09-30] `NCSTYLE_STRUCK` is 0x0001, as notcurses.h
      defines it. It was bound as 0x0020, a bit notcurses does not
      define, so strikethrough requested through the constant rendered
      as plain text — and code testing a stylemask read back from
      notcurses with `+& NCSTYLE_STRUCK` could never see it. t/33 pins
      every NCSTYLE_* value against the header.
    - BUGFIX: [2026-09-30] `Timespec` has C's layout on Windows.
      `struct timespec` is `{ time_t tv_sec; long tv_nsec; }`, 16 bytes
      with tv_nsec at offset 8 everywhere we build; with both fields
      bound as `long`, Windows (where `long` is 32-bit) got an 8-byte
      struct with tv_nsec at offset 4, and every timeout passed to
      `notcurses_get`, `ncdirect_get`, the fade calls or the stream calls
      was misread there. tv_sec is `int64` now. Pinned by t/34.
    - BUGFIX: [2026-09-30] `ncplane_name` leaked a copy of the name on
      every call. notcurses returns a strdup'd name; the binding was a
      bare `--> Str`, listed as library-owned in the 0.4.0 entry below
      and in Notcurses::Native::Str's Pod — both wrong. It now frees the
      copy through `strdup-copy-and-free`. A plane created without a
      name answers `''`, one whose name was cleared answers `Str`.
    - FEATURE: [2026-09-30] Safe receivers for the other heap returns,
      each copying into Raku-owned storage and freeing the C allocation:
      `ncplane-as-rgba` (the pixels as a `buf32`; the raw
      `ncplane_as_rgba` Pointer is documented as caller-frees),
      `notcurses-stats-snapshot` (allocates through
      `notcurses_stats_alloc` so notcurses sizes the struct, copies, and
      frees; `:into` reuses one struct per frame) with
      `notcurses-stats-free` for callers of the raw allocator,
      `ncselector-destroy-selected` and `ncreader-destroy-contents` (the
      string notcurses hands over through the destroy call's `char**`,
      which the existing Pointer bindings could only pass as NULL — the
      selector wrapper also avoids notcurses's out-of-bounds read on a
      selector with no items), and `borrowed-buf-from-pointer` in
      Notcurses::Native::Str. Every raw binding keeps its signature.
      Pinned by t/35 and t/38, including RSS-bounded leak loops.
    - NOTE: [2026-09-30] A NativeCall trap found while writing
      `ncplane-as-rgba`, present in Rakudo 2025.05 through 2026.08: a
      native call site that first receives a type object through a
      Scalar container (a `my` variable or an attribute; typed or not;
      Pointer or any CPointer class) passes NULL on every later call
      through that site. A `LEAVE c-free($pixels)` that ran once for a
      failed call therefore stopped freeing anything afterwards. The
      wrappers here decontainerise and guard their frees; the README's
      MEMORY OWNERSHIP section tells callers of the raw bindings how.
    - MAINT: [2026-09-30] Notcurses::Native::Test::Headless (test
      support, shipped under `::Test` like Test::Helper): runs a Raku
      script in a child with notcurses started on no terminal at all —
      setsid(2) on POSIX, piped standard handles on Windows — and
      collects tagged results, so t/ can assert on memory-safety
      behaviour without a TTY and survive a crashing child. A new xxt/
      tier holds the ASan driver, which needs a C compiler with
      AddressSanitizer and the pinned notcurses headers and fails
      loudly without them. The README gains a MEMORY OWNERSHIP section.
    - FEATURE: [2026-09-19] A C-level terminal restore guard in the perf
      shim: `notcurses_native_arm_terminal_guard(cleanup)` captures the
      terminal as it stands (termios on POSIX, both console modes on
      Windows), copies a cleanup escape sequence into static storage and
      registers one `atexit(3)` handler that puts both back;
      `notcurses_native_disarm_terminal_guard()` stands it down. The
      exit it exists for is the one no Raku sees: a MoarVM panic prints
      its line and calls C `exit(3)` from the runtime, so no END phaser,
      no LEAVE and no signal tap runs, and a TUI's terminal is abandoned
      in raw mode with the alternate screen up — a shell the user has to
      blind-type `reset` into. An `atexit` handler still runs there.
      SIGSEGV, SIGKILL and `_exit(2)` remain out of reach by
      construction, and are documented as such. Arming with no terminal
      is a supported no-op that arms nothing and answers a non-zero
      code; the handler never allocates, never aborts, and tolerates a
      second entry. Pinned by t/31.
    - CI: [2026-09-19] Every build lane now asserts by name that the
      shim it packs exports `notcurses_native_copy_cells`,
      `notcurses_native_arm_terminal_guard` and
      `notcurses_native_disarm_terminal_guard`. The checks it replaces
      named one symbol (macOS) or matched the `notcurses_native_`
      prefix (Linux, Windows), and a bundle whose terminal guard had
      silently failed to compile in satisfies both. Nothing downstream
      catches that: Build.rakumod recompiles the shim locally wherever
      a C toolchain exists, CI runners all have one, so the verify
      lanes' shim check inspects that fresh copy rather than the
      archive's — the build lanes are the only place a pack's own
      contents are ever tested. Windows also links kernel32 explicitly
      now; the guard's Win32 branch calls GetStdHandle, GetConsoleMode,
      SetConsoleMode, WriteConsoleA and WriteFile, and had been
      relying on MinGW pulling that library in by default.
    - FEATURE: [2026-09-19] `ensure-shim-loadable`, which puts
      libnotcurses in the process and answers whether the shim can now
      be loaded. macOS builds the shim with `-undefined dynamic_lookup`,
      and dyld4 resolves flat-namespace symbols eagerly, so a `dlopen`
      of the shim fails outright until something has loaded
      libnotcurses. NativeCall binds one sub at a time on first call, so
      a consumer whose first shim call precedes its first notcurses call
      — arming the terminal guard before `notcurses_init`, exactly — met
      that every time. Every shim binding's Pod now names it as the
      prerequisite. Linux and Windows link the core library properly and
      only pay a version read.
    - FEATURE: [2026-09-10] Bind `notcurses_poll_geometry`, the fork's
      nonpainting owner-thread geometry poll. Windows runtime CSI 6
      reports refresh cell pixel dimensions, including font-only zoom.
      The r16 prebuilts carry the fork commit with this symbol, so a
      toolchain-less install gets it without a rebuild.
    - FEATURE: [2026-09-07] `NCKEY_PASTE_BEGIN` and `NCKEY_PASTE_END`
      (preterunicode 300 and 301), the two keys the m-doughty/notcurses
      fork delivers around a bracketed paste from binaries r16 on. They
      sit in the synthesized range like every other NCKEY, so a consumer
      can name them today and treat everything between them as text once
      the library that sends them is in place.
    - BUGFIX: [2026-08-27] the library-path resolvers are no longer
      racy on first touch. nc-lib, ffi-lib, core-lib, shim-lib and
      libc-name each memoised their lookup in a bare
      `state $r = <probe>`, and a `state` initialiser is not atomic —
      the slot is flagged initialised independently of the value
      landing in it, so a thread arriving mid-initialisation read
      `Any` and NativeCall was handed a `Str` type object as a
      library name. The probe is a directory walk (plus a
      LoadLibraryExW on Windows), so the window is milliseconds wide
      on a cold page cache — and a TUI's startup is exactly a burst
      of threads binding native subs for the first time. Measured
      before the fix: 64 threads released together saw ~180 of 448
      resolver calls answer `Any`, on 25 of 25 cold launches; after
      it, 0 of 50. What a consumer saw was a native binding failing
      to load for no reason on some runs and not others. Resolution
      now happens under a lock, and every resolver is primed once
      from the module mainline on the loading thread, so the
      concurrent first touch cannot arise at all. Priming is
      per-resolver `try`-guarded so Windows' load-time failure for an
      unloadable optional shim still surfaces lazily at first use,
      exactly as before. New regression test
      (t/29-cold-start-race) drives 64 real OS threads through a
      cold resolver burst across ten child processes; it needs no
      terminal.
    - MAINT: [2026-08-27] moved the C<xt/> terminal-redirect test
      support from C<t/lib/TestHelper.rakumod> into
      C<lib/Notcurses/Native/Test/Helper.rakumod>
      (C<Notcurses::Native::Test::Helper>). A C<t/lib> directory is
      its own CompUnit repo that starts cold on a fresh C<zef
      install>, and parallel test files pulling the fixture's
      dependency chain into that cold store can race and fail the
      install outright. C<lib/> is staged and precompiled before the
      test phase runs, so shipping test support inside the dist's own
      namespace dissolves the race. This is the fleet-wide convention
      going forward.

0.6.6  2026-08-26T09:43:42+01:00

    - Fixed a test in t/ referencing path outside of the dist

0.6.5  2026-08-17T20:03:10+01:00

    - [2026-08-17] Repair t/28 so the CI verify matrix can pass again.
      Two defects, both mine and both invisible on Windows (which takes
      the flat-DLL arm): the scratch stages hardcoded .so filenames, so
      on macOS normalize-alias-chains looked for .dylib, no-op'd, and
      the assertions genuinely failed; and the method's `say` progress
      narration leaked raw stdout into the middle of a subtest's TAP
      block, which prove6 rejects as "Subtest 3 doesn't have a plan"
      and fails the whole run even with every assertion passing — that
      one broke ALL POSIX lanes. The test now builds platform-correct
      names (suffix aliases on ELF, infix on macOS) and wraps every
      call in a $*OUT-to-null-device guard. Verified against the real
      r15 Linux pack end-to-end (zef install + the exact CI prove6
      invocation) under WSL before this cut. Dist behaviour is
      unchanged: 0.6.4 installs fine for users (zef gates on exit
      codes, not TAP framing); the red was CI-only.

0.6.4  2026-08-17T19:44:36+01:00

    - [2026-08-17] Normalize prebuilt stages to one real file per
      library. The binaries-notcurses-3.0.17-r15 Linux packs shipped
      libnotcurses-core.so AND libnotcurses-core.so.3 as two separate
      real files — the packing lane materialized the version alias as a
      copy — which t/27's single-image invariant caught on the CI
      verify runners and which failed every fresh Linux install of
      0.6.3 at test time. Prebuilt extraction now runs the new
      Build.normalize-alias-chains: for each logical library the
      most-versioned real file is kept and every other name variant is
      re-pointed at it as a relative symlink, so the staged layout no
      longer trusts the archive's internal shape. Duplicates must be
      byte-identical before one is collapsed; differing "aliases" mean
      a corrupt archive and abort the install. No binary changes —
      the r15 packs stay pinned and are repaired at staging time.

0.6.3  2026-08-17T19:27:37+01:00

    - [2026-08-17] Source builds now stage version aliases as relative
      symlinks (one real dylib/so per logical library) instead of
      materializing each alias as a separate copy, matching the layout
      the prebuilt archives ship. On macOS the copies were two distinct
      dyld images — dyld dedupes by real path, not install-name — so
      NativeCall's dlopen of libnotcurses-core.dylib and libnotcurses'
      LC_LOAD_DYLIB of libnotcurses-core.3.dylib each got their own
      image. Heap state (tinfo) was shared but per-image globals were
      not: set_pixel_blitter() patched notcurses_blitters' NCBLIT_PIXEL
      entry in the init image while the blitting image still held NULL,
      a jump-to-zero segfault on the first pixel blit in Kitty. Latent
      since the staging code was written: prebuilt stages always had the
      symlink layout (tar preserves it), Linux ld.so dedupes by SONAME,
      and on macOS the interrogation clock bug (below) had been masking
      the pixel path entirely. New t/27-staged-lib-single-image.rakutest
      enforces the one-real-file-per-library invariant on POSIX.

    - [2026-08-17] Bump the pinned notcurses fork (b52f1d95) and
      prebuilts to binaries-notcurses-3.0.17-r15: fix the terminal
      interrogation timeout added in r14 firing instantly on macOS and
      Windows. inputlayer_get_responses() computed its 1-second deadline
      with CLOCK_MONOTONIC, but pthread_condmonotonic_init() cannot set
      CLOCK_MONOTONIC on those platforms (no pthread_condattr_setclock),
      so the condvar measures deadlines against CLOCK_REALTIME — a
      monotonic "now + 1s" lies decades in the past and timed out in
      0ms, handing off zeroed interrogation results: no Kitty graphics
      detected (images degraded to block blitters), no cell-pixel
      geometry, no kitty keyboard level, no default fg/bg. The deadline
      now comes from a new pthread_condmonotonic_gettime() matching the
      condvar's actual clock per platform. Windows previously "worked"
      through the same instant timeout; it now genuinely waits (bounded
      at 1s), improving detection there too.

0.6.2  2026-08-17T02:02:34+01:00

    - [2026-08-17] Bump the pinned notcurses fork (042e03f4) and prebuilts
      to binaries-notcurses-3.0.17-r14: the Windows clear escape is now
      \e[H\e[2J instead of bare \e[2J. ConPTY's ED(2) erases without
      homing the cursor, but notcurses' clear_and_home() records the
      cursor as being at 0,0 after emitting it — so the first row of every
      notcurses_refresh was written wherever the cursor actually sat,
      leaving the real top row blank. Visible as TUI top-bar corruption
      after any Windows terminal resize (a stale leading run where the
      damage-diff repair agreed with the restriped previous frame).
      Diagnosed byte-level with a CreatePseudoConsole capture harness.

0.6.1  2026-08-16T22:52:24+01:00

    - Stop destroying the process PATH on Windows. The staged library
      directory is prepended to PATH so a consumer's own DLL loads keep
      working after importing this module — but Windows names the variable
      'Path', not 'PATH', and %*ENV is an ordinary case-sensitive Hash. So
      `%*ENV<PATH> // ''` answered the empty string and the branch replaced
      the entire search path with our single directory; because environment
      names are case-insensitive to the OS, the freshly created 'PATH' key
      then won outright.

      Every native library loaded after notcurses consequently lost its own
      dependency search path. It surfaced as SQLCipher being unable to find
      libcrypto — reported, misleadingly, as "sqlcipher.dll not found" — but
      would equally have hit libvips, onnxruntime or any other consumer.
      Whichever spelling the host actually uses is now updated in place.

0.6.0  2026-08-16T20:32:48+01:00

    - Free caller-owned notcurses strings through the Universal CRT
      (ucrtbase.dll) rather than msvcrt.dll. Heap pointers returned by
      notcurses must be released by the same CRT family that allocated
      them; handing a UCRT allocation to msvcrt's free() can corrupt the
      process heap.

    - Add NOTCURSES-GET-ERROR, the sentinel every notcurses_get* returns
      on error. Those subs are bound with a uint32 return, so the error
      arrives as 4294967295 and the obvious `$id == -1` test silently
      never matches — leaving a failed read to be decoded as a valid
      input event. Consumers should compare against this constant.

    - A source build whose CMake configure fails no longer downgrades
      itself to -DUSE_MULTIMEDIA=none behind a warning. That produced a
      library with no image or video support, which nothing downstream
      could detect until it failed at runtime. The configure failure is
      now fatal and names the FFmpeg package to install for each
      platform; set NOTCURSES_NATIVE_ALLOW_NO_MULTIMEDIA=1 to opt into a
      core-only build deliberately.

    - The test helper's stdout/stderr redirection bound dup(), dup2() and
      open() with a bare `is native`, which resolves against the running
      executable. That finds libc on POSIX, but MoarVM exports none of
      them on Windows, so every xt file died at compile time. They now
      resolve through the same C runtime as the rest of the distribution,
      using the UCRT's underscore-prefixed spellings. The redirection
      itself is skipped on Windows, where MoarVM shares the descriptor
      with the TAP stream, and moved into an INIT phaser so it runs in
      the test process rather than during precompilation.

    - Bump the pinned notcurses fork (BINARY_TAG r13). Beyond the input
      and shutdown work — a waitable wake handle so the input thread can
      be broken out of its blocking wait, console-mode preservation and
      restoration, a redirected-handle fallback so notcurses_init
      succeeds when stdin/stdout are not a console, end-of-stream
      handling on redirected stdin, and a bounded, race-free
      terminal-interrogation timeout — this run fixes five further
      Windows defects:

      * blocking_write() rejects a negative descriptor instead of
        relying on write(2) answering EBADF. The Windows CRT routes an
        invalid descriptor through its invalid-parameter handler, which
        terminates the process outright; a ttyfd of -1 is normal for any
        redirected or headless output, so cursor operations killed the
        caller.
      * notcurses_stop() and ncdirect_stop() emit their final sequences
        to the FILE* the caller supplied rather than to stdout, so a
        caller that isolated the library onto a null device no longer
        finds escape sequences on its real output.
      * The C runtime is put into UTF-8 mode even when the caller passes
        NCOPTION_INHIBIT_SETLOCALE. That option is about the
        LANG-derived locale; on Windows the CRT decoding mode is
        separate, and without it multi-byte grapheme clusters were split
        one byte per cell.
      * A NUL terminator no longer contributes a column in
        utf8_egc_len(), so ncstrwidth("") answers 0 rather than 1.
      * linesigs_enable() and linesigs_disable() treat "no controlling
        terminal" as a successful no-op on every platform, as the POSIX
        path already did.

    - Pin *.c and *.h to LF. The shim source is hashed into a .srchash
      sidecar that ships inside each prebuilt pack and is compared
      against a hash of the same file in the consumer's checkout. The
      Windows runner checks out CRLF by default, so it recorded a digest
      no other checkout could reproduce: the shim was judged stale on
      every install, and a prebuilt-only Windows install cannot rebuild
      it, so it was silently dropped.

0.5.2  2026-08-14T21:21:56+01:00
    - Fix Windows loading of the bundled notcurses dependency closure.
      Loading libnotcurses.dll by absolute path does not make Windows
      search that DLL's directory for its FFmpeg, ncurses, and notcurses
      imports. Prebuilt libraries are now loaded lazily with LoadLibraryExW
      and strict LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR plus default-directory
      flags. Source installs replace the whole stage, write a durable marker,
      and use LOAD_WITH_ALTERED_SEARCH_PATH so their ordinary MSYS2 PATH
      dependencies remain available. Reinstalling through either path clears
      the other path's files and provenance.
    - Add a TTY-free full-library load test to both Windows workflow
      definitions. Prebuilt probes remove MSYS2/toolchain directories from
      PATH so CI cannot mask a missing bundled DLL. The MSYS2 source-build
      step records its active $MINGW_PREFIX/bin as a Windows path, and the
      source probe explicitly prepends that validated directory. Each probe
      fails early unless its mode agrees with the actual BINARY_TAG marker.
    - Bind every ncvisual and ncblit operation to libnotcurses-core, which
      owns and exports those symbols. Unix dependency-symbol lookup had
      hidden the incorrect libnotcurses bindings; Windows resolves exports
      only from the named DLL. The two multimedia-library bindings remain
      notcurses_init and ncdirect_init, which install the selected media
      implementation into core.

0.5.0  2026-08-12T18:42:31+01:00
    - Both Windows lanes source-build GNU libiconv 1.19 instead of
      taking MSYS2's. It is the last copyleft library in the packs
      that we could not point a user at the exact source for: LGPL-2.1,
      shipped inside the .zip, and sourced from a pacman package whose
      version moves under us and is garbage-collected off the mirrors
      — the identical argument that moved libunistring onto the
      self-built chain. A PE import scan of both published r10 packs
      settled who actually needs it: avcodec-62.dll (our ffmpeg's
      configure autodetects iconv) and libunistring-5.dll (its
      AM_ICONV takes an external libiconv on any platform whose libc
      has none), and nothing else — ncursesw included. Both importers
      are libraries we build, so this is a self-contained change
      rather than an attempt to out-run the toolchain's package
      closure. The pinned tarball and its SHA-256 are in
      resources/third-party.json and are now attached to every binary
      release like the other six.
    - MSYS2's libiconv cannot be uninstalled — the toolchain group
      depends on it — and its DLL has the identical basename, so
      "ours, not theirs" is enforced three times rather than assumed.
      build-libiconv.sh runs before libunistring and ffmpeg so the
      prefix is populated when they configure; libunistring gets
      --with-libiconv-prefix and ffmpeg gets --extra-cflags/-ldflags
      pointing at that prefix (iconv ships no .pc file, so
      PKG_CONFIG_PATH cannot steer either of them); and a new step
      re-hashes every bundled DLL against the one the lane staged,
      failing the release if they differ. Both configure changes are
      conditional on an iconv.h actually existing in the prefix, so
      the Linux and macOS lanes — which take iconv from libc — build
      exactly as before.
    - The Windows packs stop shipping four DLLs that nothing in them
      loads. A PE import-reachability closure from
      libnotcurses{,-core,-ffi}.dll over the published r10 packs found
      libintl-8.dll (both lanes) imported by nothing at all;
      libstdc++-6.dll and libgomp-1.dll (UCRT64) likewise — notcurses
      builds -DUSE_CXX=OFF and a decoder-only ffmpeg uses no OpenMP —
      and libgcc_s_seh-1.dll imported only by libstdc++, i.e.
      reachable only from another orphan. Every one of them arrived
      because bundle-dll's belt-and-braces sweep list named it, which
      is a list that copies things whether or not anything needs them.
      libintl was the expensive one: LGPL, package-manager sourced, no
      producible corresponding source, and no caller — the whole cost
      of a compliance obligation for none of the benefit. It is also
      why build-libiconv.sh configures --disable-nls, since libiconv
      would otherwise link libintl straight back in.
    - New release gate behind that: bundle-dll now fails the lane on
      any bundled DLL that nothing in the pack imports. The sweep list
      exists to survive an ldd that lies, so it will always
      over-collect at the margin; this is the counterweight, and it is
      general rather than a deny-list of four names. gcc-runtime keeps
      its manifest entry (the licences are the reason the decision
      went the way it did) but with empty pattern lists, so a
      reappearance now fails the third-party audit as an unlisted file
      too. What is left of GCC's runtime in the packs is the part gcc
      links statically into the UCRT64 DLLs, which is exactly what the
      GCC Runtime Library Exception exists to permit.
    - The macOS lanes gained the same "is this really ours?" check,
      by LC_UUID rather than by hash: bundle-macos rewrites install
      names, strips and re-signs every dylib it stages, so the bytes
      cannot match by design, while LC_UUID survives all three. It
      matters most for libunistring, which the macos-14 runner also
      has under brew as a transitive dependency of several preinstalled
      formulae — brew's bottle is 1.4.2 today, the same version we
      pin, so shipping it would even produce a true THIRD-PARTY.md,
      which is precisely the kind of coincidence that stops being true
      without anyone noticing.
    - opus builds with --disable-rtcd on Windows ARM64. opus's ARM
      runtime CPU detection has backends for Linux, Apple and MSVC —
      and none for mingw/clang on aarch64, where celt/arm/armcpu.c
      stops the build with an #error naming this exact flag. Safe
      rather than a compromise: NEON is architecturally baseline on
      aarch64, so the compile-time paths are always valid; the loss is
      runtime dispatch to optional dotprod/i8mm extensions, which
      matters to nobody decoding audio in a terminal. Proven on real
      aarch64 (alpine container): the flag removes every armcpu
      compile unit and the library builds clean.
    - build-ffmpeg.sh's architecture assertion reads config.h's exact
      ARCH_* macros instead of the configure summary's ARCH line. The
      summary normalises to the architecture family — an x86_64 build
      prints "ARCH x86", identical to i686 — so the assertion could
      never pass on UCRT64 and failed the first r10 dispatch against a
      correctly-configured build; aarch64 survives normalisation
      unchanged, so the arm lanes would have passed by luck rather
      than by checking anything. config.h defines ARCH_X86_64 /
      ARCH_AARCH64 exactly (verified against a real 8.1.2 configure),
      which is the question the assertion was always trying to ask.
    - The macos-arm64 release lane source-builds ffmpeg instead of
      installing brew's. brew's ffmpeg formula is a GPLv3 build
      (--enable-gpl, plus x264, x265, SvtAv1Enc and mp3lame), and
      dylibbundler dutifully pulled that whole encoder chain into the
      arm64 archive — a GPL-encumbered and considerably fatter bundle
      for a library that never encodes anything. That lane now runs
      the same build-{libdav1d,libvpx,libopus,ffmpeg}.sh chain
      macos-x86_64 and the Linux lanes already used: LGPL-2.1,
      decoder-only, and a bundle whose load commands name nothing but
      our own codec libs and Apple system frameworks. ncurses is the
      one library still taken from brew there — arm64 bottles target
      11.0+, which already matches that lane's floor, and X11-style
      licensing asks nothing of us beyond the notice we ship.
      libunistring and libdeflate left that list later in this same
      release; see their entries below.
    - build-ffmpeg.sh now asserts what it built. configure's output
      is tee'd, and the script fails if ffmpeg reports anything other
      than "License: LGPL version 2.1 or later", or if any
      --enable-decoder / -demuxer / -parser / -protocol name "did not
      match anything". ffmpeg only *warns* on an unmatched component,
      so a codec renamed out from under us would otherwise ship as a
      silently missing decoder — a build that succeeds and an archive
      that can't open the file.
    - Which immediately caught one: the Truevision TGA decoder is
      named `targa`; `tga` is only ever the file extension. TGA has
      been listed in the decoder allowlist — and absent from every
      bundle we ever shipped — for as long as the allowlist has
      existed. Now spelled correctly, and now actually present.
    - Pinned versions: ffmpeg 6.1.2 → 8.1.2 (newest 8.1 point release;
      staying a series behind 9.0 deliberately), dav1d 1.4.3 → 1.5.4,
      libvpx 1.14.1 → 1.16.0, opus 1.5.2 → 1.6.1, libdeflate 1.20 →
      1.25. opus now comes from downloads.xiph.org: xiph stopped
      attaching dist tarballs to their GitHub releases after 1.5.2,
      and the auto-generated tag tarball has no `configure` in it.
    - build-ffmpeg.sh also pins off the [autodetect] platform
      libraries — xlib, libxcb, sdl2, vulkan, libdrm. Every one of
      them serves a component we'd already switched off, but
      autodetection still put them on the link line: on a host with
      brew's libxcb installed, libavcodec came out with load commands
      on /opt/homebrew/opt/libx11 and friends, which dylibbundler
      would have dragged into the archive. What we ship should be a
      function of these scripts, not of the runner's package list.
    - nasm is only demanded of x86 hosts now. It assembles x86 SIMD
      and nothing else, so the arm64 lanes were being asked for a
      package they have no use for.
    - Both musl release lanes (linux-x86_64-musl, linux-aarch64-musl)
      source-build that same chain instead of `apk add ffmpeg-dev`.
      Alpine's ffmpeg is a GPLv3 build too: its libavcodec carries
      DT_NEEDEDs on libx264, libx265, libSvtAv1Enc, libmp3lame,
      libxvidcore, libaom and librav1e, `avutil_license()` answers
      "GPL version 3 or later", and bundle-elf.sh's ldd walk copied
      that entire 20 MB encoder chain into the archive we publish.
      Both lanes now run build-{libdeflate,libdav1d,libvpx,libopus,
      ffmpeg}.sh into a per-arch cache prefix with the same
      PKG_CONFIG_PATH / CMAKE_PREFIX_PATH wiring the glibc lanes use.
      The bundle's whole DT_NEEDED closure is now our own codec libs
      plus ncurses, libunistring, zlib and musl libc — 19 files,
      15.7 MB — and `avutil_license()` reports LGPL 2.1.
    - Windows (windows-x86_64 UCRT64 + windows-arm64 CLANGARM64) now
      source-builds the same LGPL-2.1, decoder-only ffmpeg chain
      (libdeflate, libdav1d, libvpx, libopus, ffmpeg) the other
      platforms use, instead of installing MSYS2's mingw-w64-*-ffmpeg.
      That package is a `--enable-gpl --enable-version3` build and
      bundle-dll swept its whole encoder tree — x264, x265, SvtAv1Enc,
      lame, rubberband — into the published archive, making the
      Windows prebuilts GPL-encumbered for a library that only ever
      decodes. The chain is cached per-msystem, verified for
      prefix-containment on every run (including cache hits), and a
      new release gate re-checks the shipped binaries for GPL imports
      and reads avcodec's embedded licence string. libvpx is linked
      statically on Windows — its configure refuses to emit a DLL off
      ELF/OS-2/Darwin — so it lives inside avcodec there; the
      libvpx_vp8 / libvpx_vp9 decoders the codec probe gates on are
      unaffected. Also fixes a latent arch bug: build-ffmpeg.sh's
      nasm gate read `uname -m`, which reports x86_64 under MSYS2
      even on Windows-on-ARM. With this, no lane installs a
      package-manager ffmpeg: all four platforms ship the identical
      decoder-only, LGPL-2.1 codec surface.
    - A workflow_dispatch of release.yml no longer publishes. It used
      to — useful when dispatch was the release path — but it meant
      "validate the recipe on a branch" and "overwrite the published
      archives every installed dist checksum-verifies against" were
      one click apart. Dispatch now builds and smokes all eight lanes
      and stops; only pushing the binaries-* tag itself publishes,
      the same contract as the app release workflows downstream.
    - musl apk list follows: `ffmpeg-dev` and `libdeflate-dev` out
      (libdeflate is source-built for version parity, not licensing —
      Alpine froze 1.20, we pin 1.25); curl / xz / bzip2 in, since
      the base image has neither curl nor GNU tar and the dep
      tarballs are .tar.xz and .tar.bz2; meson + ninja for dav1d;
      perl and diffutils for libvpx, whose configure hard-fails with
      "diff missing: Try installing diffutils" on busybox's applet;
      zlib-dev, because ffmpeg's png decoder has a hard zlib
      dependency that used to arrive transitively with ffmpeg-dev.
      nasm/yasm are added on the x86_64 lane only.
    - `_build-linux-musl.yml` gained the source-built-deps cache the
      lane never previously needed — actions/cache over
      `_ci-cache/alpine-3.20-<arch>`, keyed on the image tag, the
      arch, and a hash of all five build-lib*.sh / build-ffmpeg.sh
      scripts, with no restore-keys. Same discipline as the glibc
      lane's, for the same reason: a partially-restored prefix would
      corrupt the install tree mid-pkgconfig.
    - GNU libunistring joins the self-built chain on all four
      platforms, pinned at 1.4.2. It was the last package-manager
      library in the packs with a source-conveyance duty attached to
      it: it is LGPL-3.0-or-later OR GPL-2.0-or-later, we ship it as
      a binary, and conveying it under the LGPL means being able to
      hand a recipient the corresponding source for the exact
      `libunistring.so.5` they were given. dnf, apk, brew and pacman
      versions all move under us and get garbage-collected off the
      mirrors, so that was unanswerable a few months after any given
      release; a pinned tarball with its SHA-256 recorded in
      `resources/third-party.json` answers it indefinitely. Out of
      dnf's list on manylinux (which was still on 0.9.9, so the glibc
      packs shipped `libunistring.so.2` — they now ship `.so.5` like
      everyone else), out of apk's on alpine, out of `brew install`
      on macos-arm64, out of pacman's on both Windows lanes. macOS
      x86_64 already built it from source for deployment-target
      reasons and simply moved its invocation into the shared step.
    - `scripts/ci/build-libunistring.sh` stopped being the macOS
      x86_64 lane's private helper and became a peer of the codec
      scripts — same `$PREFIX` contract, same fetch-with-fallback,
      and a real post-install check (`unigbrk.h` plus a linkable
      `libunistring.*`) instead of an `ls | head`. The cache keys in
      all four `_build-*.yml` now hash it along with the rest.
    - Every lane now asserts that notcurses actually linked OUR
      libunistring, by reading `unistring:FILEPATH` out of
      `CMakeCache.txt` and requiring it to sit inside the source-built
      prefix — the same shape as the Windows lane's existing
      `DEFLATE:FILEPATH` check. This is not theoretical: locally, with
      the prefix deliberately missing libunistring, notcurses'
      `find_library(unistring unistring REQUIRED)` silently resolved
      `/opt/homebrew/lib/libunistring.dylib` and configured happily.
      A brew or dnf copy arriving transitively as some other package's
      dependency would have shipped a binary we never built, pinned,
      or published source for, which is precisely the failure this
      whole change exists to make impossible.
    - New `resources/third-party.json`: one entry per component that
      ships inside a pack, with SPDX licence, copyright notice,
      upstream URL, the exact pinned source (tarball URL + SHA-256,
      git URL + commit, or an honest "package manager"), and
      per-platform binary basename patterns. Fifteen components,
      including the ones nobody had written down before: the MSYS2
      toolchain runtimes the Windows packs carry (`libwinpthread-1`,
      the `libgcc_s_*`/`libstdc++`/`libgomp` set under the GCC Runtime
      Library Exception, or `libc++`/`libomp` under
      Apache-2.0-with-LLVM-exception on CLANGARM64), and `libiconv` /
      `libintl`. Those last two are a KNOWN GAP recorded in the file:
      they are LGPL and package-manager-sourced, i.e. exactly the
      problem libunistring just stopped having, but they arrive
      transitively rather than by our choosing and moving them is a
      separate piece of work. `zlib` and `ncursesw` stay
      package-managed with no gap — Zlib and X11-style licences ask
      for a notice, which we now ship, and nothing else.
    - Generated licensing kit in every archive: `THIRD-PARTY.md`
      (component / version / licence / copyright / source URL + hash
      table, plus the per-component notes) and a `LICENSES/` directory
      with the full text of every licence that pack's contents are
      under. Written by `scripts/ci/emit-third-party-kit.sh` from the
      manifest. It ships inside the archive rather than only in the
      repository because the archive is what a user actually receives,
      and a notice they have to go and find discharges nothing.
    - New release gate, `scripts/ci/audit-third-party.sh`, run in
      every lane: every file in `bundle/` must match some component's
      patterns for that platform (or the platform's system-library
      allow-list), and every component the manifest says ships there
      must actually be present. The first direction catches the
      accident that started this whole thread — the x264 / x265 /
      SvtAv1Enc encoder tree arrived in the packs because nobody chose
      it, it just came in on ffmpeg's dependency closure, and it took
      a licence audit rather than a build failure to notice. The
      second catches a library silently dropped by a bundling walk,
      which is a pack that breaks at `dlopen` on a user's machine.
      Verified against real locally-built artefacts: the macos-arm64
      bundle passes, the published r9 macos-arm64 pack fails naming
      all eight of its GPL/OpenSSL strays, a planted `libx264.165.dylib`
      fails, a removed `libunistring.5.dylib` fails, and a
      `libunistring.2.dylib` renamed in from a distro build fails on
      both counts at once.
    - Both licensing steps live in the `package-and-upload` composite
      action rather than in the four build workflows. That action is
      the single choke point every lane goes through on the way to
      producing an archive, so a lane cannot be added that ships an
      unaudited pack, and cannot be edited to skip the gate without
      also losing its upload.
    - `_release-publish.yml` attaches the six source tarballs
      (ffmpeg, dav1d, libvpx, opus, libdeflate, libunistring) to every
      binary release, downloading them at publish time from the same
      pinned URLs the build scripts use and hard-failing if any does
      not match the manifest's SHA-256 — attaching source that does
      not correspond to the shipped binaries would be worse than
      attaching none. `checksums.txt` now covers the tarballs as well
      as the eight archives; `Build.rakumod` looks entries up by
      artefact name, so the extra lines are inert to it.
    - The release body was also two years stale: it advertised
      `quay.io/pypa/manylinux2014_*` and a glibc 2.17 floor for lanes
      that have been manylinux_2_28 / glibc 2.28 for a while, and did
      not mention the Windows lanes at all. Fixed, and given a
      Licensing section that inventories what is copyleft (ffmpeg,
      libunistring — with their source attached) versus permissive,
      and states plainly that platform C runtimes are dynamically
      linked and therefore not redistributed.
    - `t/17-third-party-manifest.rakutest` validates the manifest
      hermetically on every `prove6`: SPDX strings come from a closed
      known-good set (adding a component under an unread licence has
      to be a decision, not a typo that sails through), URLs are
      https, tarball hashes are 64 hex digits, git refs are full
      commit SHAs, patterns are non-empty and whitespace-free and not
      claimed by two components at once, `required` is never true with
      an empty pattern list, and `resources/licenses/` and the
      manifest reference each other exactly — no orphan texts, no
      dangling references. It also cross-checks the notcurses ref
      against `NOTCURSES_FORK` and every component version against the
      `VERSION=` line in its `scripts/ci/build-*.sh`, so a version
      bump applied to one and not the other cannot ship source for
      8.1.2 alongside binaries built from 8.1.3.
    - Fixed in passing, found by tripping over it while proving this
      chunk out locally: the `notcurses-source-<sha>` actions/cache
      entry included the `build/` tree cmake creates inside the
      checkout. That key is shared by all four Linux lanes, so
      whichever finished first saved its CMakeCache — absolute paths,
      compiler paths, object files and all — and the other three
      restored it on the next run. Both container lanes bind-mount to
      `/work`, so cmake could not even notice the source directory had
      moved; it would have reused a manylinux-x86_64 configure in an
      alpine-aarch64 build. The cache path now excludes
      `*/build`. (Locally the same collision surfaced as cmake
      refusing to configure at all, which is how it got noticed —
      in CI it would have been a mystifying cross-lane failure
      appearing long after the commit that enabled it.)
    - `.gitattributes` pins `*.txt` and `*.json` to LF. The licence
      texts are copied verbatim into every pack, so without this the
      same file would hash differently in a Windows .zip than in a
      Linux .tar.gz depending on the runner's checkout settings.
    - ncursesw stays package-managed, deliberately. Its licence is
      MIT-style X11: a notice duty, which `LICENSES/X11.txt` and the
      copyright line in `THIRD-PARTY.md` now discharge, and no source
      duty at all. Source-building it for uniformity is a reasonable
      future pass, but its `--with-default-terminfo-dir` / `ticdir`
      configuration is genuinely delicate per platform (the macOS
      x86_64 lane, which does build it, has to skip `install.data`
      because `tic` would write to SIP-protected `/usr/share/terminfo`),
      and that is a poor trade against a licence that asks nothing of
      us.
    - The first live CI dispatch of the above found four more bugs the
      local proofs hadn't:
    - ffmpeg.org is flaky from GitHub-hosted runners — every lane's
      ffmpeg download saw connection timeouts/resets against it,
      reproduced locally too, so it wasn't a one-off. `build-ffmpeg.sh`
      now fetches ffmpeg's own GitHub mirror tag archive
      (`FFmpeg/FFmpeg` @ `refs/tags/n8.1.2.tar.gz`) instead of
      `ffmpeg.org/releases`. That only works because ffmpeg checks
      `configure` into git rather than generating it via `make dist` —
      most projects' tag archives have no buildable configure, this
      one does. VERSION stays the bare `8.1.2` (still what t/17
      cross-checks the manifest against); a new TAG variable carries
      the `n`-prefixed release-tag spelling. `resources/third-party.json`
      picked up the new URL, sha256 and an explicit `filename`
      (`ffmpeg-n8.1.2.tar.gz` — the bare `n8.1.2.tar.gz` says nothing
      about what it is and would collide with the next project pinned
      at the same tag, same reasoning as libvpx's entry already
      documents), and t/17 gained an ffmpeg-specific check tying the
      manifest's URL/filename to `build-ffmpeg.sh`'s `n${VERSION}` tag
      convention explicitly, rather than trusting the generic
      substring-contains-version check to have caught a dropped `n` or
      a reversion to the old ffmpeg.org layout.
    - The two container lanes (`build-linux-glibc.sh`,
      `build-linux-musl.sh`) now `chown` `bundle/` and `$CACHE_DIR`
      back to the host user before exiting. Docker on a Linux runner
      runs these containers as root against a bind-mounted `/work`, so
      everything they create there — the bundle directory above all —
      comes out root-owned on the host. The new `package-and-upload`
      composite action's `emit-third-party-kit.sh` step runs
      afterwards, on the host, as the unprivileged runner user, and
      its `mkdir bundle/LICENSES` hit exactly that: "Permission
      denied" on musl (both arches) and glibc-aarch64. Ownership is
      restored by reading `/work`'s own uid:gid off the bind mount
      itself (`stat -c '%u:%g' /work`) rather than assuming a fixed
      runner uid, so the fix holds regardless of which uid GitHub
      happens to run the job as. `_ci-cache/notcurses-source` didn't
      need the same treatment — nothing host-side ever writes into it,
      only containers read and write it across runs.
    - `_build-macos.yml`'s arm64 lane stopped taking libdeflate from
      brew and now source-builds it into the same workspace cache
      prefix as ffmpeg and friends, alongside the ncurses it still
      keeps from brew. The lane's `PKG_CONFIG_PATH` /
      `CMAKE_PREFIX_PATH` / `CPATH` env block only ever pointed at
      that cache prefix, never at brew's include path, so notcurses'
      `find_path(libdeflate.h)` came up empty despite the brew keg
      being installed — "Couldn't find libdeflate.h" at configure
      time. The earlier local proof of this lane missed it because it
      built libdeflate into the prefix on both arches by hand; the
      live workflow, as written, hadn't. This also makes
      `resources/third-party.json`'s libdeflate entry — which already
      claims "ours everywhere" — actually true on arm64 instead of
      silently shipping brew's copy under that claim. The cache key's
      `hashFiles` list already covered `build-libdeflate.sh`, so no
      cache-invalidation change was needed.
    - All four `_build-*.yml` dependency-chain caches moved from the
      combined `actions/cache` to a restore/save split. The combined
      action only saves in a post step gated on job success, and this
      dispatch showed exactly what that costs: the Windows lane built
      the entire libdeflate→dav1d→vpx→opus chain, died at the ffmpeg
      download (see above), and saved nothing — the next push would
      have re-paid the whole ~15-20 min build for a failure that had
      nothing to do with the codec chain. `_build-windows.yml` and
      `_build-macos.yml` now save immediately after their
      already-separate source-build step, before the steps that can
      still fail (notcurses configure/build). The two container lanes
      needed more than a step reorder: their entire build previously
      ran as one `docker run` per architecture, chain and notcurses
      build together, so there was no step boundary to save at. Both
      `build-linux-glibc.sh` and `build-linux-musl.sh` gained a
      `DEPS_ONLY=1` escape hatch that stops (and chowns `$CACHE_DIR`,
      per the fix above) right after the codec chain is ready, letting
      the workflow split the single docker invocation into two: one
      that builds (or, cache-hit, skips) just the chain, cached and
      saved immediately after; a second, unconditional one that builds
      notcurses against the now-warm cache. Precedent for the
      restore/save shape is App-Moneymoor's vcpkg cache block.

0.4.4  2026-08-12T10:43:56+01:00
    - Fix `Build.rakumod`'s `!sha256` unconditionally shelling out to
      `shasum -a 256` on every POSIX platform. manylinux/EL-minimal
      containers ship `sha256sum` (coreutils) but no `shasum` (a Perl
      tool) — the run failed, the digest came back undefined, and the
      caller reported "Checksum mismatch ... got unknown", rejecting
      a perfectly valid prebuilt pack and falling back to a source
      compile that then dies for want of ncursesw/libunistring. POSIX
      now tries `sha256sum` first (universal on Linux), then
      `shasum -a 256` (macOS/BSD); either tool missing, unspawnable,
      non-zero-exit, or producing no 64-char lowercase-hex digest
      falls through to the next. The mismatch note now also names
      which tool(s) were tried when the digest came back unknown.

0.4.3  2026-08-11T15:36:14+01:00
    - Bump BINARY_TAG to binaries-notcurses-3.0.17-r9 — first pack
      that ships the shim .srchash sidecars.
    - Shim freshness is now content-based, not mtime-based. A
      libnotcurses_native_shim.<ext>.srchash sidecar (SHA-256 of the
      shim source) ships in the prebuilt packs and is written by every
      local compile; mtimes are meaningless across machines, and the
      old comparison made every fresh ecosystem install think the
      dist's shim source was newer than the pack's prebuilt shim —
      silently recompiling (or, without a C toolchain, warning and
      falling back to the slow per-cell path despite a good shim in
      the pack; observed on a toolchain-less Alpine CI container).
    - Successful shim compiles are parked in a content-addressed
      build cache (<cache>/shims/<src-sha256>.<ext>), so a machine
      that compiled once never recompiles for the same shim source +
      binary tag, even though extraction wipes the staged dir on
      every install.

0.4.2  2026-08-11T12:17:30+01:00
    - Clarify docs

0.4.1  2026-08-03T17:00:29+01:00
    - Use notcurses with patch to support tmux 3.4

0.4.0  2026-05-20T09:19:16+01:00
    - CI fix: codec-probe PNG fixture moved from chunli44.png →
      chunli01.png. The notcurses repo has ~11 chunli* PNGs that are
      symlinks to earlier frames (chunli32-37, 39, 41-44 → others).
      On Unix git checks them out as real symlinks; on Windows git's
      default core.symlinks=false materializes each as a 12-byte
      text file containing the target's name, so the codec probe
      opened "chunli40.png" as content, libavcodec read 8 bytes,
      saw "chunli40" instead of the PNG magic 89 50 4E 47 0D 0A 1A
      0A, and rejected the file with "Invalid PNG signature
      0x6368756E6C693430". chunli01.png is a regular file and works
      identically on every lane. Header comment in codec-probe.c
      now warns future authors to pick regular files only.
    - CI fix: fetch-notcurses-source.sh now strips trailing \r from
      each parsed line of NOTCURSES_FORK before applying the 40-char
      lowercase-hex SHA check. Windows checkouts with
      core.autocrlf=true (default for Git for Windows) were leaving
      a CR on the SHA, pushing it to 41 chars and failing the regex
      with "sha=… must be a 40-char lowercase hex string." `read -r`
      strips \n but not \r, so the fix is one `${line%$'\r'}` trim
      per iteration. Linux glibc/musl workflows' `awk` SHA extraction
      gains a `tr -d '\r'` for the same belt-and-braces reason.
      Primary fix: new top-level .gitattributes pins NOTCURSES_FORK
      / BINARY_TAG / *.sh / *.yml / *.yaml to `text eol=lf` so fresh
      checkouts no longer get CRLF in the first place. Existing
      clones renormalize via `git add --renormalize .` (or the
      bash/awk hardening covers them transparently).
    - vendor/notcurses/ deleted from the repo. Build.rakumod's shim-
      compile path now resolves include headers (and the Windows
      import lib) by calling !ensure-notcurses-source — the same SHA-
      keyed git fetch the source-build fallback uses, wrapped in a
      try{} so prebuilt-only installs that hit the shim short-circuit
      don't trigger an unnecessary fetch. Selkie's
      `examples/viewported-card-list.raku` previously reached into
      the sibling Notcurses-Native vendor/notcurses/data/ dir for
      sample images; Selkie 0.8.0+ vendors the 10 referenced images
      under its own examples/data/ tree. .gitignore drops the now-
      meaningless `vendor/notcurses/build/` entry.
    - CI/CD: all four prebuilt-binary lanes (macOS arm64/x86_64,
      Linux glibc x86_64/aarch64, Linux musl x86_64/aarch64, Windows
      x86_64/arm64) and the bundle / codec-probe helpers now fetch
      notcurses from the SHA pinned in NOTCURSES_FORK instead of
      reading from vendor/notcurses. Single source of truth — both
      install-time (Build.rakumod) and release-time (CI) build from
      the exact same commit, so no more "I bumped NOTCURSES_FORK
      and forgot to sync vendor/notcurses" drift. New shared script
      scripts/ci/fetch-notcurses-source.sh mirrors Build.rakumod's
      !ensure-notcurses-source: git init + fetch-by-SHA into
      $NOTCURSES_SRC_CACHE/<sha>/, idempotent reuse on cache-hit,
      writes NOTCURSES_SRC_DIR=<path> to $GITHUB_ENV when running
      under GHA. Workflows cache the per-SHA checkout via
      actions/cache keyed on the pin so bumping NOTCURSES_FORK auto-
      invalidates. Migrations:
        * scripts/ci/build-linux-glibc.sh + build-linux-musl.sh +
          bundle-elf.sh now consume $NOTCURSES_SRC_DIR. musl lane
          adds `git` to its `apk add` list.
        * .github/workflows/_build-{macos,windows}.yml gain a
          "Fetch notcurses source" step; subsequent steps use
          $NOTCURSES_SRC_DIR. Windows lane adds `git` to its msys2
          install set.
        * .github/actions/bundle-{dll,macos}/action.yml read
          $NOTCURSES_SRC_DIR/build instead of vendor/notcurses/build.
          bundle-dll converts the Windows-style env value through
          cygpath for MSYS2 bash; bundle-elf captures $workspace
          before cd'ing into the build dir so absolute-path copies
          don't depend on `cd ../../..` relative math.
        * scripts/ci/codec-probe.c switches from $WORKSPACE_DIR + a
          hardcoded vendor/notcurses/data relpath to a single
          $NOTCURSES_DATA_DIR env var (set by run-codec-probe.sh to
          $NOTCURSES_SRC_DIR/data). FIXTURES paths now bare basenames.
        * NOTCURSES_FORK header updated — drops the now-obsolete
          "CI prebuilt lanes still read from vendor; collapse onto
          the fetch once CI is migrated" caveat.
        * .gitignore: ignore _ci-cache/ and bundle/.
      Escape hatch unchanged: NOTCURSES_NATIVE_VENDOR_DIR=<path>
      short-circuits the fetch for fork iteration / airgapped builds.
    - new module lib/Notcurses/Native/Str.rakumod with three helpers:
      libc-name() (musl/glibc/macOS/Windows libc resolver shared with
      Native.rakumod's setenv path), strdup-copy-and-free() (decodes
      a malloc'd C char* and frees via libc free), and
      borrowed-str-from-pointer() (decodes a pointer into caller-
      provided storage without freeing). Used to fix the memory-leak
      class below.
    - memory leak fix: every binding that returned a heap-allocated
      char* and was declared `--> Str` silently leaked the original
      malloc'd pointer at every call. MoarVM's NativeCall copies the
      bytes into a Raku Str and does NOT free the source. Affected
      functions in Context.rakumod (ncwcsrtombs, notcurses_at_yx,
      notcurses_detected_terminal, notcurses_accountname,
      notcurses_hostname, notcurses_osversion), Cell.rakumod
      (nccell_extract, nccell_strdup), Plane.rakumod
      (ncplane_at_cursor, ncplane_at_yx, ncplane_contents),
      Direct.rakumod (ncdirect_readline, ncdirect_detected_terminal),
      and Widgets.rakumod (ncreader_contents). Each now rebinds to
      `--> Pointer` under a `_<name>_raw` symbol and exposes the
      public name as a Raku wrapper that calls strdup-copy-and-free.
    - pointer-into-buf returns (Context.rakumod's ncnmetric, ncqprefix,
      nciprefix, ncbprefix family) rebind similarly but route through
      borrowed-str-from-pointer so the caller's CArray[uint8] $buf
      isn't double-freed.
    - library-owned static pointer returns (notcurses_str_blitter,
      notcurses_str_scalemode, notcurses_version, ncplane_name,
      ncselector_selected/previtem/nextitem, ncmenu_selected/
      mouse_selected, nctab_name, nctabbed_separator,
      nccell_extended_gcluster) keep their `--> Str` binding and
      gain an `OWNED-BY-LIBRARY` Pod comment so a future audit
      doesn't flag them as leaks.
    - new API: ncpile-render-to-string(NcplaneHandle --> Str) in
      Context.rakumod. Owns the malloc'd buffer lifecycle for
      ncpile_render_to_buffer — no more "caller forgets to free
      the output pointer" leaks for snapshot/rendering consumers.
      The raw ncpile_render_to_buffer binding stays exported for
      callers who want to manage the lifecycle themselves.
    - behavior fix: setenv(3) failures in lib/Notcurses/Native.rakumod
      now `note` to stderr instead of being silently ignored. Pre-fix,
      a libc setenv failure (ENOMEM/EINVAL) silently desynced %*ENV
      and the C environment — ncurses then couldn't find terminfo and
      notcurses_core_init failed with a vague error. The libc resolver
      replaces the previous hardcoded 'libc.so.6' so musl Alpine /
      distroless containers no longer dlopen-fail at module load.
      The per-call redeclaration of mac_setenv / linux_setenv is
      gone too — one module-level binding now serves every call.

0.3.4  2026-05-16T04:01:59+01:00
    - Build.rakumod: drop $MIN-GLIBC from v2.35 to v2.28. The Linux
      glibc lanes are rebased onto manylinux_2_28 containers
      (quay.io/pypa/manylinux_2_28_{x86_64,aarch64}, RHEL 8 baseline)
      so the prebuilts now load on every glibc Linux distro under
      active maintenance in 2026 — RHEL 8+, Ubuntu 18.10+, Debian
      10+. Previously the 2.35 floor meant Ubuntu 20.04 / Debian 11
      users fell back to a 5-15 min CMake source build. (manylinux2014
      / RHEL 7 / glibc 2.17 was the first target but pypa retired it
      in March 2025 and its CentOS 7 yum mirrors are decaying after
      the June 2024 EOL — manylinux_2_28 is the maintained successor.)
    - Build.rakumod: add libc axis to detect-platform. Linux keys
      now carry a libc suffix (`linux-x86_64-glibc`,
      `linux-x86_64-musl`, etc.); non-Linux keys unchanged. New
      `detect-libc` method probes `/lib/ld-musl-*.so.1` then falls
      back to `!detect-glibc-version`. Implicitly fixes a silent bug
      where musl users (Alpine / Postmarket OS / Void) were
      downloading the glibc artefact and segfaulting at first dlopen
      because `ldd --version` returns non-zero on musl, which
      short-circuited the glibc-too-old guard.
    - %PLATFORM-SLUGS: add linux-x86_64-musl, linux-aarch64-musl,
      linux-x86_64-glibc, linux-aarch64-glibc keys (the glibc entries
      are renamed from the previous `linux-<arch>` keys). The musl
      lanes are built in alpine:3.20 containers (musl 1.2.5 headers,
      1.20+ runtime floor per notcurses' declared support level).
    - Unknown-platform diagnostic now reports the detected libc on
      Linux, so a user on an unsupported libc-arch combination can
      see exactly which axis didn't match.
    - %PLATFORM-SLUGS: add 'darwin-x86_64' => 'macos-x86_64' so Intel
      Macs (Mac Pro 2013, iMac Pro, the 2016-2020 Intel MacBook Pro
      line, Mac Pro 2019) and Hackintoshes get a prebuilt download
      instead of a 5-15 min CMake source build. The CI repo
      (m-doughty/Notcurses-Native) produces the artefact on an arm64
      GHA runner under Rosetta 2 — clang under Rosetta emits ordinary
      x86_64 Mach-O that native Intel Macs run identically. Pinned at
      MACOSX_DEPLOYMENT_TARGET=10.15 (Catalina) so the artefact loads
      on every Intel Mac Apple supports back to ~2012 hardware. The
      build path was chosen because GitHub's macos-13 native-x86_64
      runner is on its way out; arm64-with-Rosetta is the long-lived
      option. Side effect: x86_64 Rakudo running under Rosetta on
      Apple Silicon now gets a working prebuilt too instead of
      falling through to source build — not the audience this lane
      targets, but a free win.
    - NOTE: this version (0.3.4) still ships BINARY_TAG=r5. The
      darwin-x86_64 slug-map entry only becomes useful once the
      m-doughty/Notcurses-Native CI repo publishes a binaries-
      notcurses-3.0.17-r6 release that includes the new
      notcurses-macos-x86_64.tar.gz artefact; until then, Intel Mac
      installs will still take the source-build fallback (just like
      pre-0.3.4). When r6 ships, BINARY_TAG → r6 + resources/
      checksums.txt update happen as a single follow-up commit
      (resources/checksums.txt has the procedure documented at the
      top of the file). That commit cuts 0.3.5.
    - Build.rakumod unknown-platform diagnostic: dropped the
      Apple-Silicon-Rosetta hint introduced in 0.3.3. With
      darwin-x86_64 now mapped, that case never reaches the
      `without $plat` branch — the hint was unreachable and would
      have misled anyone hitting the diagnostic for an actually-novel
      platform.
    - t/16-build-detect-platform.rakutest: updated darwin/x86_64
      assertion (now expects 'macos-x86_64') and added darwin-x86_64
      to known-platform-keys expected set. Unknown-platform Str:U
      coverage stays via the freebsd/riscv64 case.
    - Build.rakumod: fix `zef install` aborting with "Type check failed
      for return value; expected Str but got Any" on platforms not
      mapped in %PLATFORM-SLUGS. The headline case is x86_64 Rakudo
      running under Rosetta on Apple Silicon, which reports
      $*KERNEL.hardware = 'x86_64' → key 'darwin-x86_64' → unmapped.
      Root cause: the slug-map hash was untyped, so missing-key lookups
      returned `Any` (not `Str`) and tripped detect-platform's `--> Str`
      constraint before the intended source-build fallback at line 102
      could fire. Typed the hash `my Str %PLATFORM-SLUGS`. While here,
      promoted `!detect-platform` to a public `detect-platform(:$os,
      :$hardware --> Str)` so tests can inject kernel pairs without
      having to override `$*KERNEL`, and added `detect-platform-key` /
      `known-platform-keys` helpers used by the unknown-platform
      diagnostic.
    - Build.rakumod: the unknown-platform warning now lists the exact
      key that was looked up (e.g. 'darwin-x86_64'), the values of
      $*KERNEL.name / $*KERNEL.hardware, the full set of platforms
      that DO have prebuilts, and an Apple-Silicon-under-Rosetta hint.
      Saves the next person an hour of staring at the previous
      one-line message.
    - t/16-build-detect-platform.rakutest: new regression test
      covering every entry in %PLATFORM-SLUGS plus the
      darwin-x86_64 (Rosetta) and freebsd-riscv64 (fully unknown)
      fall-throughs. Would have failed on 0.3.2.

0.3.2  2026-05-12T16:55:21+01:00
    - lib/Notcurses/Native.rakumod: convert the `$nc-lib`,
      `$ffi-lib`, `$core-lib`, `$shim-lib` library-path bindings
      from `constant` to state-cached subs (`sub nc-lib { state $r =
      _resolve-lib(...); $r }`). `constant X = _resolve-lib(...)`
      ran at compile time and baked the resolved path into the
      precompiled bytecode — and Rakudo doesn't track
      `resources/BINARY_TAG` as a precomp dependency. A BINARY_TAG
      bump (which moves staged libs to a new versioned directory
      and may GC the previous one) would leave the precomp pointing
      at the old path, producing "Cannot locate native library"
      errors on freshly installed packages until the user manually
      ran `rm -rf ~/.raku/precomp/`. Deferring resolution to first
      sub-call means each process picks up the current tag,
      regardless of when the precomp was built. NativeCall accepts
      a Callable for `is native()` and invokes it lazily on first
      use of each bound sub. All 600+ bindings in `lib/Notcurses/
      Native/*.rakumod` updated from `is native($X-lib)` to
      `is native(&X-lib)` to match.
    - t/15-shim-presence.rakutest: updated to call `shim-lib()`
      instead of treating `$shim-lib` as a Str — the constant is
      now a state-cached sub.
    - BINARY_TAG bumped to binaries-notcurses-3.0.17-r5. The r4 tag
      was never published — the workflow that produces its archives
      failed before the release step on Linux (the
      --unresolved-symbols=ignore-in-shared-libs flag was misnamed
      and left object-file unresolved refs intact) and on Windows
      (the objdump-based export check matched the wrong format).
      The shim binary's runtime contract changed as part of fixing
      those builds — Linux now links directly against
      libnotcurses-core with DT_NEEDED + DT_RUNPATH=$ORIGIN
      instead of deferring resolution to the host process's flat
      symbol namespace; macOS now reserves -Wl,-headerpad_max_-
      install_names so any future install_name_tool relocation
      doesn't overflow the Mach-O header — so the new artifact
      isn't bit-identical to the r4 spec even though the API is
      the same. Bumping the tag avoids any ambiguity about which
      shim build downstream consumers are running against.
    - .github/workflows/build-binaries.yml: Linux shim step now
      links the shim explicitly against bundle's libnotcurses-core
      (-Lbundle -lnotcurses-core) and sets DT_RUNPATH=$ORIGIN via
      patchelf. Mirrors Vips-Native's working pattern. Drops the
      strip --strip-unneeded step that was clearing the regular
      symtab and breaking the post-strip nm -g check.
    - .github/workflows/build-binaries.yml: Windows shim step
      replaces the objdump -p export-table regex with
      nm -g --defined-only (matches Vips-Native; stable across
      MinGW/UCRT/CLANGARM64 binutils versions where the objdump
      output format differs). Drops the strip --strip-unneeded
      step too — same regular-symtab issue manifests on PE.
    - .github/workflows/build-binaries.yml: macOS shim step adds
      -Wl,-headerpad_max_install_names so any future
      install_name_tool relocation has the load-command padding
      it needs (defensive — Build.rakumod's
      !rewrite-macos-install-names explicitly skips the shim
      today, but this is belt-and-braces against future relocators).
    - Build.rakumod: !try-compile-shim mirrors the workflow
      changes — Linux links explicitly, macOS adds the
      headerpad option. Source-build path now matches the
      prebuilt path in every meaningful way.
    - Build.rakumod: !rewrite-macos-install-names glob now
      excludes `libnotcurses_native_shim.dylib` via
      `!~~ /'_shim'/`. The shim has no @rpath/libnotcurses*.dylib
      dependencies to rewrite (it's compiled -undefined dynamic_-
      lookup) and its short @loader_path install-name can't be
      replaced with the absolute staged path without headerpad
      space — on a force-install the previous run's shim was
      already on disk and the rewrite pass loudly refused it.

0.3.1  2026-05-12T16:01:23+01:00
    - CI/CD republish binaries
    - src/notcurses_native_shim.c: new C-side perf-shim module with
      batched primitives that are unaffordable to express call-per-
      cell over Raku's NativeCall boundary. Initial export
      `notcurses_native_copy_cells`, a direct port of
      Selkie::Widget::ViewportedCardList's per-cell read+write loop
      (copies a rows × cols slice from one ncplane to another with
      base-cell substitution for empty source cells, matching
      ncplane_at_yx semantics). Selkie's VCL!copy-cells used to spend
      ~75,000 NativeCall trips per render on a chat with five visible
      cards × five widget planes × ~3000 cells; the shim collapses
      that to one. Linked with -undefined dynamic_lookup (macOS) or
      -Wl,--unresolved-symbols=ignore-in-shared-libs (Linux) so it
      has no link-time dependency on libnotcurses — symbols resolve
      at runtime against the host process's already-loaded
      libnotcurses.
    - Build.rakumod: !try-compile-shim stages a compiled
      libnotcurses_native_shim alongside the existing notcurses libs
      on every install path (prebuilt, source build, fallback).
      Non-fatal when no C toolchain is available — Selkie's binding
      flips an internal latch and falls back to the per-cell Raku
      merge with a one-shot user-visible warning so the perf cost is
      attributable.
    - lib/Notcurses/Native.rakumod: $shim-lib constant exported,
      resolved by the same _resolve-lib lookup as the core libs.
    - lib/Notcurses/Native/Plane.rakumod: notcurses_native_copy_cells
      Raku binding bound to $shim-lib.
    - .github/workflows/build-binaries.yml: CI builds + ships the
      shim binary in the prebuilt archives so users on supported
      platforms don't need a C toolchain at install time.
    - BINARY_TAG bumped to binaries-notcurses-3.0.17-r4 so prebuilt
      caches invalidate and consumers pick up archives that include
      the shim.
    - t/15-shim-presence.rakutest: assert libnotcurses_native_shim
      is staged. Gated on NOTCURSES_NATIVE_REQUIRE_SHIM=1 — set in
      both test.yml and glibc-fallback.yml workflows so CI fails
      loudly if either the prebuilt archive drops the shim or
      Build.rakumod's !try-compile-shim silently failed on the
      source-build path. End-user installs without the env var skip
      the test cleanly so a missing toolchain doesn't break the
      install.
    - lib/Notcurses/Native.rakumod: $NOTCURSES_NATIVE_LIB_DIR
      override doc tightened to spell out that the patched
      libnotcurses we ship (0.3.0's ncvisual_blit_internal begy/begx
      fix) is ABI-compatible at the C symbol level but BEHAVIOURALLY
      incompatible — pointing the override at vanilla system
      notcurses 3.0.17 silently misrenders any clipped sprixel (chat
      avatars at the top of the scroll, for example).

0.3.0  2026-05-12T00:11:57+01:00
    - vendor/notcurses (src/lib/visual.c): patch ncvisual_blit_internal
      to honor ncvisual_options.begy/begx/leny/lenx for all blit paths
      (generic resize, FFmpeg, OIIO, all sprixel and cell blitters).
      Upstream notcurses 3.0.17 silently drops these fields whenever
      the source needs resizing — sprixel blitters take only
      (data, leny, lenx) with no begy/begx parameter and consume from
      data[0], so a cropped blit shows the top of the source instead
      of the requested sub-region. Cell blitters reference begy/begx
      but index the resized buffer with the input-space offset, which
      is a latent out-of-bounds read for begy > 0.
      The fix introduces a static ncvisual_subregion_internal helper
      that materializes the requested source region once at the entry
      of ncvisual_blit_internal (single alloc + single row-loop
      memcpy, honoring source rowstride padding and re-padding the
      destination via pad_for_image). Downstream backends then see a
      "full source" and consume normally, with begy/begx/leny/lenx
      zeroed in a local blitterargs copy.
      Fixes Selkie's ViewportedCardList rendering the wrong rows when
      an image is partially clipped at the top of the viewport, and
      incidentally addresses the FIXMEs at upstream
      src/lib/internal.h (blitterargs comment) and src/media/oiio.cpp.
    - Build.rakumod: rewrite macOS install-names to absolute staged
      paths via install_name_tool after staging the dylibs. Without
      this, dyld resolves `@rpath/libnotcurses-core.3.dylib` (and
      siblings) through the LOADER CHAIN'S rpaths, which on a typical
      Homebrew-Raku setup means `raku`'s `@executable_path/../lib`
      (= `/opt/homebrew/lib`) is searched first — and if Homebrew's
      notcurses is also installed there, dyld silently loads
      Homebrew's unpatched library instead of ours. Tests and the
      module load succeed (path resolution at the Raku level still
      reports our staged path) but the actual symbol resolution
      runs the wrong code. Baking the absolute path into the
      install-name eliminates dyld's discretion.
      Also expanded find-lib to stage every version variant
      (`libfoo.dylib`, `libfoo.3.dylib`, `libfoo.3.0.17.dylib`)
      because the rewritten install-names point to the
      `.3.dylib` symlinks, which must exist at the staged path.
    - BINARY_TAG bumped to binaries-notcurses-3.0.17-r3 to invalidate
      prebuilt binary caches and force consumers to pick up the
      patched library.

0.2.6  2026-04-29T23:53:41+01:00
    - Bump Github actions to use node 24+
    - `Build.rakumod` now garbage-collects sibling staged dirs for
      older BINARY_TAGs after each successful install. Without this,
      every release accumulated another `binaries-notcurses-*` dir
      under `~/.local/share/Notcurses-Native/`, where stale Raku
      precomp could load the older libs alongside the new ones —
      cf. the Vips::Native r7→r8 incident that revealed this class
      of bug. Set `NOTCURSES_NATIVE_KEEP_OLD_STAGES=1` to opt out
      (e.g. when intentionally pinning multiple versions for testing).

0.2.5  2026-04-16T03:16:51+01:00
    - Native.rakumod: set TERMINFO_DIRS at module load via libc
      setenv(3) so ncurses finds terminal definitions on systems
      without Homebrew ncurses installed. Our bundled libncursesw
      was compiled against Homebrew's ncurses, which bakes the
      terminfo search path to the Homebrew cellar — on a fresh
      Mac without `brew install ncurses`, that path doesn't exist
      and notcurses_core_init fails with "No terminal available"
      even though the libraries loaded fine. The fix points
      TERMINFO_DIRS at macOS's system /usr/share/terminfo/ (always
      present) plus common Linux paths. Uses the same _setenv-c
      pattern as Vips-Native (Raku's %*ENV doesn't propagate to C
      getenv on macOS). Respects user-set TERMINFO_DIRS.
    - test.yml: run prebuilt-path t/ tests before installing
      system deps (brew/apt) so the self-contained bundle is
      exercised with no system notcurses present. xt/ tests
      (terminal-dependent) run after system deps install since
      they need terminfo data on macOS. Saves several minutes per
      failed run.

0.2.4  2026-04-16T02:58:01+01:00
    - Build.rakumod: detect system glibc via `ldd --version` and
      fall back to CMake source compile when it's older than the
      prebuilt target (currently v2.35, matching the ubuntu-22.04
      CI runner). Previously, users on Ubuntu 20.04 / Debian 11 /
      RHEL 8 downloaded prebuilt libnotcurses + ffmpeg libs that
      loaded but failed at first symbol use with "GLIBC_2.xx not
      found". The guard fires before the download so affected users
      just see a one-line note and a ~3–5 min source compile
      (core-only if their ffmpeg dev packages are missing) instead
      of a broken install. NOTCURSES_NATIVE_BINARY_ONLY=1 now
      hard-fails with a clear message on old-glibc systems rather
      than producing a broken install.
    - New CI workflow .github/workflows/glibc-fallback.yml: runs
      `zef install .` inside an ubuntu:20.04 container (glibc 2.31)
      with apt-installed cmake + ncurses/unistring/deflate dev
      packages and asserts both that the fallback message appears
      in the build log and that the source-compiled libs load.

0.2.3  2026-04-15T02:52:48+01:00
    - CI: rework test.yml to install via zef (which runs Build.rakumod
      → downloads prebuilt → SHA-verifies → stages libs to the XDG
      data dir) instead of building notcurses by hand and dropping
      libs into resources/lib/. The hand-build step was a vestige of
      the pre-XDG-staging layout and stopped working when META6.json
      dropped the lib resource entries in 0.2.2 — tests started
      failing with "cannot open shared object file" because nothing
      was actually staging libs to where Native.rakumod now looks.
      Workflow now also re-installs with NOTCURSES_NATIVE_BUILD_FROM_
      SOURCE=1 as a second pass to keep the CMake fallback path
      covered on every CI run.

0.2.2  2026-04-15T02:46:26+01:00
    - Stage native libs to an XDG-style data dir
      ($XDG_DATA_HOME/Notcurses-Native/<binary-tag>/lib/, or
      $LOCALAPPDATA on Windows, ~/.local/share fallback) instead of
      the dist's resources/. Reason: zef hashes every staged resource
      filename to a SHA-keyed name, which silently breaks the inter-
      dylib references baked into notcurses (libnotcurses.dylib loads
      libnotcurses-core.3.0.17.dylib via @loader_path; same on Linux
      with $ORIGIN, same on Windows with sibling-DLL search). Hashed
      filenames meant the loader couldn't find any sibling lib by its
      real name, and `Notcurses::Native` died at first dlopen with a
      cryptic "Library not loaded" error post-install. Tests passed at
      install time (pre-staging) so the bug only surfaced when
      consumers like Selkie tried to actually use the module.
    - META6.json no longer lists the 9 dylib/.so/.dll entries; only
      checksums.txt and a new BINARY_TAG resource (a tiny text file
      immune to the hash-renaming problem, used by Native.rakumod to
      locate the staged-libs directory at runtime).
    - Native.rakumod resolver: env override
      (NOTCURSES_NATIVE_LIB_DIR) → XDG-staged dir → fail with the
      staged path in the error message for actionable debug.
    - New env knob: NOTCURSES_NATIVE_DATA_DIR to override the XDG base
      directory (e.g. for system-wide installs or sandboxed envs).

0.2.1  2026-04-15T02:23:12+01:00
    - Build: extract Windows .zip prebuilts via PowerShell's
      Expand-Archive instead of `tar`. GNU tar (which is first on
      PATH inside MSYS2 install environments) parses `D:\...` as a
      remote `host:path` and bombs with "Cannot connect to D:
      resolve failed". Expand-Archive ships on every supported
      Windows and has no such quirk. macOS / Linux still use `tar`.

0.2.0  2026-04-15T02:17:17+01:00
    - Prebuilt-binary-first install path. Build.rakumod now attempts
      to download a per-platform archive from the repo's GitHub
      Releases containing all three notcurses libs (libnotcurses,
      libnotcurses-core, libnotcurses-ffi) plus the ffmpeg sibling
      dylibs notcurses dyn-links, before falling back to the existing
      CMake source compilation. Saves the 5–15 minute CMake build +
      sidesteps the "install these 10 -dev packages first" pain
      that the HN thread surfaced.
    - ffmpeg sibling bundling with rpath relocation: @loader_path/
      on macOS via dylibbundler, $ORIGIN on Linux via patchelf,
      sibling-DLL layout on Windows. Archive self-contained — no
      system ffmpeg/ncurses/libunistring/libdeflate needed at
      runtime.
    - SHA256 verification against bundled resources/checksums.txt;
      refuses any prebuilt whose hash isn't recorded (hard security
      boundary).
    - Cache downloaded archives in $XDG_CACHE_HOME/Notcurses-Native-
      binaries/ (or $HOME/.cache/ fallback).
    - New env knobs: NOTCURSES_NATIVE_BUILD_FROM_SOURCE=1 to skip
      prebuilts; NOTCURSES_NATIVE_BINARY_ONLY=1 to refuse fallback;
      NOTCURSES_NATIVE_BINARY_URL to override release base URL;
      NOTCURSES_NATIVE_CACHE_DIR to override cache dir;
      NOTCURSES_NATIVE_LIB_DIR for runtime lib-dir override.
    - New BINARY_TAG file at repo root as single source of truth for
      the pinned binary release tag (binaries-notcurses-<upstream>-
      r<recipe-rev>), read by both Build.rakumod and the CI
      workflow.
    - New .github/workflows/build-binaries.yml: builds + publishes
      prebuilt archives for five platforms (macOS arm64, Linux
      x86_64/aarch64 glibc, Windows x86_64/arm64) on manual dispatch
      or binaries-* tag push. macOS uses dylibbundler, Linux uses
      recursive ldd walk + patchelf, Windows uses recursive ldd on
      MSYS2 + sibling DLL layout.
    - macOS is arm64-only for v1. Intel Macs fall through to the
      compile fallback; universal builds need cross-arch brew
      setup that isn't worth the CI complexity for initial ship.
      Can revisit if Intel Mac users complain.
    - FFI lookup in Notcurses::Native now respects the
      NOTCURSES_NATIVE_LIB_DIR env override before falling back to
      %?RESOURCES. Escape hatch for custom notcurses builds.
    - Fixed $os.contains('win') bug in FFI lookup: "darwin" matches
      "/win/", causing file-extension detection to pick 'dll' on
      macOS. Now uses $*DISTRO.is-win.

0.1.5  2026-04-12T21:17:32+01:00
    - Build: tighten library-matching regex in Build.rakumod so that
      staging `libnotcurses` doesn't accidentally pick up
      `libnotcurses-ffi` (only `.` is a valid separator after the
      library name, never `-`). The 3.0.16 → 3.0.17 bump surfaced this:
      filesystem ordering began handing us the FFI shim first, which
      lacks `notcurses_init` and quietly broke every terminal-dependent
      test.
    - Bump vendored notcurses 3.0.16 → 3.0.17, which upstream describes
      as "Fix build problems on Windows and Mac OSX." The public API is
      unchanged between these releases (single-character attribute-macro
      typo fix in notcurses.h, no ABI impact), so our bindings need no
      changes. Drops the Windows termios workaround that would otherwise
      have been needed for 3.0.16.

0.1.4  2026-04-12T20:19:12+01:00
    - CI: add Windows (MSYS2 UCRT64) to the GitHub Actions test matrix.
      Uses OpenImageIO as the multimedia backend (per upstream notcurses
      Windows recommendation). Build-only since notcurses-tester is
      Unix-only.
    - README: document system dependencies with per-OS install commands
      for Linux (Debian/Fedora), macOS (Homebrew), and Windows (MSYS2
      UCRT64). Added a core-only (no multimedia) note.

0.1.3  2026-04-09T23:56:25+01:00
    - Switch library resolution from $?FILE to %?RESOURCES for portable
      loading when used as a dependency by other modules

0.1.2  2026-04-09T18:06:28+01:00
    - Move terminal-dependent tests to xt/ to avoid prove6 TAP harness
      bug during zef install (t/ has pure-Raku tests only)
    - CI runs prove6 on t/ and Perl 5 prove on xt/
    - Fix NcBlitter enum values (NCBLIT_PIXEL was 6, should be 7)
    - Fix visual functions: use $nc-lib (full) not $core-lib for FFmpeg
    - Add 130 NCKEY_* key code constants, NcPixelImpl enum, NCBOX_*,
      NCMICE_*, NCALPHA_*, NC_BG_* channel bitmasks
    - Add NcvisualOptions.set-plane for correct plane compositing
    - Add 8 example programs (hello, colors, boxes, input, clock,
      image viewer with kitty pixel support, direct mode, progress bars)
    - Build.rakumod: search /opt/homebrew/bin for cmake when PATH
      is stripped by mi6/zef subprocess
    - Windows CI disabled pending upstream notcurses termios fix

0.1.1  2026-04-09T17:23:50+01:00
    - Fix TAP harness corruption: redirect stdout/stderr to /dev/null
      before notcurses init, reroute $*OUT via /dev/fd/N for TAP output
    - Fix Build.rakumod: search /opt/homebrew/bin for cmake when PATH
      is stripped by mi6/zef subprocess
    - Fix NcBlitter enum values (NCBLIT_PIXEL was 6, should be 7)
    - Fix visual functions: use $nc-lib (full) not $core-lib for FFmpeg
    - Skip Unicode cell tests on non-UTF-8 environments
    - Set LANG/LC_ALL=en_US.UTF-8 in CI for proper UTF-8 detection
    - CI uses prove (Perl 5) instead of prove6 to avoid TAP parser bug
    - Windows CI disabled pending upstream notcurses termios fix
    - Add installation troubleshooting to README

0.1.0  2026-04-09T16:41:32+01:00
    - Complete NativeCall wrapper for notcurses 3.0.16 TUI library
    - 606 functions bound across 9 modules (100% of bindable symbols)
    - Vendored notcurses 3.0.16 built with FFmpeg multimedia + FFI lib
    - Build.rakumod: CMake build for macOS, Linux, Windows (MSYS2)
    - Modules: Native (core), Types, Plane, Cell, Channel, Context,
      Direct, Input, Visual, Widgets
    - 25 CStruct types: all notcurses options structs, nccell, ncinput,
      ncstats, nccapabilities, ncvgeom, ncvisual_options, timespec,
      widget options (selector, menu, tree, tabbed, plot, reader, etc.)
    - 19 opaque CPointer handle types for type-safe FFI
    - 226 constants: NCKEY_* (130 key codes), NCSTYLE_*, NCOPTION_*,
      NCALPHA_*, NCVISUAL_OPTION_*, NCMICE_*, NCBOX_*, NC_BG_* bitmasks
    - 7 enums: NcLogLevel, NcAlign, NcBlitter, NcScale, NcInputType,
      NcPixelImpl, NcBlitter (with corrected values matching C header)
    - CStruct Str field workaround: set-cstruct-str helper + multi
      method new constructors for all structs with string fields
    - NcvisualOptions.set-plane method for correct plane compositing
    - Visual functions use libnotcurses (full) for FFmpeg backend
    - Variadic printf bindings (Rakudo 2026.03+)
    - 16 test files, 161 subtests, 748+ assertions
    - Tests cover: channel math, cell operations, plane lifecycle,
      widget lifecycle, input handling, context/capabilities, direct
      mode, visual/image loading, rendered output verification
    - Render verification tests: exact text, color, style, z-order,
      box drawing, erase, merge, and gradient checks via notcurses_at_yx
    - 4x4 PNG test fixture for visual pipeline testing
    - 8 example programs: hello, colors, boxes, input, clock,
      image viewer (with kitty pixel protocol), direct mode, progress bars
    - GitHub Actions CI for Linux, macOS, Windows
    - Only unbound: 4 vprintf variants (va_list is not FFI-bridgeable)
