flutter_fbdev

CI pub package License: MIT

Run Flutter on no-DRM framebuffer handhelds, and map their gamepad.

A growing class of cheap ARM Linux handhelds (Anbernic RG34XXSP, RG35XX, and other Allwinner H700 / Mali-fbdev devices) have no DRM/KMS - no /dev/dri, no libdrm/libgbm. The standard embedded Flutter runtime, flutter-pi, is DRM/KMS-only and cannot run on them. flutter_fbdev fills that gap:

  • a tiny software-rendered /dev/fb0 embedder (≈300 lines of C) that renders any Flutter app straight to the framebuffer - the surface these devices already use for everything;
  • gamepad input over a platform channel (focus-independent - injected key events don't get delivered on a framebuffer embedder), mapped from raw evdev to logical buttons in Dart;
  • analog sticks (HandheldInput.sticks, self-calibrating per axis);
  • looped audio (FbdevAudio.play) - the embedder decodes a bundled Ogg Vorbis file (stb_vorbis) and plays it via ALSA, so you get sound on boards where the usual Flutter audio plugins don't run;
  • an on-screen button-mapping overlay to identify any device's controls by eye;
  • build + deploy tooling (cross-compile with zig, deploy over Samba).

Built while porting a touch-based Flutter game to an Anbernic RG34XXSP. It renders, the colours are right, and the d-pad and A/B work.

Why a framebuffer embedder?

flutter-pi flutter_fbdev
Output DRM/KMS + GBM + GLES /dev/fb0 (software / Skia CPU)
Needs /dev/dri, libdrm/gbm yes no
Runs on Mali-fbdev H700 handhelds
GPU-accelerated ✗ (CPU rasteriser)

Software rendering is the trade-off - fine for 2D/UI-heavy apps at modest resolution (e.g. 720×480), not for heavy 3D. If your device does expose DRM/KMS, use flutter-pi instead.

What's in the box

lib/                 Dart: HandheldInput, HandheldInputOverlay, isFbdevHandheld
native/
  flutter_fbdev_embedder.c   the framebuffer embedder
  flutter_embedder.h         the Flutter embedder API header
  build_embedder.sh          cross-compile (zig, or Docker fallback)
tool/
  deploy_samba.sh            copy the bundle to the device over Samba
example/             a minimal handheld app

Quick start

1. Add the Dart package

dependencies:
  flutter_fbdev: ^0.1.0

2. Wire up input

import 'package:flutter_fbdev/flutter_fbdev.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  HandheldInput.instance.start();            // receive gamepad input
  HandheldInput.instance.addListener((e) {   // logical button events
    if (!e.pressed) return;
    switch (e.button) {
      case HandheldButton.dpadLeft:  /* … */
      case HandheldButton.a:         /* … */
      default: break;
    }
  });
  runApp(const MyApp());
}

Drop the mapping overlay on top of any screen (inert on touch devices):

Stack(children: [ const MyGame(), const HandheldInputOverlay() ])

isFbdevHandheld switches on handheld code paths (button input, landscape, silent audio, skipping touch menus). fbdevTryStep(...) wraps risky bootstrap calls so a plugin that's missing on the embedder can't abort startup.

3. Build the flutter-pi bundle (AOT, arm64)

dart pub global activate flutterpi_tool
flutterpi_tool build --arch arm64 --cpu pi3 --release   # pi3 = Cortex-A53, fits H700
# → build/flutter-pi/pi3-64/   (app.so, icudtl.dat, libflutter_engine.so, assets)

4. Build the embedder into the bundle

brew install zig   # one-time (or rely on the Docker fallback)
.../flutter_fbdev/native/build_embedder.sh build/flutter-pi/pi3-64
# → build/flutter-pi/pi3-64/flutter_fbdev  (aarch64 ELF, self-contained)

5. Deploy + run on the device

FBDEV_IP=192.168.0.42 .../flutter_fbdev/tool/deploy_samba.sh build/flutter-pi/pi3-64 roms/ports/myapp

Launch it with a one-line script (e.g. a Knulli/Batocera Port):

#!/bin/sh
DIR="$(dirname "$0")/myapp"
cd "$DIR" && chmod +x ./flutter_fbdev && ./flutter_fbdev "$DIR" > "$DIR/run.log" 2>&1

