Building avox core and plugins across platforms: dynamic or static, the platform decides

Implementation notes·about 3395 words

This article is about the layer that never shows a picture but eats the most time: how the engine's optional capabilities and third-party dependencies are compiled, packaged and deployed across five platforms.

First, the shape of the problem. Besides the main trunk — decoding, rendering, audio — the engine has a set of optional capabilities: effects-subtitle overlay (a separate render layer over the picture), Blu-ray disc reading, magnet-link streaming with progressive download, speech recognition, offline translation, SMB/WebDAV remote sources. They share one trait: each drags in a large third-party library — libass, libbluray, libtorrent, sherpa-onnx, libsmb2 — and not every platform needs or uses every one. Stuffing all of it into the trunk is a bad trade; cutting it all is not an option either. So they became plugin modules: one directory per module, one exported function, one registry, and one ABI version number.

Five platforms, five answers

The same plugin source has two build forms: DYNAMIC compiles into a standalone shared library, STATIC merges into the main library. On the engine side the convention is that switching form means moving a module name from one list to another, with zero changes to business code. But the five platforms ended up with answers that are not the same:

PlatformEngine bodyPlugin form
Windowsavox.dll.dll under plugins/, scanned and loaded on first need
macOSstatic libavox.a, linked into the shim dylib.dylib shipped in the bundle, loaded on demand
iOSeverything merged into a single archive inside one framework dylibno dynamic plugins; all static in the archive
Androidlibavox.so.so alongside the main library in the APK native lib directory, same scanning and loading
Linuxlibavox.sosame shape as Windows

This table is the source of every pitfall that follows: each platform's plugin form was not chosen, it was decided in advance by how that platform distributes software.

Windows: what you manage yourself in a DLL world

Dependency search paths. Plugins drag dependencies of their own: the inference plugin needs onnxruntime.dll and DirectML.dll, the subtitle plugin needs ass-9.dll and fribidi-0.dll. The approach is to make each plugin self-contained — dependency DLLs are copied into plugins/ as well; at load time the plugins/ directory is added to the process DLL search path, then the library is opened through the load API flag that says “resolve dependencies from the library's own directory”. Plugin and dependencies then always travel together, and the whole directory works wherever it is moved. Which plugins ship in the release package is decided by a whitelist: the engine build tree produces a dozen-plus modules, and only six modules plus seven dependency DLLs — thirteen files — go with the player; anything not listed does not even put its build output in the package.

A crash during load must not take everything down. If code in a plugin's DllMain (the entry point executed automatically when the library loads) crashes, the usual consequence is that the whole player goes with it. Loading here is wrapped in Windows structured exception handling: a failed load or a crash during load is logged, the module is marked unavailable, and the player keeps running with that capability degraded at worst.

ABI handshake. Plugins and the engine compile separately, and a layout mismatch is fatal. Every plugin exports an ABI version; if it does not match the engine's, loading is refused. Object ownership across the shared-library boundary is fixed as “plugin allocates, host deletes”, on the premise that the whole chain uses the static CRT (runtime library) uniformly: in that configuration each module's allocations ultimately land on the process default heap, so cross-module frees line up — a property measured in practice, not a written promise from Windows. The accompanying prohibition: standard-library containers (strings, arrays) must not cross the boundary, because their destructors are inlined and would free memory in the wrong CRT copy — an assert-and-crash in debug builds; plugin interfaces therefore return raw pointers and C strings only. One level up, the lifetime of engine API objects is deliberately not left to the caller: creation is a factory function inside avox, destruction rides the interface's virtual destructor, and by design there is no paired destroy function; the bindings for other languages (the SWIG-generated Python / C# / Node wrappers) mark each create function as “return value owned by the target language”, and the collector there calls delete when it reclaims the proxy. The one exception is borrowed pointers — a few objects (the AI assistant session, for example) must have their lifetime nested inside the host and are deliberately not ownership-marked, because garbage-collection order is not deterministic and marking them would create dangling pointers instead. One more iron rule for plugin builds: never export the main library's export macro, or the vtable gets treated as exported symbols and goes haywire across the boundary. Finally, every plugin runs a capability probe after loading — when runtime conditions are not met (model files not downloaded, say), the plugin reports itself unavailable instead of crashing. Missing-file degradation is part of the design.

