Graphics / macOS engineering

How I Got TFT PBE Running on Apple Silicon—and Turned the Experiment into a Native macOS Launcher

A deep dive into ANGLE, Vulkan, Android Emulator, MoltenVK, reproducible benchmarks, and AI-assisted engineering beyond my usual stack.

published  August 9, 2026·read  18 min·source  Original research
Back to writing
engineering case study

My primary background is in backend engineering and infrastructure security: Go, Kubernetes, Vault, PKI, and distributed systems. Before this project, I had never built an Android application, developed a native macOS app, or worked with graphics stacks.

The project began with a simple personal goal: run the Android version of TFT PBE on an Apple Silicon Mac with performance good enough to actually play. It quickly became clear that the problem did not live in a single application or emulator. Between the game and the screen were several independent layers—Unreal Engine, OpenGL ES, ANGLE, Vulkan, Android Emulator, gfxstream, MoltenVK, and Metal. Any one of them could appear to work in isolation while the full chain remained incompatible or unstable.

The result was a native SwiftUI launcher that installs a verified environment, creates an Android Virtual Device, manages the game lifecycle, preserves user state, exposes resolution and resource profiles, repairs damaged installations, and supports signed updates.

But the most important outcome for me was not the Play button. It was the reproducible engineering process that let me turn an unfamiliar black box into a system I could reason about, separate real improvements from lucky one-off runs, and evolve a laboratory prototype into an application another person could use.

01 · finding

Installing the APKs Was the Easy Part

At the time of the research, the target build was TFT PBE 18.1. The APKs installed successfully on Android 16 ARM64, but the game could not complete startup. The emulator exposed OpenGL ES 2.0, 3.0, or 3.1, while this TFT build required an OpenGL ES 3.2 context or newer.

Simply enabling Vulkan in the emulator did not solve the problem. The game continued to use an incompatible GLES context. Forcing Unreal Engine itself onto a direct Vulkan RHI did not help either: the device profile explicitly kept the game on OpenGL.

The working path turned out to be different:

effective graphics path
TFT OpenGL ES 3.2
        ↓
Android ANGLE
        ↓
Vulkan
        ↓
Android Emulator gfxstream / MoltenVK
        ↓
Apple Metal
fig 02 · graphics stackThe game stayed on OpenGL ES; ANGLE, gfxstream, and MoltenVK formed the compatible path to Metal.

For the TFT package, I selected the system ANGLE driver and enabled controlled exposure of an ES 3.2 context. The game still believed it was rendering through OpenGL ES. Vulkan operated one layer below, as the transport between ANGLE, the emulator, and the macOS graphics stack.

That distinction matters. I did not “move the game to Vulkan.” I built a compatible path for its existing GLES renderer.

I also did not define success as “the process exists.” The game had to open its interface, load content, authenticate, and enter a match. Only then could the graphics path be considered functional.

02 · finding

A Successful Launch Was Not Yet a Solution

At first, I displayed the game through scrcpy. It was useful for fast diagnostics, but it introduced two separate problems.

First, right-click was mapped to Android Back by default. From the outside, this looked like a random game crash. The logs showed something much less dramatic: the app had simply been sent to the background.

Second, the Android 16 image did not expose a hardware video encoder. scrcpy therefore encoded the entire game screen on the guest CPU—at the same time as TFT and several shader workers were running. The result was periodic stalls that could not be fixed through in-game graphics settings.

Moving to the native Android Emulator window removed an unnecessary encode-and-stream loop, restored normal mouse behavior, and eliminated those intermittent freezes.

The next issue was even less obvious. The launcher temporarily started QEMU as a background process while it waited for Android to boot and applied the runtime configuration. In zsh, the BG_NICE option is enabled by default, so the background process silently inherited nice=5. Short bursts of host-side rendering and input work then lost scheduling priority to other macOS processes.

The fix was one line:

codezsh
unsetopt BG_NICE

After restart, QEMU ran with nice=0. This tiny discovery helped more than several early attempts to increase the virtual machine’s CPU or RAM allocation.

fig 03 · launch pathsRemoving the software streaming loop and restoring QEMU’s normal scheduling priority solved problems that looked like game or GPU failures.

