No description
  • Shell 76%
  • JavaScript 24%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Stoney_Eagle 824680dbc0 feat(release): a payload revision, so published bytes never change under anyone
The windows fix has to ship, and the archive it replaces is already published.
Re-uploading different bytes under v0.17.5 would break the one promise this
file opens with: core pins every payload by digest, and a tarball that changes
under you is the thing digests exist to prevent.

So the archive name and the tag carry a revision of our own.
LIBASS_VERSION still says which sources were built; LIBASS_REVISION says which
build of them is published, and the two are joined into
LIBASS_PAYLOAD_VERSION -- empty revision means no suffix, so the scheme costs
nothing until it is used. v0.17.5 stays exactly where it is and keeps resolving
for anything still pinned to it.

The publish job now refuses a tag that disagrees with versions.env. The tag and
the archive names come from different places, and a mismatch would publish a
release whose every asset 404s at the URL a consumer actually asks for --
surfacing as "libass is not installed on this machine" on a machine that just
downloaded it.
2026-08-09 21:37:33 +02:00
.github/workflows feat(release): a payload revision, so published bytes never change under anyone 2026-08-09 21:37:33 +02:00
cross fix(windows): the payload carries the gcc runtime instead of importing it 2026-08-09 21:33:29 +02:00
fonts feat(libass): link the wasm build into the worker the web loads 2026-08-04 09:34:57 +02:00
scripts feat(release): a payload revision, so published bytes never change under anyone 2026-08-09 21:37:33 +02:00
src feat(wasm): the worker speaks a protocol, and it renders in a browser 2026-08-04 13:39:57 +02:00
.gitignore feat(libass): one libass we build, for every surface 2026-08-04 09:05:02 +02:00
README.md docs(libass): No Game No Life is the acceptance fixture 2026-08-04 09:36:24 +02:00
versions.env feat(release): a payload revision, so published bytes never change under anyone 2026-08-09 21:37:33 +02:00

nomercy-libass

One libass, built by us, for every surface NoMercy renders subtitles on.

Why

Before this, four origins:

surface came from
web a vendored JavaScript port of libass (SubtitlesOctopus)
desktop nothingwin32-x86-64/ass.dll not found on Windows
Android io.github.peerless2012.ass
Apple a prebuilt XCFramework fetched from a third party

Four builds, four versions, four ways to fall short — and no way to tell which one a bug belonged to. This repo builds libass, freetype, fribidi and harfbuzz from pinned, digest-verified sources and publishes every artifact from one release, so a subtitle drawn in a browser and one drawn on a television came out of the same code.

Layout

  • versions.env — the one place a version or digest is written
  • scripts/fetch.sh — fetch and verify every source
  • scripts/build-target.sh — the four builds, in dependency order, for one target
  • scripts/build-apple.sh — the four Apple slices and the XCFramework
  • cross/*.ini — one meson cross file per target
  • .github/workflows/release.yml — the whole matrix on a tag

Targets

linux-x86-64, windows-x86-64, android-arm64-v8a, android-armeabi-v7a, android-x86_64, wasm, apple (ios + tvos, device + simulator).

The bar

The Android app's lib/nomercy-ass carries the tuning months of production found. This pipeline is not done until it matches or beats it, measured:

tier pool glyph cap libass cache render ceiling
LOW (265MB TV boxes) 14 MB 2 500 8 MB 1280x720
MEDIUM (1-2 GB) 20 MB 4 000 16 MB 1920x1080
HIGH (4 GB+) 24 MB 6 000 32 MB 1920x1080

The entry cap and the MB cap are independent and both matter: 16 MB with a 1 500-entry cap measured 1 834 ms average dynamic render because the count limit evicted before the MB budget was reached and every evicted glyph was re-rasterized. 16 MB / 4 000 returns it to ~400 ms.

Wiring the consumers

Desktop — done. scripts/package.sh emits libass-<version>-<platform>.tar.gz flat, which is exactly what nomercy-player-core-kmp's NativeArchives resolves and what AssRenderers.jvm searches. No player change is needed; publishing the artifact is the whole integration.

Android — one swap left. AndroidAssRenderer is written against io.github.peerless2012:ass-kt's JNI wrapper, while the desktop drives libass through our own JNA interface in subtitles-libass/src/jvmMain/.../LibAss.kt. JNA runs on Android, so the swap is to move LibAss.kt to a source set both share and rewrite AndroidAssRenderer against it, loading our .so from the APK's jniLibs. That deletes ass-kt from gradle/libs.versions.toml and leaves one renderer implementation instead of two.

Web — the wasm target. packages/nomercy-subtitle-octopus and the vendored public/js/octopus/subtitles-octopus.js go once the wasm artifact publishes; the video player links ours instead.

Apple. subtitles-libass/build.gradle.kts fetches a third-party XCFramework by digest today. Point libassArchive at our release and the cinterop is unchanged.

The web worker

scripts/build-wasm-worker.sh links the wasm build into nomercy-libass-worker.js + .wasm, exporting the libass entry points the NoMercy wrapper's worker bridge calls and preloading the fallback face at /fonts. The wrapper in packages/nomercy-subtitle-octopus keeps its API; only the worker underneath it changes, so retiring SubtitlesOctopus is a three-file swap rather than a rewrite.

Acceptance fixture

No Game No Life. Its subtitles are the test worth passing: karaoke timing, positioned signs, per-style fonts and colours, rotated and faded typesetting. A renderer that draws Latin dialogue correctly can still fail every one of those and look fine to anyone not reading the screen carefully — which is exactly the class of failure four separate libass builds hid.

Correctness is judged on that episode, on every surface, against the same frame: web, desktop, Android and Apple showing the same sign in the same place with the same face.