A harder discipline: FFmpeg's bin and lib must come from the same batch. This is the most expensive lesson in submodule integration. On Windows, FFmpeg ships as DLLs plus import libraries, and an import library is glue from function name to ordinal inside the DLL. When FFmpeg was rebuilt once (decoders going from 62 to 164), new exports were inserted in the middle and every later ordinal shifted; the import library did not change with it, so the host's call to the subtitle-release function by the old ordinal actually landed in the TAK (a lossless audio format) decoder's parse function — the crash came the moment subtitles opened, three times in a row, same signature. The lesson hardened into five rules: when rebuilding, produce bin and lib in the same batch and deploy them together; swapping only the DLL without relinking the host always blows up; check with a full-table diff — comparing only the ordinal ranges actually used will miss it, and the one missed was exactly the one that broke; the cure is to stop using ordinals — regenerate the import library from the current DLL by function name, after which adding or removing exports no longer shifts anything; check other modules that import av libraries by ordinal (mk_api and friends) the same way.

macOS: static core plus dynamic plugins, symbol bills on both sides

On Mac the engine body is a static library and plugins are dynamic libraries, and plugins really ship in the bundle and load on demand — no iOS prohibition here, but more bills to settle.

One side is what plugins owe the host. Plugins compile with “undefined symbols resolved at runtime” and are opened in a mode where their symbols are visible only to themselves; consequently the libraries a plugin depends on are invisible to it, and the plugin must resolve its own dependencies cleanly — the remote-source plugin reported undefined symbols under exactly this loading mode, and the fix was to resolve inside the plugin rather than expect the host to find things for it.

The other side is what the host owes plugins. When the engine is statically linked into the shim (the glue layer between engine and UI), the linker only pulls members that are referenced, so engine functions “only a plugin would use” never get pulled in; the plugin then looks them up by name at load and the whole module is unavailable. The fix is to count in reverse: list each shipped plugin's own undefined symbols and force-keep them one by one on the link command line — nothing missed. There was one “speech-recognition plugin unavailable” incident, and it traced to exactly this: an engine method the plugin needed had never been linked into the host.

Then there is a gate further out than linking: signing. macOS has code gating, and a GUI process loading an unsigned or modified dynamic library at runtime is refused outright (with an error that disguises itself as file-not-found). So in this project the shim takes the “embedded through CocoaPods, loaded at startup” form rather than runtime loading; when the deploy script is invoked inside the build flow it only compiles and copies, never signs — signing stays with Xcode's signing phase, because appending files after signing invalidates it and every dynamic library then fails to load. Two similar incidents: a dylib's ID defaults to an absolute path inside the build tree, and without rewriting it the plugin directory the engine derives from “where am I” points into the build tree and finds nothing (the subtitle plugin crashed exactly this way); and stray files in the app bundle (runtime-written files with Windows-style paths, Finder companion files) break the bundle signature seal, after which all dynamic loads fail and Apple's upload validation throws two errors in a row.

With those bills settled, Mac ships plugins in the bundle and loads them on demand — the plugin legs for Blu-ray and speed control both work. The same engine takes a completely different road on iOS.

iOS: there is no place for a dynamic plugin

On iOS the plugin list says “all removed or converted to static in the archive”; the project comment gives the reason that the store does not allow loading third-party executable code, and separately, dynamic plugin artifacts do not fit into the bundle on iOS at all — that road was never opened. After merging into the archive, three pitfalls appeared in order.

The first: the linker quietly drops symbols. Members of a static archive that nothing references are discarded, and a plugin's registry is a global constructor object that is naturally “referenced by nobody” — so it compiles but never registers. The fix is a keep-alive table: the plugin's registration macro generates a keep symbol, the build adds a compile definition for each static module, and the core keep-alive table takes the address of those symbols once, pulling them into the link closure. Adding a static module is now a fixed three steps: turn on the definition, add one registration line.

The second: the main executable's exports are stripped by store processing. The engine and shim used to be linked into the app's main executable, with the UI side getting the entry point through dlsym (runtime lookup by name). One early TestFlight build measured: not a single pvx_-prefixed symbol was left in the main executable's export table — the UI, browsing and purchase features, all pure Dart, worked fine, but pressing play produced silence; in the same bundle the exports of MoltenVK and other frameworks were alive. The fix was to move the engine plus shim wholesale into a dylib inside our own framework, whose export table store processing does not touch. Acceptance became a command: after building, count the exports — about 99 pvx_ symbols, including the ABI version one.

