Building a 64-Layer ZMK Glove80 Layout
How I widened ZMK's layer state from 32-bit to 64-bit and built a dual-OS keyboard layout for the Glove80.
I forked MoErgo’s official ZMK firmware for the Glove80 and built a 64-layer dual-OS layout that lets me switch between macOS and Linux configurations live, testing Claude Code’s web agent at the same time.
Constraints
The Glove80 runs ZMK firmware, which caps keyboard layers at 32. That’s enough for a single-OS layout, but I use both macOS and Linux on the same keyboard. The two operating systems need different home-row modifier mappings (macOS uses Ctrl/Alt/Cmd/Shift, Linux uses Gui/Alt/Ctrl/Shift), different clipboard shortcuts, and different application-switching behaviors. I needed 64 layers: 32 for macOS, 32 for Linux, mirroring the same structure with OS-specific keybindings.
The existing keymap was a single monolithic file: more than 11K lines of ZMK devicetree syntax1. This was sunaku’s Glorious Engrammer v42-rc6, an extraordinarily well-crafted layout. I’d been running Glorious Engrammer for a couple of years (using its QWERTY base layer, I’m not on the engram layout itself, I just lean on the modifier system). sunaku’s work is the kind of open-source contribution that quietly makes a piece of hardware dramatically better for thousands of people. The hold-tap behaviors, bilateral enforcement, and thumb cluster model all come from this keymap. It builds on urob’s timeless ZMK helper macros under the hood, but the complete keymap, 64+ behaviors, macros for every punctuation edge case, a full Miryoku-inspired layer stack, is sunaku’s.
The monolithic format isn’t an accident. MoErgo provides a Layout Editor: a web-based GUI where you drag-and-drop key bindings instead of editing devicetree code. A single .keymap file is the unit of exchange: you import it into the editor, rearrange keys visually, and export it back. sunaku’s keymap supports both workflows, raw code editing and the GUI tool, and that portability is part of why Glorious Engrammer has such wide adoption across Glove80 users.
Under the hood, sunaku ships a full code-generation pipeline. A Ruby ERB template engine processes YAML character databases (world.yaml with 400+ international glyphs, emoji.yaml with 90+ emoji) and JSON layout exports into ~5,000 lines of ZMK devicetree. Nearly 300 configurable #define parameters cascade through an inheritance system: operating system type, difficulty level, per-finger modifier order, emoji hair-style presets, Unicode input timing, even per-key RGB colors. A Docker container wraps the rake build so any user can regenerate the keymap from templates without installing Ruby locally. This is all by design: Glorious Engrammer is a customizable blueprint meant to be forked and configured by thousands of Glove80 users without writing code.
I’m building this keymap for myself, not shipping it as a configurable product. I need sunaku’s behaviors, the home-row mod macros, the thumb-cluster model, the combo patterns, but not the template engine that generates them. I can track the upstream repository’s releases, read the source, and manually merge what matters. Shedding the code-generation layer also means the keymap can exist as direct devicetree source: no Ruby, no ERB, no Docker, just .dtsi files that compile with nix-build.
The problem was that Glorious Engrammer is built for a single OS. At more than 11K lines in one file, a change to one layer’s home-row mods required scrolling through hundreds of lines of nearly identical definitions. Duplicating the entire stack for Linux while keeping it maintainable wasn’t feasible in a single file. And while the monolithic format works for a point-and-click GUI, it doesn’t work for an AI coding agent that needs to understand and modify specific concerns without losing context across more than 11K lines.
The Build System
MoErgo’s upstream ZMK uses GitHub Actions for CI builds. The workflow pushes a commit, waits for an action runner to spin up, compiles the firmware for both halves, and produces artifact files. Typical turnaround: 5-10 minutes, assuming no queue. For rapid iteration, where I’m making small changes and need to verify they compile, that feedback loop is too slow.
This repo ships Nix build expressions that compile everything locally in under a minute:
nix-build -A glove80_combined
The output at ./result/glove80.uf2 contains both left and right firmware concatenated together; flash it to either half and the bootloader handles the rest. Internally, the Nix build wraps the Zephyr RTOS toolchain (ARM GCC cross-compiler, cmake, ninja) in a reproducible derivation. It evaluates the devicetree with arm-none-eabi-cpp, compiles via cmake/ninja, merges both halves with cat, and caches the result with ccache for subsequent runs.
This local build took the iteration cycle from “push, wait, download artifact, flash” to “run one command, flash.” With 18+ modular include files and 64 layers, the ability to compile locally was the difference between the project being feasible and being abandoned halfway through.
The 64-Layer Patch
The 64-layer support patch started from a draft ZMK PR #2846 on the upstream ZMK repository. The PR proposed widening the layer state bitmask from 32 to 64 bits but hadn’t been merged into mainline. I needed it to work on MoErgo’s ZMK fork specifically, so I adapted the approach and fixed several edge cases the draft hadn’t covered: primarily around atomic operations on ARM Cortex-M and bit operations in the combo and pointing subsystems.
ZMK tracks which layers are active using a bitmask stored in a uint32_t: 32 bits, one per layer. The fix is conceptually simple: widen it to uint64_t. The implementation is not, because the Glove80 runs on an ARM Cortex-M processor that only has 32-bit native atomic operations. You can’t atomically compare-and-swap a 64-bit value in a single instruction.
The solution is a WRITE_BIT64 macro that splits the 64-bit value into two 32-bit halves and performs atomic compare-and-swap on whichever half the target bit falls in. Here’s the macro from app/src/keymap.c:
// app/src/keymap.c
// 64-bit version of WRITE_BIT for layer state manipulation
#define WRITE_BIT64(var, bit, set) \
do { \
atomic_val_t _WRITE_BIT64_old_value; \
atomic_val_t _WRITE_BIT64_new_value; \
if (bit < 32) { \
do { \
_WRITE_BIT64_old_value = ((uint32_t *)&var)[0]; \
_WRITE_BIT64_new_value = _WRITE_BIT64_old_value; \
WRITE_BIT(_WRITE_BIT64_new_value, bit, set); \
} while (!atomic_cas((atomic_t *)&var, _WRITE_BIT64_old_value, \
_WRITE_BIT64_new_value)); \
} else { \
do { \
_WRITE_BIT64_old_value = ((uint32_t *)&var)[1]; \
_WRITE_BIT64_new_value = _WRITE_BIT64_old_value; \
WRITE_BIT(_WRITE_BIT64_new_value, bit - 32, set); \
} while (!atomic_cas((atomic_t *)&(((uint32_t *)&var)[1]), \
_WRITE_BIT64_old_value, \
_WRITE_BIT64_new_value)); \
} \
} while (0)
The type definition change in app/include/zmk/keymap.h is one line:
// app/include/zmk/keymap.h
// Before:
typedef uint32_t zmk_keymap_layers_state_t;
// After:
typedef uint64_t zmk_keymap_layers_state_t;
The full patch touches 5 files: keymap.h, keymap.c, combo.c, conditional_layer.c, and input_listener.c. Every uint32_t layer mask becomes uint64_t, every BIT(layer) becomes BIT64(layer), and every WRITE_BIT on layer state becomes WRITE_BIT64. The total diff is +39/-17 lines; small for the impact it has.
This patch is based on ZMK PR #2846. For 128+ layers, you’d need an array-based approach (uint32_t[] with DIV_ROUND_UP), but 64 was enough for my use case.
Modular Architecture
With 64 layers, the monolithic keymap file was no longer tenable. The refactor was about giving Claude the ability to target specific concerns without reprocessing the entire keymap. A file of more than 11K lines exceeds practical context for precise edits. Breaking into focused .dtsi files meant Claude could navigate to home-row-mods.dtsi for mod-tap tuning or combos.dtsi for chord changes without drowning in 10,000 unrelated lines. I split it into this tree:
app/boards/arm/glove80/
├── glove80.keymap # 83-line entry point
└── includes/
├── helpers.dtsi # Preprocessor macros + 64 layer ID #defines
├── custom-nodes.dtsi # RGB indicators, color definitions
├── behaviors.dtsi # Orchestrator — includes behavior modules
├── combos.dtsi # 18 chorded combos
└── behaviors/
├── definitions.dtsi # Key positions, OS detection, shortcuts
├── home-row-mods.dtsi # 3,468 lines — hold-tap behaviors
├── world-characters.dtsi # Unicode/Compose macros (opens macros{})
├── emoji.dtsi # Emoji library (closes macros{})
└── post-macros.dtsi # Mouse config, scroll acceleration, scalers
└── layers/
├── base-layers.dtsi # 7 macOS base layouts
├── base-layers-linux.dtsi # 7 Linux base layouts
├── typing-layer.dtsi # Typing helper layer
├── typing-layer-linux.dtsi
├── finger-layers.dtsi # 8 finger helper layers
├── finger-layers-linux.dtsi
├── function-layers.dtsi # Cursor, Number, Function, Emoji, World, Symbol, System
├── function-layers-linux.dtsi
├── mouse-layers.dtsi # Mouse, MouseFine, MouseSlow, MouseFast, MouseWarp
├── mouse-layers-linux.dtsi
├── special-layers.dtsi # Gaming, Factory, Lower, Magic
└── special-layers-linux.dtsi
The entry point glove80.keymap is 83 lines. It includes everything via the C preprocessor:
// app/boards/arm/glove80/glove80.keymap
#include <behaviors.dtsi>
#include <dt-bindings/zmk/outputs.h>
#include <dt-bindings/zmk/keys.h>
#include <dt-bindings/zmk/bt.h>
#include <dt-bindings/zmk/rgb.h>
#include <dt-bindings/zmk/pointing.h>
#include "includes/helpers.dtsi"
#include "includes/custom-nodes.dtsi"
#include "includes/behaviors.dtsi"
#include "includes/combos.dtsi"
/ {
keymap {
compatible = "zmk,keymap";
/* macOS Layers (0-31) */
#include "includes/layers/base-layers.dtsi"
#include "includes/layers/typing-layer.dtsi"
#include "includes/layers/finger-layers.dtsi"
#include "includes/layers/function-layers.dtsi"
#include "includes/layers/mouse-layers.dtsi"
#include "includes/layers/special-layers.dtsi"
/* Linux Layers (32-63) */
#include "includes/layers/base-layers-linux.dtsi"
#include "includes/layers/typing-layer-linux.dtsi"
#include "includes/layers/finger-layers-linux.dtsi"
#include "includes/layers/function-layers-linux.dtsi"
#include "includes/layers/mouse-layers-linux.dtsi"
#include "includes/layers/special-layers-linux.dtsi"
};
};
The helpers.dtsi file defines all 64 layer IDs as preprocessor constants:
// app/boards/arm/glove80/includes/helpers.dtsi
/* macOS layers (0-31) */
#define LAYER_QWERTY 0
#define LAYER_Enthium 1
#define LAYER_Engrammer 2
#define LAYER_Engram 3
#define LAYER_Dvorak 4
#define LAYER_Colemak 5
#define LAYER_ColemakDH 6
#define LAYER_Typing 7
#define LAYER_LeftPinky 8
// ... through ...
#define LAYER_Magic 31
/* Linux layers (32-63) */
#define LAYER_QWERTY_Linux 32
#define LAYER_Enthium_Linux 33
#define LAYER_Engrammer_Linux 34
// ... through ...
#define LAYER_Magic_Linux 63
The firmware entry point compiles the full 64-layer monolith from these 18+ source files. The flattened frangonf-glove80.keymap export at the repo root exists only for readability and for qmk.nvim formatting; it’s not used by the build2.
Dual-OS Layer Design
The 64 layers split into two mirrored stacks:
| Range | OS | Families |
|---|---|---|
| 0-31 | macOS | 7 base, 1 typing, 8 finger, 7 function, 5 mouse, 4 special |
| 32-63 | Linux | Same structure, offset by +32 |
The critical difference between the stacks is the home-row modifier mapping. On macOS, the home row mods are CAGS (Ctrl, Alt, Gui/Cmd, Shift). On Linux, they’re GACS (Gui, Alt, Ctrl, Shift). This swap is the main reason the full layer stack is mirrored instead of reusing one shared base.
The macOS QWERTY base layer uses LeftPinky(A, LAYER_QWERTY) on the A key, which expands to a hold-tap behavior where holding activates the left pinky modifier (Ctrl on macOS) and tapping produces A. The Linux version uses the same macro name but points to LAYER_QWERTY_Linux, where the pinky modifier is mapped to Gui instead of Ctrl.
There’s one additional macOS-specific feature: MACOS_CAGS_EXTEND. When enabled, the E, C, I, and comma positions become extra Ctrl-capable positions on the base layers. This reduces pinky strain by providing middle-finger Ctrl access: useful for macOS shortcuts like Cmd+C, Cmd+V, and Cmd+I that otherwise require awkward pinky stretches.
Home Row Mods
The home-row mod system is the most complex part of the keymap. It’s based on sunaku’s timeless home row mods and urob’s ZMK helpers, extended with bilateral enforcement.
Each home-row key expands into a hold-tap behavior. For example, LeftPinky(A, LAYER_QWERTY) means: tap to produce A, hold to activate the left pinky modifier and enter the transient left-pinky helper layer. The helper layers (8 per OS stack) exist solely to resolve the hold-tap state; they’re never typed on directly.
Bilateral enforcement (ENFORCE_BILATERAL) prevents same-hand home-row mod activation. If you’re holding a key on the left hand, pressing a home-row mod key on the same hand produces a plain tap instead of the modifier. This reduces false triggers during fast typing.
The tapping resolution is configurable via a difficulty level system:
// app/boards/arm/glove80/includes/behaviors/home-row-mods.dtsi
// DIFFICULTY_LEVEL 1: novice (500ms)
// DIFFICULTY_LEVEL 2: slower (400ms)
// DIFFICULTY_LEVEL 3: normal (300ms)
// DIFFICULTY_LEVEL 4: faster (200ms)
// DIFFICULTY_LEVEL 5: expert (100ms)
// DIFFICULTY_LEVEL 0: sunaku default (150ms)
#ifndef TAPPING_RESOLUTION
#define TAPPING_RESOLUTION 150
#endif
Combos
The layout includes 18 chorded combos defined in combos.dtsi. These use two-thumb chords on the bottom row:
| Combo | Keys | Result |
|---|---|---|
| Sticky Globe | T2+T3 | &sk _GLOBE (input language switch) |
| Sticky RAlt | T1+T2 | &sk RALT |
| Alt+Tab Switcher | T2+T5 | &mod_tab_chord _A_TAB LAYER_Cursor |
| Win+Tab Switcher | T5+T6 | &mod_tab_chord _G_TAB LAYER_Cursor |
| Hyper | T2+T5 (right) | LG(LA(LC(LSHFT))) |
| Ctrl+Tab | T3+T6 | &mod_tab_chord LCTL LAYER_Cursor |
| Sticky Shift | T1+T4 | &sticky_key_modtap LSFT/RSFT |
| Caps Word | T4+T5 | &caps_word |
| Caps Lock | T1+T5 | &kp CAPSLOCK |
| Base Reset | T1+T2+T3 | &to 0 |
The mod_tab_chord behavior is a parameterized macro that holds a modifier, taps Tab repeatedly while a key is held, and activates the Cursor layer on release. This gives me app-switching chords that feel like native OS shortcuts.
All combos use a 50ms firing decay (COMBO_FIRING_DECAY), short enough that deliberate chords register while accidental sequences don’t.
Mouse Control
The layout includes a 5-tier mouse speed system:
| Tier | Layer | Scaler | Speed |
|---|---|---|---|
| Fine | 24/56 | 1:16 | 0.0625x |
| Slow | 25/57 | 1:4 | 0.25x |
| Normal | 23/55 | none | 1x |
| Fast | 26/58 | 4:1 | 4x |
| Warp | 27/59 | 12:1 | 12x |
Mouse motion defaults: 600px/s max speed, 300ms acceleration time, linear acceleration. Scroll: 10 units/s max, no acceleration. The scroll acceleration feature uses modifier-based activation: holding a modifier key while scrolling activates higher-speed multipliers.
Using Claude Code Web
I used the project to test Claude Code’s web-based agent workflow. I picked this project specifically because it was effectively free: in November 2025, Anthropic ran a promotional campaign giving Pro subscribers $250 in Claude Code credits3. I had no reason to buy in normally, but a free credit pool removed the friction. The Glove80 keymap turned out to be a good stress test: multi-file refactoring, C preprocessor macros, ARM firmware constraints, and a build system that moves slower than most web projects.
The model under the hood was Claude Sonnet 4.5; Anthropic had just switched it to the default for Claude Code on November 14, 2025, the day before the first commit in this repo. The workflow: connect the GitHub repository at Claude Code, describe a task in natural language, and let Claude implement changes across multiple files. Each session ran in an isolated cloud sandbox with its own filesystem and network access. Claude could read the full codebase and propose changes as pull requests.
Over the course of the project, 11 pull requests were created through Claude’s web sessions. Each PR branch followed the naming pattern claude/<descriptive-name>-<session-id>. The PRs covered:
- Reviewing and validating the 64-layer support patch
- Refactoring the monolithic keymap into modular structure
- Adding Linux layer support (layers 32-63)
- Implementing bilateral enforcement for Linux layers
- Adding composable find/replace shortcuts per OS
- Enabling scroll acceleration with tap-dance behaviors
- Extending home-row mods with Ctrl-capable positions on macOS
The web interface handled multi-file edits well. When I described “add Linux layer support to all combos,” Claude updated combos.dtsi to include layer scoping for layers 32-38 alongside the existing 0-6 ranges. When I asked to “split tab switcher combos for correct cursor layer per OS,” it understood that left-hand combos should activate LAYER_Cursor while right-hand ones activate LAYER_Cursor_Linux.
The main limitation was approval fatigue on large refactors. The modular architecture refactoring touched 18+ files, and reviewing each change required understanding the devicetree include chain. I ended up doing some of the review work in the browser and some locally after pulling the branches.
Working layout
The layout compiles and runs on the Glove80. All 64 layers are accessible. macOS and Linux configurations have independent home-row mods, clipboard shortcuts, and application-switching behaviors. The modular structure makes it possible to edit one layer file without touching the others.
The frangonf-glove80.keymap export at the repo root works with qmk.nvim for Neovim-based keymap editing. The keymap-drawer YAML export generates visual layout diagrams.
Remaining rough edges
The shared macros {} block is fragile. The world characters file opens the block, the emoji file closes it, and the post-macros file relies on that exact ordering. A build error in one file produces cryptic errors in another. I’d look for a way to make this more robust: perhaps by using ZMK’s macro system differently.
I’d also set up a CLAUDE.md file at the start. Claude Code reads it automatically to understand project conventions. I added one later, but the early PRs would have benefited from explicit instructions about ZMK devicetree syntax and the modular include structure.
The bilateral enforcement for Linux layers required creating separate behavior definitions. I initially thought I could reuse the macOS behaviors with conditional logic, but the devicetree preprocessor doesn’t support that kind of runtime branching. The duplication is intentional and necessary.
References
- frangonf/zmk. The forked Glove80 ZMK firmware with 64-layer support and dual-OS layout
- ZMK PR #2846. The original 64-layer support proposal that this implementation is based on
- sunaku’s Glorious Engrammer. The base keymap and home-row mod system this layout extends
- urob’s ZMK Helpers. Bilateral enforcement and timeless home-row mod patterns
- Claude Code. The browser-based AI coding assistant used for implementation
- MoErgo Glove80. The split keyboard hardware this firmware runs on
- keymap-drawer. Visual keymap rendering tool
- qmk.nvim. Neovim plugin for ZMK keymap formatting and board preview
Footnotes
-
The more than 11K line count comes from commit
1e3b77c4(sunaku v42-rc6 frangonf macos keymap as base glove80.keymap), which replaced the 138-line MoErgo factory default with sunaku’s full Glorious Engrammer v42-rc6 keymap. The factory original was preserved asmoergo-glove80.keymap. ↩ -
The more than 10K line count spans all modular
.dtsifiles combined. The home-row-mods file alone is 3,468 lines; the world-characters file is 4,426 lines; the emoji file is 2,463 lines. The total is approximate, counted across theincludes/tree at the time of writing. ↩ -
Anthropic ran a promotional campaign in November 2025 offering Pro subscribers $250 in Claude Code credits and Max subscribers $1,000, coinciding with major model launches. Credits applied to all Claude Code sessions: terminal, web, and IDE. More details at Anthropic’s blog and third-party coverage of Claude access options. ↩
This post was written with AI assistance.