This is what makes performance work on layered systems dangerous: the user sees “the game is slow,” but the root cause may be neither the game, the GPU, nor the guest OS. It may be the shell behavior of the process that launched the emulator.

03 · finding

More Resources Do Not Automatically Mean More Performance

The natural first reaction was to give the virtual machine more CPU and memory. Measurements showed that neither was the primary bottleneck.

On an M1 Max, I settled on seven vCPUs. TFT’s configuration used an affinity mask covering the first five guest CPUs, so seven vCPUs left two processors outside that set for game and render workloads. Subsequent measurements still showed the equivalent of roughly 4.5 idle guest CPUs. Adding an eighth vCPU did not address the active constraint.

Memory told a similar story. The guest still had several gigabytes available after accounting for reclaimable cache, while the TFT process itself used roughly 1.4 GiB of resident memory. Increasing guest RAM did not remove action latency and could only add pressure to host memory.

This became one of the recurring principles of the project: before changing a resource limit, prove that the system is actually constrained by it.

04 · finding

Device Profiles Mattered More Than the Graphics Settings in the UI

TFT did not recognize the virtual Android device exposing an Apple M1 Max GPU, so it selected a generic low-performance profile. That meant a 75% internal render scale, a small texture pool, reduced graphics settings, and conservative rendering parameters.

Changing the Android display resolution alone was not enough. After the game process restarted, Unreal could delete a one-time copied DeviceProfiles.ini, create a 1280×720 SurfaceView, and scale it to a 2560×1440 window. The external resolution looked correct, while the number of pixels actually rendered remained low.

The configuration therefore became transactional. The launcher verifies hashes, applies the profile for the full AVD session, and guarantees restoration of the original state during shutdown. This preserves the real framebuffer even after an internal TFT process restart.

fig 04 · framebuffer truthWindow size and real render resolution were different problems; the device profile had to persist for the entire AVD session.

The selected profile keeps a 100% render scale, disables dynamic resolution, uses FXAA 4 and anisotropy 8, and applies a set of validated RHI and GPU-scene parameters. The game’s direct Vulkan RHI remains disabled: Vulkan operates below it, inside ANGLE and the emulator graphics stack.

05 · finding

I Stopped Optimizing by Feel

Early on, it was easy to convince myself that a new flag had “made the game smoother.” FPS varied from scene to scene, the first run behaved differently from a warm run, the shader cache filled over time, and a quick lobby test said almost nothing about a heavy combat stage.

So I built a separate experimental harness. It launched a fresh Trial, advanced the game to fixed stages, validated both the stage number and the combat phase, and then recorded:

  • the effective resolution and SurfaceView;
  • active graphics flags;
  • SHA-256 hashes of the APK overlay, device profile, and runtime;
  • Mac power and thermal state;
  • memory and system load;
  • average FPS, p95/p99 frame time, and long-frame counts;
  • whether rollback succeeded after each candidate.
fig 05 · experiment loopEvery candidate passed through the same gated, cold-start, rollback-verified experiment loop.

Candidates changed one factor at a time. Each was separated by a cold launch and an AVD restoration check. An unknown screen, CAPTCHA, MFA prompt, lost connection, or failed semantic phase gate stopped the experiment instead of being treated as “close enough.”

A single complete run was not considered confirmation. Candidates without enough repetitions remained provisional, and the strongest results were rerun before a winner was selected.

The goal of automation here was not to remove the human from the process. It was to remove the temptation to select an attractive number and forget the conditions under which it had been produced.

06 · finding

What the Measurements Showed

The strongest confirmed improvement came from replacing the old pipe graphics transport with ASG. In the same combat scene, ASG delivered about 40.1 FPS, compared with 29.6 FPS for the old transport—roughly a 35% improvement.

Another unexpected result involved resolution. In a controlled stage 1-5 scene, moving from 1600×900 to 2560×1440 increased the number of source pixels by 2.56×, yet the measured results were 30.5 FPS and 31.3 FPS, respectively. This does not mean that higher resolution is free in every situation. It means that this particular scene was constrained more by CPU, RHI, or transport work than by pixel fill.