The embedder picks the framebuffer + input devices itself, renders, and forwards /dev/input events to your Dart app. Volume / Power quits (a hard exit baked into the embedder so you're never trapped).

The demo, end-to-end

example/ is a cosy showcase that proves every part: software rendering (live FPS + animation), gamepad input (a controller diagram that lights up live), the mapping overlay (flashes each press), and d-pad navigation. Here's the exact flow - the same five steps for any app:

cd example

# 1) build the Flutter AOT bundle for arm64 (Cortex-A53 → --cpu pi3)
flutterpi_tool build --arch arm64 --cpu pi3 --release
#    → build/flutter-pi/pi3-64/  (app.so, icudtl.dat, libflutter_engine.so, assets)

# 2) cross-compile the framebuffer embedder INTO the bundle (one self-contained dir)
../native/build_embedder.sh build/flutter-pi/pi3-64
#    → build/flutter-pi/pi3-64/flutter_fbdev  (aarch64 ELF)

# 3) deploy the bundle to the device's Ports area over Samba
FBDEV_IP=192.168.0.42 ../tool/deploy_samba.sh build/flutter-pi/pi3-64 roms/ports/fbdevdemo

# 4) put the launcher next to it (example/FbdevDemo.sh) in roms/ports/, e.g.:
#    cp FbdevDemo.sh  <samba-share>/roms/ports/FbdevDemo.sh
  1. On the device, refresh the games list (or restart EmulationStation) and pick Ports → FbdevDemo. Press buttons - the controller diagram lights up, the overlay flashes each press, the FPS counter shows the software rasteriser keeping up. Volume / Power quits. Logs land in roms/ports/fbdevdemo/run.log - read them over Samba (no SSH needed).

Tip: the 20 MB libflutter_engine.so is the slow part of a first deploy; later code-only changes just re-copy the ~3-4 MB app.so.

Mapping a new device

HandheldProfile.rg34xxsp is bundled. For another device, run with the `HandheldInputOverlay` visible, press each button, read its label, and build a HandheldProfile:

HandheldInput.instance.profile = const HandheldProfile(keys: {
  304: HandheldButton.a, 305: HandheldButton.b, /* … */
}, hatX: 16, hatY: 17);

The d-pad is typically an EV_ABS hat (HAT0X/HAT0Y); face/shoulder buttons are EV_KEY codes. The overlay also shows raw KEY n / ABS n for anything unmapped.

Status & caveats

  • Software rendering only (no GPU). Great for UI/2D; profile heavy scenes.
  • The display path assumes a 32-bpp framebuffer (the converter honours the fb's bitfield offsets). RGB565 panels would need a small tweak.
  • Deploy tooling targets macOS hosts + Samba-enabled firmware (Knulli/Batocera family); adapt tool/ for your setup.
  • No touch - design for buttons (or use the input layer to drive focus).
  • UI scale: the embedder sets devicePixelRatio to 1.25 by default so fonts/widgets aren't tiny on small dense panels. Override per-device with the FBDEV_PIXEL_RATIO env var (e.g. FBDEV_PIXEL_RATIO=1.5); your app lays out responsively within the resulting logical size.

Releasing

Publishing happens on a version tag, not on every push (CI only lints/tests pushes). The first publish is manual; after that it's automated:

# First publish only (creates the package on pub.flutter-io.cn - needs an interactive login)
flutter pub publish

# Then, once on pub.flutter-io.cn, enable Automated publishing on the package admin page
# (GitHub Actions, repo gotnull/flutter_fbdev, tag pattern v{{version}}).

# Every release after that:
tool/release.sh 0.1.1     # bumps pubspec + version.dart, verifies, tags, pushes

Pushing the vX.Y.Z tag triggers .github/workflows/publish.yml, which publishes via pub.flutter-io.cn's OIDC automated publishing (no tokens stored). flutterFbdevVersion mirrors the pubspec version (guarded by a test) so the running version is visible in-app.

License

MIT - see LICENSE.

Libraries

flutter_fbdev
Run Flutter on no-DRM framebuffer handhelds (Anbernic RG34XXSP and friends), with gamepad/evdev input and an on-screen button-mapping overlay.