The third: the resource bundle is treated as nested code. The resources the engine reads (shaders, fonts, certificates) used to be packed as avox.bundle, and store validation treated every file inside the .bundle as nested code requiring its own signature — it rejected even .py/.mm/.md by name, four rejections in total (error 90035); and the distribution private key was not local, signing being hosted in the cloud. The fix: rename the bundle to an extension-less directory name and carry it as framework resources; on the engine side, falling back to the new name avox_res (including avox.bundle/fonts-style subpaths) when the old one is not found — compatible on both ends.

macOS and iOS: same Apple, opposite roads

Put the two side by side and the interesting part is that the pitfalls point in opposite directions. On Mac, dynamic plugins are the norm, and the trouble is all “who owes whom a symbol”: the plugin owes the host resolution, the host owes the plugin retention, and both bills must be settled explicitly. On iOS there are no dynamic plugins and the app has exactly one binary, and the trouble comes from the linker being too eager: unreferenced registry members are silently dropped, and a keep-alive table has to hold the plugins in. One side pins things in; the other pulls them along.

A few more contrasts:

  • Package shapes. Mac produces two at once: a merged static archive, and a dynamic library embedded through the pod and loaded at startup — neither goes through “runtime loading of a file-based dylib”, which is exactly what dodges the code gate; iOS has only the merged archive inside the framework dylib.
  • Prebuilt slices. iOS has separate prebuilt libraries for device and simulator, each merged into its own archive; an early script used plain directory search to find libraries and merged simulator objects into the device archive in directory order, which only exploded at link time; all lookups are now pinned to the platform path. Mac has no such fork.
  • Resource bundles. On Mac avox.bundle ships and reads as usual; on iOS, error 90035 forced the extension-less directory.
  • The one exception. On fully static iOS, MoltenVK must be dynamic: volk has a set of global function-pointer variables and MoltenVK exports functions with the same names, so merging both into one static link blows up at relocation; it can only be embedded as a dynamic framework and loaded by the engine. That is the other side of this article's theme: the same name may appear only once in one link.
  • Deployment discipline. On Mac the build artifact is only the packaging position; the copy installed in the user's Applications directory is the one actually running, and overwriting it while merging old files breaks the signature seal (this happened once: the build tree was new, the user still had the old package); on iOS Xcode signs uniformly and installs straight to the device, so this step does not exist.

One example: the same TLS, four copies across four modules

The clearest illustration that “the platform decides the answer” is TLS (the layer that encrypts connections; HTTPS depends on it). Several modules need the network: FFmpeg opening https streams, the remote-source plugin speaking WebDAV/SMB, the torrent plugin talking to trackers, AI modules making HTTPS requests; and there are two TLS implementations at hand — OpenSSL, and the BoringSSL bundled in the WebRTC archive. Several modules need TLS and there are two implementations, and each platform answers completely differently.

Windows is the easiest: OpenSSL ships as two DLLs, one per process, and whoever needs it links it.

What must be prevented is “two implementations in one binary”. Mac hit this once: a host that statically linked WebRTC (BoringSSL) also had OpenSSL libraries mixed in, and at runtime it showed up as wild-pointer crashes — BoringSSL objects consumed by OpenSSL. The fix was not just to separate them but to add a build-time guardrail: after linking, scan the symbol table and fail immediately on OpenSSL symbols, turning a runtime crash into a build-time failure. The Mac deploy script also decides automatically from the engine archive's undefined symbols “which flavour” to link: if new OpenSSL names appear, link the static library against OpenSSL; otherwise treat it as BoringSSL and let the WebRTC archive provide the symbols.