fig 06 · benchmark summaryThe winning profile came from repeated measurements, not the highest isolated FPS number.
ExperimentObservationDecision
ASG versus legacy pipe40.1 versus 29.6 FPS in the same combat sceneASG became part of the primary profile
1600×900 versus 2560×144030.5 versus 31.3 FPS with 2.56× more source pixels1440p remained the primary quality profile
128 active Metal command buffersBest single result in the heavy stage: 32.4 FPSNot accepted without cold confirmation
Cold MVK128 repeatResult fell to 31.6/23.3 FPS at stages 1-5/1-8Candidate remained experimental
Reproducible control series40.60 / 36.03 / 27.83 FPS at stages 1-2 / 1-5 / 1-8Accepted as the reproducible baseline

The MVK128 case is especially representative. Its first run looked like a new winner: 32.4 FPS in the heavy scene. It would have been easy to declare victory and move the setting into the main profile. But the cold confirmation produced 23.3 FPS at the same late stage and a worse p95. The average across the two successful runs was almost identical to the control profile, while frame-time stability was worse.

MVK128 therefore remained a separate experimental launch profile.

The 57 FPS target for the heavy scene was not reached. The reproducible baseline at the heavy stage was 27.83 FPS. I deliberately did not describe the solution as “60 FPS,” even when the lobby or a light early-game scene ran close to 60. For an engineering case study, an honest boundary is more valuable than an attractive headline number.

07 · finding

Negative Results Became Part of the Product

Some hypotheses did not merely fail to help—they made the system worse.

Aggressive PSO prewarming caused a reproducible SIGSEGV inside Android’s OpenGL program-binary cache. Enabling the submit thread reduced performance to 25.8 FPS in the heavy stage. Raising the number of Metal command buffers to 256 caused a regression and produced a 133 ms frame. Shader prewarming failed the fixed Trial. Disabling async composition cost roughly 7.6% FPS without proving a meaningful improvement in action latency.

These variants were not erased from the project history. They remained in the research log together with their test conditions and rejection criteria. That saves time for the next person—including me, several months later.

A good experiment log should answer not only “what works?” but also “what has already been tested, why did it look reasonable, and what evidence caused us to reject it?”

08 · finding

The Most Painful Failure Was Not in Graphics

One working AVD copy was stored inside a directory managed by macOS File Provider. The system marked the large userdata-qemu.img and related files as compressed,dataless. The emulator could no longer boot the guest OS because it saw placeholders instead of local disk files.

Attempting to reconstruct an empty backing file was logically understandable but operationally wrong. On the next launch, QEMU created a new small overlay, and the later state of that AVD copy was lost. There was no open deleted inode, Time Machine backup, or APFS snapshot available for recovery.

That incident led to a separate rule: an AVD is not a collection of independent files. It is a stateful, internally consistent artifact. You cannot replace the backing inode or timestamp of an existing qcow2 image without a recoverable copy of the entire AVD. And you should not keep that runtime inside a directory where File Provider may offload files on its own.

The mistake did not weaken the project. It changed the design. User runtime data now lives in a dedicated Application Support directory, and Repair and Reset have clearly different semantics.

09 · finding

Turning the Prototype into an Application

A working shell script solved my problem, but it did not solve the problem for another user. To make the project distributable, I built a native SwiftUI launcher.

Its model is implemented as an explicit state machine:

launcher lifecycle
needsInstall
→ installing
→ ready
→ launching
→ playing
→ stopping

Any state can transition to failed
with a specific recovery action.

The launcher:

  • downloads pinned versions of Android Platform Tools, Emulator, and the ARM64 system image;
  • verifies the SHA-256 hash of every component;
  • creates and configures the AVD;
  • installs the game and prepares the runtime;
  • lets the user choose resolution, RAM, vCPU count, UI scale, and language;
  • starts and cleanly stops the emulator process tree;
  • preserves user state across application updates;
  • supports Repair without deleting game state and a confirmed full Reset;
  • exposes clear installation, launch, and recovery states;
  • provides game hotkeys through a separate macOS input bridge;
  • is localized in English and Russian.