iOS is the reverse: one binary cannot hold two implementations, so it must choose one — BoringSSL, already inside the WebRTC archive, adds no weight. OpenSSL contributes only headers, with all symbols supplied by the WebRTC archive. The three missing APIs (a compatibility control layer BoringSSL removed, and one renamed function) are covered by a small compatibility unit that forwards by manually pinned numeric values. Even compiling that unit has an ordering constraint: the two sets of headers fight each other — BoringSSL headers pin the compatibility-layer macros to “does not exist” (and that is precisely what must be supplied), while OpenSSL 3 headers turn several setter functions into macros, so a straightforward forward becomes self-recursive. The solution: undefine the macros, hand-declare the BoringSSL-side prototypes, and pin the header search order.

Android answers a third way: each carries its own, and they leave each other alone. Counting one APK: libavox.so, libavformat.so, libavox_remote.so and libavox_torrent.so — a TLS implementation is embedded in each of the four shared libraries. The main library and FFmpeg keep symbols private inside the binary, neither exported nor externally dependent; the two plugins carry export tables but load with symbols visible only to themselves, so they do not collide. On Android the main library also hides internal symbols of static libraries behind one more layer — two static dependencies (sentencepiece and sherpa-onnx) each packed a different version of protobuf (a data-serialisation format), and without hiding they collide.

The cost is that settings are not shared: a trust root configured in one module is invisible to another. Certificate authorities (the bodies that issue certificates) can only travel through the filesystem: the compile-time default certificate directory is baked into the TLS implementation (pointing at the app's private directory), at runtime the bundled CA certificates are merged into one bundle written to cert.pem in that directory, with two environment variables as a backstop. The time HTTPS failed everywhere on mobile, three layers were involved: the implementation's compile-time default path was desktop-shaped and does not exist on mobile; in one version, loading the default path returned early on a failed “file leg”, so setting only the directory variable had no effect; and when playing HLS (segmented streams), segment connections are opened separately and do not inherit the parent connection's certificate parameters — in the end that layer was not patched but covered by the default-path fix, so segments benefited too (an attempt to pass parameters separately at the segment layer was reverted). With all three fixed, HTTPS came alive on mobile.

One question, three answers: share one copy when you can (Windows' DLLs, one chosen after merging on iOS); isolate when you cannot, each carrying its own and cross-module state moving through files (Android); if they truly must sit in one binary, add a compatibility layer (iOS's three APIs).

Further down: submodules, prebuilt artifacts and the glue layer

The third-party dependencies have an integration layer of their own. 3rdparty/ in the engine repo is a set of git submodules (sub-repository pointers held in the main repo): zlmediakit, fdk-aac, freetype, sherpa-onnx, sentencepiece, g2o, eigen, volk, cpp-httplib, godot-cpp. Some large prebuilt artifacts are not committed: onnxruntime for each platform, iOS's MoltenVK and Vulkan SDK, the FFmpeg slices used by the simulator; a new machine copies them from an old one, and missing ones cause the corresponding capability to be skipped or degraded automatically. Each dependency family has a lookup script with platform-pinned directory names — mac and iOS must stay separate, or linking an iOS arm64 static library on Mac reports a platform mismatch immediately.

The build scripts carry their own freshness checks: an engine artifact older than its sources is a warning, and in severe cases a hard failure. The Android packaging path is a hard failure, precisely to prevent “engine source fixed, shipped package still carrying the old decoder” — a seek-corruption reproduction traced back to exactly that.

Finally the glue layer itself. The shim between UI and engine is a project independent of the Flutter build, with one build tree per platform compiled by that platform's deploy script — Flutter's build command will not compile it for you. Pure forwarding functions live in one shared file referenced by all five ends; only platform-specific parts (window handles, texture bridges, GPU lanes) stay in per-platform files. The Dart side follows yet another lifetime style: handles and resources live in the shim and are released by explicit paired pvx_*_free calls from the host — C APIs have no garbage collection, so paired release is the only rule. And shim and engine are strictly coupled: after engine headers change the shim must be rebuilt, because a vtable mismatch is a crash, not an error.

Back to the opening sentence: dynamic or static is not a technical preference, it is decided in advance by how the platform distributes software. Once decided, every pitfall is a derivation — in the dynamic world, search paths, crash isolation, ABI, and symbol bills on both sides; in the static world, the linker dropping symbols, resource signing, and “only one implementation per binary”.

The avox engine is open source on GitHub. The rest of the series: the product introduction, From the open-source engine avox to a product: the cross-platform player Panvox.

Originally published in Chinese on Zhihu (October 2026); product progress follows the product page.