The settings include four display profiles: 1920×1080, 2560×1440, 3200×1800, and an experimental 3840×2160 mode. RAM and vCPU values are validated against the current Mac, so a preference copied from a more powerful machine safely falls back to a valid local value.

From the user’s perspective, all the internal layers should disappear. They should not need to understand how gfxstream differs from MoltenVK. They should see clear states—Install, Ready, Play, Stop, Repair, and Reset—and receive a specific recovery action when something goes wrong.

10 · finding

Distribution Is an Engineering Problem Too

An application that works on the developer’s Mac is not yet a release.

Public builds use a dedicated pipeline with Developer ID signing, hardened runtime, notarization, and a stapled ticket. Components are signed from the inside out, after which both the final application and the DMG are verified.

Updates are delivered through Sparkle. The release archive is verified with an Ed25519 signature before extraction. Release artifacts are published under immutable, versioned names, and a new appcast replaces the previous one only after every artifact has been uploaded successfully. Automatic update checks are enabled, but installation still requires user confirmation.

The runtime does not live inside the .app bundle. Android components, the AVD, TFT state, and user settings live under ~/Library/Application Support/TFT PBE Launcher, so replacing the application bundle does not copy, upload, or delete user data.

fig 08 · release boundaryThe application is replaceable and signed; the user’s Android runtime and game state remain local and separate.

This is the same approach I use in infrastructure projects: immutable artifacts, integrity verification, an explicit boundary between code and state, a safe publication order, and a rollback path.

11 · finding

Where AI Fit into the Project

AI was not a decorative part of this work. I used it extensively as an engineering copilot in an unfamiliar stack:

  • to navigate Android Emulator, ANGLE, Unreal Engine, and macOS APIs;
  • to generate testable hypotheses;
  • to analyze logs, tombstones, and profiles;
  • to prepare single-factor experimental changes;
  • to write SwiftUI code, shell tooling, and tests;
  • to refactor the documentation after the research was complete.

But the workflow “give the model a task and accept the first working answer” would quickly have produced cargo-cult optimization.

The critical decisions remained human-owned:

  • which invariants could not be violated;
  • what data counted as evidence;
  • how to isolate a single factor;
  • when a result required repetition;
  • which hypotheses to reject despite an attractive one-off run;
  • where automation had to fail closed;
  • which data and user state could not be touched;
  • when the prototype was reliable enough to become a product.
fig 09 · human-owned loopAI accelerated the iteration loop; experimental design, safety constraints, and acceptance decisions remained human-owned.

AI did not replace domain expertise. It increased the speed of the hypothesis → change → measurement → validation → documentation loop. The quality of the result still depended on the quality of the experimental method.

12 · finding

Why This Experience Extends Beyond a Game Launcher

The project’s domain is a game running inside Android Emulator, but the engineering problems closely resemble those found in backend, platform, and security systems.

They included:

  • diagnosing a black-box system across several technology layers;
  • finding hidden defaults and non-obvious side effects;
  • managing a runtime that contains user state;
  • designing reproducible performance experiments;
  • building phase gates and fail-closed automation;
  • controlling versions and hashes;
  • recovering from partially completed operations;
  • distributing signed software;
  • separating an immutable application bundle from mutable user state;
  • documenting limitations and negative results honestly.

The most useful lesson for me was this: you can enter an unfamiliar domain quickly, but you cannot shorten the path by weakening the quality bar. Documentation reading, code generation, and log analysis can all be accelerated. Trust in the result cannot. It only comes from repeatable validation.

13 · finding

What Is Now Open Source

The repository contains the SwiftUI launcher source code, runtime scripts, tests, architecture documentation, benchmark methodology, and a cleaned research log covering both successful and rejected experiments.

For a player, it is a way to run TFT PBE on Apple Silicon through a straightforward macOS application. For a developer, I think the more valuable part is the complete trail showing how a personal workaround evolved into a controlled system with measurement, recovery, integrity verification, and a release process.

The project does not prove that AI can instantly turn someone into an expert in any technology. It demonstrates something more practical: an experienced engineer can use AI to investigate a new stack much faster—as long as responsibility for architecture, safety, and proof of correctness remains with the engineer.

attribution

Original technical case study by Sergei Naumov.