ZYRNTOPO Documentation

Complete reference for every feature in ZYRNTOPO — an offline-capable topographic mapping application for Android, Windows, Linux and the browser. The core app is free with no account; ZYRNTOPO Pro is an optional unlock for the power features — permanent for a one-time price, or monthly. Your data stays on your device.

Free vs ZYRNTOPO Pro. The core app is free — offline maps with unlimited downloads, GPS and tracks, drawing, measurement, coordinates and MGRS, per-view POIs, 3D terrain, viewshed, true-scale PDF map sheets, photo waypoints, live weather radar (NEXRAD), custom map services (WMS, WMTS and WFS), and GPX/GeoJSON/KML/CSV export. A ZYRNTOPO Pro unlock — permanent for a one-time price, or a monthly plan — adds Team Sync (+ chat), the tactical bridges (ATAK, TAK server, MAVLink) and the power lines overlay. Anything that needs Pro carries a PRO tag in the interface before you tap it. An account is needed only to activate Pro, which is then verified on-device and works offline. See pricing.

Getting Started

Install, permissions, and your first map.

Map Interface

Layers, 3D terrain, controls, and compass.

Team Sync

P2P, LAN, drone, ATAK.

Offline Maps

PMTiles archives, Area Saver, storage.

Search & Rescue

Periods, resources, clues, and gear.

Bridge Tools

LAN relay, ATAK/SoRD bridges.

ZYRNMAPS

The native Android client.

Plugin SDK

Build a plugin for ZYRNMAPS.


Installation

ZYRNTOPO runs on Android, Windows, Linux and the web. Choose the one that fits your device.

Android

  1. Install from Google Play. Updates arrive automatically, like any other app.
  2. On a Fire tablet, or any device without Google Play, install from the Amazon Appstore instead. It handles installation and updates for you.
  3. On first launch, the app shows a prominent disclosure and requests Location (for GPS position and track recording). The Microphone is requested only when you record a voice note. You can update these any time in Android Settings.
  4. The app works immediately — no sign-in, no setup wizard.

The Android download is around 12 MB because the points-of-interest dataset is not bundled on Android — at roughly 340 MB it is far past Google Play's base-download limit. The app range-reads it from pois.zyrntopo.com on first run and streams a copy into device storage in the background, so every launch after the first is fully offline. The desktop builds bundle it outright, which is why those installers are ~670 MB.

Linux (desktop)

The desktop build is the same application in an Electron shell, serving the web build from a loopback port — service worker, offline tile caches and the POI store all behave exactly as they do in the browser. One command installs it on any distribution:

curl -fsSL https://zyrntopo.com/install.sh | sh

The script resolves the current release, detects your distribution and your CPU architecture, verifies the package's SHA-256 against the published checksum file, and installs it. Re-running it upgrades in place. It never installs unverified bytes: a checksum mismatch aborts before anything is written.

  • Debian, Ubuntu, Mint, Pop!_OS.deb via apt
  • Fedora, RHEL, CentOS, openSUSE.rpm via dnf, yum, zypper or rpm
  • Arch, Manjaro, CachyOS, EndeavourOS.pacman via pacman -U
  • Anything else — a self-contained .AppImage installed under ~/.local, needing no root

Architectures

Every format ships for both x86_64 (also called amd64 — any normal PC or laptop) and ARM64 (aarch64 — ARM laptops, a Raspberry Pi 4 or 5 running a 64-bit OS, ARM servers and VMs). The installer reads uname -m and fetches the matching package; there is nothing to choose. If you are downloading by hand and are not sure, run uname -m: x86_64 means the x86_64 files, aarch64 means the ARM64 ones.

There is no 32-bit build, and there will not be one. The desktop app is built on Electron, which dropped 32-bit Linux support in 2019 — it is not a gap waiting to be filled. If that is the hardware you have, the web app runs in any modern browser and works offline once loaded: app.zyrntopo.com.

Updating, checking and removing

Installing also puts a zyrntopo command on your PATH, so you manage it like any other program — no need to keep the install URL to hand. Note that native packages are installed directly rather than from a repository, so apt upgrade and friends will not pick up new ZYRNTOPO releases; use zyrntopo update.

zyrntopo            # launch the app
zyrntopo update     # upgrade to the current release
zyrntopo version    # installed vs current
zyrntopo remove     # uninstall (keeps your saved maps)
zyrntopo help

Uninstalling removes the application but keeps your saved maps and downloaded tiles, which live in ~/.config/ZYRNTOPO. Delete that directory too if you want a clean slate. If you prefer your package manager, the package is named zyrntopo-desktop: apt remove zyrntopo-desktop, dnf remove zyrntopo-desktop or pacman -R zyrntopo-desktop.

Useful overrides:

# pin a specific version instead of the current release
ZYRN_VERSION=1.16.8 sh -c "$(curl -fsSL https://zyrntopo.com/install.sh)"

# force the no-root AppImage even on a deb/rpm/Arch system
ZYRN_METHOD=appimage sh -c "$(curl -fsSL https://zyrntopo.com/install.sh)"

# install the AppImage somewhere other than ~/.local
ZYRN_PREFIX=/opt/zyrntopo ZYRN_METHOD=appimage sh -c "$(curl -fsSL https://zyrntopo.com/install.sh)"

Prefer to read the script before running it? Fetch it first — it is plain POSIX sh: zyrntopo.com/install.sh.

AppImage and FUSE. AppImages normally mount themselves with FUSE 2, which Arch, Fedora 38+ and Ubuntu 22.04+ no longer ship. The installer works around this: it puts a small launcher on your PATH that uses FUSE when present and falls back to --appimage-extract-and-run when it is not, so you never see "AppImages require FUSE to run".

Windows (desktop)

The same Electron shell as the Linux build, on Windows 10 or 11. Every installer carries the whole application — there is nothing to download on first launch.

The short way, and the one to prefer — it picks the architecture for you, checks the SHA-256 against the published checksum, and only then installs:

irm https://zyrntopo.com/install.ps1 | iex

Then manage it the same way the Linux build is managed: zyrntopo update, zyrntopo version, zyrntopo remove. To pin a version or take the MSI instead, set the environment first:

$env:ZYRN_VERSION='1.16.10'; irm https://zyrntopo.com/install.ps1 | iex
$env:ZYRN_METHOD='msi';     irm https://zyrntopo.com/install.ps1 | iex

Prefer to read the script before running it? It is plain PowerShell 5.1: zyrntopo.com/install.ps1.

There is no manual download. The installers are close to 1 GB each — the offline map archive ships inside them — and running one by hand means trusting an unsigned binary past a SmartScreen prompt without checking it. The command above resolves the architecture, verifies the SHA-256 against the published checksum, and refuses to install on a mismatch, so it is the only route offered.

The -setup.exe is what it installs by default: per-user, so no administrator, with Start menu and desktop shortcuts. The .msi is for managed fleets — per-machine into C:\Program Files\ZYRNTOPO, deployable unattended through Group Policy or Intune. Ask for it with $env:ZYRN_METHOD='msi', which downloads and verifies it the same way; from there it is an ordinary msiexec /i … /qn package.

Deploying the ARM64 MSI. The application inside is native ARM64, but the MSI package itself is built as an x64 package, so tools that read the package platform (Intune, some inventory systems) will report it as x64. It installs and runs correctly on ARM64 Windows — just target it by device rather than by package architecture. If your tooling insists on a true ARM64 package, use the ARM64 -setup.exe, which is what the install command takes by default.

Upgrading is the same step as installing — re-run the command, or zyrntopo update. Uninstall with zyrntopo remove, or from Settings ▸ Apps (msiexec /x for the MSI); either way your saved maps and downloaded tiles are kept, in %APPDATA%\ZYRNTOPO. Delete that folder too for a clean slate.

SmartScreen. The Windows installers are not code-signed yet, so Windows shows "Windows protected your PC" for anyone who runs one directly. That prompt is asking you to verify the binary yourself, which is exactly what install.ps1 does before it executes anything — it compares the download against the published SHA-256 and aborts on a mismatch. Install through the command and the prompt does not arise.

Web / PWA

Open app.zyrntopo.com in any modern browser. For offline use, install it as a PWA: in Chrome/Edge click Install app in the address bar; in Firefox/Safari use Add to home screen.

Verifying a download

Every release publishes a checksum manifest. To check a file by hand:

curl -fLO https://pois.zyrntopo.com/downloads/SHA256SUMS-1.16.8.txt
sha256sum -c SHA256SUMS-1.16.8.txt --ignore-missing

On Windows, in PowerShell:

Get-FileHash zyrntopo-1.16.10-win-x64-setup.exe
# compare the hash with the line for that file in
# https://pois.zyrntopo.com/downloads/SHA256SUMS-1.16.10.txt

Each release publishes a SHA256SUMS-<version>.txt listing every file in it. Scripts can read the same data as JSON from releases.json, where current names the published release.

Only the current release is hosted. Superseded builds are retired from the archive as soon as a new one goes live, so ZYRN_VERSION is there to pin the current release explicitly — not to fetch an older one, which will 404. The install commands leave it unset and take what is published.

First Launch

On first launch ZYRNTOPO shows a prominent disclosure, then requests Location using the native OS dialog. Location is used for map position, tracks, route following, and nearby field context; for everything except track recording it is foreground-only. Background location is optional and asked separately the first time you Record Track, so a recording can continue with the screen off. Online weather, elevation, routing, or search features may send the needed coordinates or query to their provider. The Microphone permission is requested in-context the first time you record a voice note. On Web, the browser asks for Location when you first use GPS.

You can decline any permission dialog. The app still works — only the corresponding feature (GPS, voice notes) is unavailable until you grant it in Settings.

The default base layer on first launch is VersaTiles Eclipse — a full-detail dark vector street map rendered via MapLibre GL. Your map starts centered at the contiguous US (z4) or at your last-saved position if you've used the app before.

After your first session, all key UI state is automatically persisted — base layer selection, map position and zoom, and hillshade opacity are restored exactly as you left them on every subsequent launch.

Basic Navigation

The interface has three main areas:

  • Map canvas — the full-screen map. Pan with one finger (touch) or mouse drag. Pinch or scroll to zoom. Rotate with two fingers.
  • Top bar — map title, layer switcher, GPS button, search, and the More drawer trigger.
  • More drawer — opens from the hamburger menu: Map Objects, Folders, Operational Periods, Team Members, Team Sync, Team Chat, Gear, Settings, Offline Tiles, Import/Export, and quick links to the Website, Documentation, your Account, Help & FAQ, and Send feedback. (Help & FAQ and Send feedback moved here from Settings in 1.15.13 — all five are places to go rather than preferences to set.)

Long-press anywhere on the map to hide the on-screen controls — the coordinate box, the weather widget, the zoom and scale readout, the GPS buttons and the bottom bar all clear away for an uninterrupted view of the map. Long-press again (or right-click, on desktop) to bring them back. Nothing is saved: the controls return the next time the app opens.

To drop a marker, start a line, or view point info for a spot, use Tools in the bottom bar.


Base Layers

Tap the layer icon (stack icon in top bar) to open the layer panel. The current basemap shows as a single compact row — tap it to open the base-layer picker, grouped by category with a thumbnail preview per layer. Sixteen base layers are included — 15 raster layers organized into groups, plus the no-key VersaTiles Eclipse vector basemap — plus a built-in browser for 1,345 additional worldwide imagery sources (OSM Editor Layer Index).

Map Styling catalog: fifteen categorized ZYRNTOPO style tweaks in the layer panel restyle any basemap — including raster satellite and offline archives: all roads (side streets included), highways with route shields & names, train tracks, country/state borders with names, national & state park boundaries, power lines, contour lines with a selectable interval (CalTopo-style, generated from terrain data), water & lakes, rivers & streams, trails & paths, offroad & gravel, trail/offroad name labels, campgrounds & viewpoints, live OSM peaks / fishing spots / boat launches, and business labels (food, fuel & shops). They stay on across basemap switches, work offline once tiles are cached (the live-OSM tweak needs internet), and persist across launches. A Map Legend button beside the Map Styling title opens a full symbology reference. The ZYRNTOPO Outdoor / ZYRNTOPO Topo basemaps also carry deeper cartography: crisp lake shorelines, tracktype-graded tracks, an unpaved-surface stipple, and trail-name labels.
Map Layers menu: a compact stacked layout. Add Base Layer stacks a second basemap or downloaded map over the current one — each stacked layer gets its own opacity slider and one-tap remove, e.g. USGS Topo contours blended over satellite. Overlays are grouped pill rows with map thumbnails; active ones get an orange outline and check badge. The Catalog submenu (Customize, or "Configure Layers" inside the picker) lets you search every available layer and choose exactly which ones the menu offers — with Reset To Defaults one tap away.
Quick setups / activity presets: the layer panel includes one-tap presets — Hike, Snow / Ski, SAR, and Off-road — that switch to a sensible base map and turn on the matching overlays (e.g. Snow / Ski enables hillshade, slope angle, and avalanche danger), so you're configured in a single tap. The same panel has a brightness slider and a night-mode toggle. (The separate shaded-relief control was removed in 1.15.13 — it was a second set of controls for the same Hillshade layer the overlay catalog already owns, and the two disagreed about its brightness. Hillshade now lives in one place, under Terrain, with one opacity slider.)

The Offline column reflects each provider's terms — = saveable with the Area Saver; Online only = view-only (terms forbid bulk/offline download, import a .pmtiles instead).

Topo & Terrain

LayerBest forMax zoomOffline
USGS TopoUSGS composite topo, US-focused16
USGS Imagery + TopoAerial imagery overlaid with topo labels16
OpenTopoMapWorldwide topographic contours, elevation, terrain17Online only
NatGeo WorldNational Geographic styled topographic map16Online only
Esri World TopoEsri's worldwide topographic basemap19Online only
USA Topo (Historical)Historical USGS quadrangle scans (via Esri)15Online only
Esri World PhysicalPhysical terrain without labels — clean reference8Online only

Imagery

LayerBest forMax zoomOffline
Esri SatelliteHigh-resolution aerial imagery worldwide19Online only
USGS ImageryUSGS-sourced US aerial imagery16

Water / Bathymetric

LayerBest forMax zoomOffline
Esri Ocean BasemapBathymetry, depth contours, GEBCO / NOAA marine data — strong for water-SAR13Online only

Street & General

LayerBest forMax zoomOffline
OpenStreetMapStandard OSM street map (also the never-blank fallback)19Online only
OSM HumanitarianHumanitarian OSM Team styled map19Online only
CyclOSMRoads + cycle infrastructure detail18Online only

Vector Basemaps

VersaTiles Eclipse is the built-in no-key vector basemap and the app default — no PMTiles import required — rendered by MapLibre GL JS. It is free, needs no API key, and overzooms cleanly (it stays sharp at any zoom). Its style, glyphs, and sprites are bundled in the app, so the vector map renders even from a cold offline start (only the tiles need a connection until you download an area).

LayerStyleBest for
VersaTiles Eclipse (default)Dark / night, detailed street + terrain labelsGeneral navigation, hiking, field ops, low-light and night use
The vector style/glyphs/sprites are bundled, but the vector tiles still stream from VersaTiles until cached. For an offline-ready vector map, use Area Saver to download an area (or import a .pmtiles vector archive). The app ships a single vector basemap, Eclipse. Topo, satellite, and bathymetric layers stay raster (no no-key vector equivalent exists).

Imagery Catalog

Beyond the 16 built-in layers, ZYRNTOPO ships with a filtered copy of the OSM Editor Layer Index — 1,345 worldwide imagery sources curated for OSM editing (regional aerials, government topo maps, historical photos, etc.). Tap Layers → Custom WMS / WMTS → Browse Imagery Catalog and filter by:

  • Search — name, country code, description
  • Type — XYZ (TMS) or WMS
  • Category — aerial photo, map, historic photo, historic map, elevation, OSM-based, QA
  • Country — ISO country code dropdown

One-tap Add converts the entry into a custom source (TMS templates have {zoom} rewritten to {z}; WMS endpoints have LAYERS and FORMAT auto-extracted) and persists it across sessions. Added sources appear in the dropdown alongside the built-in 16 base layers.

The Offline column shows which bases ZYRNTOPO will pre-download into a local .pmtiles archive. This is gated by each provider's terms: USGS The National Map (US-government public domain, attribution requested) and the VersaTiles vector basemap is downloadable; Esri, OpenStreetMap, OpenTopoMap, and NatGeo stay online-only because their terms prohibit bulk/offline tile download. For those, import a provider-approved .pmtiles instead — see Offline Maps (PMTiles).

You can also add custom tile sources manually: tap Add WMS/WMTS Source at the bottom of the layer panel, enter a tile URL template (https://example.com/{z}/{x}/{y}.png), a name, and tap Add.

Overlays

Overlays layer on top of any base map. Multiple overlays can be active simultaneously, each with an adjustable opacity slider.

Terrain & Topography

  • Slope Angle Shading — terrain coloured by steepness, computed on-device from the elevation (DEM) data — no external service. The avalanche-oriented ramp keeps gentle terrain clear and heats up through the critical band: 27–29° yellow, 30–34° orange, 35–45° red (where most slab avalanches release), 46–50° purple, 51–59° blue, 60°+ grey (cliffs). Toggle it under Layers → Conditions → Slope Angle with an opacity slider. Works online (Mapterhorn DEM) and offline once DEM tiles are cached or a terrain archive is imported. Renders from zoom 8 in.
  • Slope Aspect — terrain coloured by the compass direction each slope faces, computed on-device from the same DEM as slope angle. An 8-sector colour rose (N, NE, E … NW) for planning sun exposure, snow loading, and avalanche aspect. Toggle it from the overlay picker with its own opacity slider; an on-map legend appears while it's active. Works online and offline (cached DEM / terrain archive).
  • Hillshade — shaded relief from Esri's World_Hillshade service (DEM-derived). Pairs well with imagery and any topo layer.
  • USGS Topo Overlay — overlays USGS Topo at adjustable opacity for blending with satellite imagery.
  • USGS Hydrography — streams, rivers, water bodies from USGS National Map.

Reference

  • Country & state borders — always on, and drawn from the offline vector tiles rather than fetched, so they are there with no signal. Country and state/province lines are both solid purple, with countries drawn heavier so the two stay distinguishable; region names are set in caps. State and province lines appear from zoom 7 in — the offline tiles carry no state-level boundary data below that zoom. County lines are not available offline — the offline tile schema carries national and state/province levels only, so there is nothing to draw below that.
  • Boundaries & Places — Esri labels overlay (great companion for satellite imagery).
  • Transportation — Esri's roads/highways reference layer.

Route Discovery

  • Hiking Trails — Waymarked Trails hiking-route overlay (display only — see CORS note).
  • Cycling Routes — Waymarked Trails cycling network (display only).
  • MTB Trails — Waymarked Trails mountain-bike network (display only).
  • Public Lands (BLM) — US Bureau of Land Management ownership and surface management. Available at zoom 9+ (display only).
  • OpenSeaMap — nautical seamarks for coastal/marine ops.
  • OpenSnowMap pistes (NEW) — worldwide ski/snowboard pistes and lifts — great for winter SAR and backcountry skiing.
  • OpenRailwayMap (NEW) — global rail infrastructure: tracks, stations, signals.
  • OpenInfraMap power (NEW) — high-voltage transmission lines and substations from OSM data.
  • Esri Reference Labels (NEW) — transparent label-only overlay; pairs with satellite/physical bases.

Weather & Hazards

  • Wind Plot (NEW) — standard meteorological wind barbs on a grid across the current view, sourced from Open-Meteo. A half barb is 5 kt, a full barb 10, a pennant 50, and speed is rounded to the nearest 5 before it is drawn; below 3 kt the station shows as an open circle for calm. Each barb points along the true wind direction and deliberately does not rotate with the map, so a northerly still reads as a northerly however the map is spun. The grid re-samples when you stop panning and refreshes on a 15-minute cycle, matching how often the data updates. Online-only, with its own opacity slider in Layers → Weather & hazards. Like every source in the app it needs no API key and no account.
  • Weather Radar (NEXRAD) — near-real-time NEXRAD base-reflectivity mosaic (Iowa Environmental Mesonet), refreshed about every 5 minutes. Online-only — radar is time-sensitive and isn't cached for offline use.
  • Active Wildfires (NIFC) — current US wildland-fire perimeters (over 100 acres) from the National Interagency Fire Center, drawn as vector polygons; tap a perimeter for the incident name, acreage, and discovery date. Loaded on demand and refreshed every few minutes.
  • Avalanche Danger — US avalanche-forecast zones from avalanche.org, colour-coded by the current North American danger scale (1 Low → 5 Extreme). Tap a zone for its rating; an on-map legend appears while it's active. Online-only and refreshed periodically (off-season zones show with no active rating).

Field Data — US

  • PLSS Survey Grid (BLM) — the Public Land Survey System township / section / intersected grid, shown as a reference overlay.
  • USFS Motor Vehicle Use Map — legal motorized roads & trails on national-forest land (the authority for vehicle access while hunting and scouting).
  • USFS Ownership — US Forest Service surface-ownership / administrative boundaries, for confirming you're on national-forest land vs. adjacent private or other-agency parcels.
  • Wetlands (NWI) — USFWS National Wetlands Inventory polygons (waterfowl habitat and fishing / launch context).
  • Tribal Lands / Reservations (NEW) — American Indian, Alaska Native, and Native Hawaiian areas from the US Census Bureau: Federal & State reservations, off-reservation trust lands, and Hawaiian home lands. Jurisdiction and access context in the backcountry. Toggle it from the overlay picker with its own opacity slider (display only, like the other field-data layers).
  • USGS Contours — transparent topographic contour lines to drape over satellite imagery.
  • Watersheds (USGS WBD) — HUC drainage-basin boundaries for hydrology and fishing.

These US field-data layers come from free, no-API-key ArcGIS map services via a generic dynamic-MapServer overlay type, so any number of them can be stacked at adjustable opacity.

Use any map as an overlay: beyond the dedicated overlays above, any raster basemap, imported map, or downloaded offline archive can be stacked with its own opacity slider — e.g. USGS Topo contours over satellite for hunting, or Esri Ocean bathymetry under your tracks for fishing. It lives in the Base Layers section: tap Add Base Layer and pick the map to stack.
CORS / Area Saver note: Waymarked Trails and BLM tiles render fine in the map viewer (the browser's <img> loader doesn't need CORS), but their servers don't send Access-Control-Allow-Origin, so the Area Saver can't read tile bytes for offline pre-download. To take those overlays offline, you'd need a third-party PMTiles export of the same data and import it via Settings → Offline Maps → Import .pmtiles.

3D Terrain View

Tap the 3D badge in the layer panel or the top bar to switch to 3D terrain mode, powered by MapLibre GL with WebGL rendering.

  • Real DEM elevation data from Mapterhorn (Terrarium-encoded WebP)
  • Drapes any base layer over the 3D terrain mesh
  • Hillshade and sky atmosphere included
  • Two-finger drag to pitch the view; two-finger rotate to spin
  • Use the pitch slider to adjust terrain tilt
  • Terrain exaggeration can be increased in Settings

Offline 3D Terrain

Save terrain data offline so 3D mode works without a connection:

  1. Pan/zoom to your AOI
  2. Open Layers → Save Terrain
  3. Confirm the tile count + size estimate; ZYRNTOPO downloads the Mapterhorn DEM tiles (z0–10 by default) and packs them into a .pmtiles archive tagged with kind: 'dem'
  4. From then on, any 3D activation prefers the local DEM archive over the network — fully air-gapped 3D terrain

The DEM archive is independent from base-tile archives. Both can coexist; the 3D engine combines them via MapLibre's raster-dem source pointed at a pmtiles:// URL.

You can also bundle a starter DEM archive with the app by adding an entry to public/maps/manifest.json with "kind": "dem" — see Offline Maps.

For the best offline 3D experience: run the Area Saver for your base-tile imagery and tap Save Terrain in the same view. Both layers go into separate PMTiles archives and combine automatically the next time you enable 3D.

Vector PMTiles

ZYRNTOPO can display vector PMTiles archives (OpenFreeMap exports, Protomaps Basemaps, tippecanoe output, etc.) — not just raster ones. The app uses MapLibre GL's addProtocol('pmtiles') to read tiles directly from the in-OPFS archive without any network round-trip.

The no-key VersaTiles Eclipse vector basemap is available directly from the layer dropdown — no import required, and their style/glyphs/sprites are bundled for offline use. See Base Layers → Vector Basemaps. Use Area Saver (or import a .pmtiles) to take the vector tiles offline.
  • Import any .pmtiles file (raster or vector) via Layers → Offline Maps → Import .pmtiles.
  • Activate a vector archive: tap Use (3D) in the offline maps list. The app enters 3D mode and switches to a MapLibre style.
  • A default style is auto-generated from the archive's vector_layers metadata: every layer gets a fill, line, and circle entry filtered by geometry type, plus label symbols where the layer has a name field.
  • Vector tiles drape over the DEM just like raster bases — so vector maps get real 3D terrain too.
  • Raster + vector PMTiles can coexist; switch between them from the base-layer dropdown.
The auto-generated style is generic. Power users with a custom MapLibre style JSON can point it at pmtiles://<fileName>. A "load custom style" picker is planned for a future version.

Controls & Compass

All map controls are accessible from the map canvas:

  • Zoom buttons (+/−) — bottom-right corner
  • Download map area — the offline-area button lives in the top bar, to the right of Point Info. Tap it to draw/drag a box and download that region. See Area Saver.
  • Compass rose — tap to reset rotation to North-up; long-press to enable heading-up mode (map rotates with your bearing)
  • Center crosshair — a fixed crosshair at the map center for precise location reading
  • Context menu — long-press the map anywhere for: drop marker here, start line from here, view point info, navigate here
Zoom in all the way: every base layer now keeps zooming to z22. Past a source's native detail, the deepest tile is smoothly scaled up (overzoom) instead of the map blanking or locking — the same behaviour as Google/Organic Maps. Vector basemaps stay crisp at any zoom.

Night Mode

Tap the moon icon to cycle through three states: Off → Dim → Dark. Night mode applies a CSS filter to the map canvas to reduce brightness and blue light without affecting the UI. It works on all base layers and overlays and is preserved across sessions.

Coordinate Display

Your current GPS position is always shown in the bottom status bar. Choose your preferred format in Settings → Coordinate Format:

FormatExample
Decimal Degrees (DD)39.0835° N, 108.5601° W
Degrees Minutes Seconds (DMS)39°05'00" N, 108°33'36" W
Degrees Decimal Minutes (DDM)39° 05.010' N, 108° 33.606' W
UTM12S 741234 4328765
MGRS12SVJ4123428765

Tap any displayed coordinate to copy it to the clipboard.

UTM / MGRS grid overlay

Add the UTM / MGRS grid from the overlay picker (Layers → Add an overlay → Topographic / Reference) to draw a labelled military-style reference grid over any base map. The grid is computed in the view-centre UTM zone, redraws as you pan and zoom, and has its own opacity slider in the layer stack. Spacing scales with zoom (100 km → 10 km → 1 km) — the same grid lines underlie MGRS.


Markers & Waypoints

Drop a marker by long-pressing the map and selecting Drop Marker Here, or tap GPS marker to drop one at your current location. A details panel opens immediately.

Marker properties

  • Name — any text label
  • Notes — multi-line description or clue notes
  • Icon — choose from 50+ typed icons: trailhead, summit, camp, water, warning, medical, wildlife, and more
  • Color — full color picker with preset swatches
  • Folder — assign to any existing folder
  • SAR attributes — mark as clue, assign to operational period, set find status

Tap an existing marker to open its info card. From there: edit, delete, navigate to, copy coordinates, or zoom-in.

Averaged waypoint

For higher-accuracy waypoints, use Add → Average WP. Hold the device still over the point while it collects continuous GPS fixes; the dialog shows a live sample count, the running average position, and an estimated ± accuracy that tightens as samples accumulate (random error falls off as roughly √N). Tap Save waypoint to drop a marker at the averaged position, tagged with the sample count and accuracy.

Lines & Routes

Tap Draw in the top bar then select Line. Tap the map to place vertices. A live distance display shows the running total. When done, tap Finish.

  • Each line segment shows distance in your preferred units
  • Edit mode: drag existing vertices, insert new ones, or delete
  • Assign name, color, and folder
  • Free Draw — open Tools → Free Draw and drag on the map to sketch. Each stroke saves immediately as a line you can rename, recolour, or move later. Works with touch or mouse; pinch-zoom still works mid-sketch. Press Escape to exit the tool.

Elevation profile

Open any line, track, or route and tap Elevation in its editor to see a CalTopo/Gaia-style elevation profile: the path is resampled evenly by distance, elevations are fetched, and a profile chart is drawn with total distance and gain / loss / min / max in your preferred units. Needs a connection to fetch elevation data.

Polygons & Areas

Select Polygon in the Draw menu. Tap to place polygon vertices; the shape closes automatically when you tap Finish. Area is calculated and shown in the info panel (acres, sq km, sq mi).

Buffer zones — tap an existing object and choose Buffer Zone to create a polygon offset by a specified distance.

Sectors / wedges — from the context menu choose Draw Sector to create a pie-wedge search area with configurable bearing and radius.

Highlight — select Highlight in the Draw menu to shade an area with a translucent fill (preset colours or a custom colour). Use it to mark a search segment, a hazard zone, a planned burn, or an area of interest without hiding the map underneath.

Circle — open Tools → Circle, press at the centre point and drag outward. A live radius readout appears while you size it; release to name and save. Saved as a buffer-style polygon you can edit like any other shape.

Range Rings

Long-press a marker or tap More → Range Rings to draw concentric search rings around a point. Configure:

  • Number of rings — 1 to 10
  • Spacing — distance between each ring (meters or feet)
  • Custom radii — override with specific values per ring
  • Bearing offset — rotate the ring set for directional probability

Range rings are stored as a single object and can be edited or deleted from the Objects panel.

Folders & Organization

Open More → Folders to manage folders. Folders help group objects by purpose — e.g., "Day 1 Search", "Water Sources", "Hazards".

  • Create folder with name and color
  • Assign any marker, line, or polygon to a folder
  • Toggle folder visibility — hides/shows all objects inside on the map
  • Export a single folder as GeoJSON

Operational Periods

Open More → Operational Periods. Operational periods (OPs) are standard SAR planning containers — typically one per 12 or 24 hour shift.

  • Create an OP with a name, start time, and active status
  • Assign any map object to an OP
  • Objects belonging to an inactive OP can be filtered out of view
  • Export a single OP as GeoJSON
For ICS-compatible SAR operations, create one OP per official operational period and assign all that period's assignments and finds to it before exporting for the Situation Unit.

GPS Modes

The GPS button (bottom-right of map) cycles through three states:

  1. Off — no GPS. Button is gray.
  2. Follow — map re-centers on your position every few seconds. Button is blue.
  3. Heading-up — map rotates to match your bearing AND follows your position. Button is blue with rotation indicator.

An accuracy circle is displayed around your position marker. In poor GPS conditions (dense canopy, canyon) the circle will be large — always verify position against known landmarks.

By default ZYRNTOPO uses the device's built-in GPS. Accuracy depends entirely on hardware quality and sky visibility. Always carry a dedicated GPS device as backup on critical operations.

External GNSS receivers New in 1.15.3

A separate receiver — USB or Bluetooth — can be used instead of the phone's own GPS. Once connected it becomes the position source for everything: the live dot, track recording and anything shared with your team. Disconnect it and the device GPS takes over again.

Receivers are read over NMEA 0183, which is what essentially every GNSS unit speaks. Sentences are checksum-verified, and anything that fails is discarded rather than guessed at.

ConnectionWhere it works
USB serialAndroid
USB serial (Web Serial)Windows, Linux, Chrome/Edge
Bluetooth LEAndroid app, Chrome/Edge on desktop
Baud rate is detected for you. Serial receivers vary — 9600 and 38400 are both common — and the wrong rate looks exactly like a broken receiver. Auto-detect tries each in turn and tells you which of three things happened: it found the rate, something is connected but is not sending NMEA, or nothing is arriving at all.
Classic Bluetooth (SPP) receivers are not supported. Many units sold as "Bluetooth GPS" use classic SPP/RFCOMM, which the app cannot open — use a USB connection with those. Bluetooth support here is BLE, which is what newer receivers and serial-to-BLE adapters use.

On Android, a USB receiver and a MAVLink drone link cannot both be connected at once: there is one USB port and connecting a receiver disconnects the drone.
No accuracy circle is drawn for an external receiver. NMEA carries no accuracy figure — only satellite geometry (HDOP), which is not the same thing. Rather than invent a number, the app shows none. Satellite count, fix quality and HDOP are shown instead, and a fix that stops arriving is reported rather than left frozen on screen.

Track Recording

Tap the track icon (footprints, top bar) to start recording. A live track is drawn on the map in real time. The track bar shows live stats:

  • Distance traveled
  • Elevation gain (from GPS altitude)
  • Elapsed time
  • Average speed (mph or km/h)

Tap Stop to end the recording. A trip summary (distance, elevation gain, duration, and max speed) is shown, and the track is saved as a line object in your map objects list — exportable as GPX or GeoJSON, and you can open its elevation profile from the editor.

Measurement Tools

Tap More → Measure to open the measure tool. Tap points on the map to build a measurement path:

  • Point-to-point distance with running total
  • Bearing between last two points
  • Area measurement — close the shape to get area in acres or sq km
  • Elevation profile — tap two points to see the elevation chart along the path
  • Viewshed / line-of-sight — tap an observer point and the tool shades the terrain that's visible from it in green, computed on-device from the DEM. Set observer and target height, a maximum radius, and an optional radio (4/3-earth) mode that models radio-horizon bending for comms planning. Distances and areas report in your chosen units. Great for siting repeaters, lookouts, or signal mirrors.

Measurement results appear in a card directly on the map and stay visible while you keep working, so you can read them without reopening the Measure panel. Tap the card's × to dismiss the result and clear the drawing.

Tap Clear to reset without saving the measurement.

Field Navigation Shortcuts

ZYRNTOPO includes a few quick actions for search and field coordination without adding military-specific marker sets or fire-planning workflows.

  • Heading Line: open Tools → Heading Line while GPS/compass is active to create a bearing line from your current position and current heading. Choose a length such as 500 m, 1 km, 0.5 mi, or 1000 ft, then save or edit the line.
  • Bearing Sight: open Tools → Sight for a fullscreen compass HUD. It shows the live azimuth your device is pointing — toggle between magnetic and true (local declination is applied), with mils, a rotating compass card, and a vertical inclination angle. Tap Shoot azimuth → map to drop a bearing line from your GPS fix (or the map centre). It runs entirely from the device's compass and accelerometer, so it works fully offline and needs no camera.
  • Share Loc: open Tools → Share Loc to share your current GPS position, or the map center if GPS is off. The share card includes DD, DMS, UTM, MGRS coordinates, and a map link.
  • Share point: long-press any location and choose Share point to send a clean coordinate card for that exact point.

Offline Maps (PMTiles)

The primary offline mechanism is a real .pmtiles archive stored as a single file in the device's Origin Private File System (OPFS). This replaces fragile per-tile browser caching with something that survives storage pressure, works across reloads, and can be transferred to teammates as a single file.

What you can save offline

ZYRNTOPO only pre-downloads tiles from sources whose terms permit offline storage. That's a deliberate choice — it keeps you compliant and avoids provider rate-limit / IP blocks. Here's exactly what the Area Saver (and Save Terrain) can save:

Saveable for offline use
  • USGS The National Map (US-government public domain, attribution requested) — USGS Topo, USGS Imagery, USGS Imagery + Topo, and the USGS Topo & Hydrography overlays.
  • Vector basemap — VersaTiles Eclipse (the app default).
  • 3D terrain elevation — Mapterhorn DEM, via Save Terrain in the same view.
  • Computed layers — slope angle, slope aspect, and the UTM/MGRS grid render on-device from the saved DEM, so they work offline automatically (nothing extra to download).
  • Any imported .pmtiles — raster or vector archives you bring yourself (Protomaps, OpenFreeMap exports, your own builds).
Online-only — view while connected; these providers' terms forbid bulk/offline tile download, so the Area Saver skips them:
  • OpenStreetMap, OSM Humanitarian, CyclOSM, OpenTopoMap — their tile policies explicitly prohibit "save area for offline."
  • Esri ArcGIS — Esri Satellite, World Topo, World Physical, Ocean, USA Topo (Historical), NatGeo World, hillshade, reference labels, boundaries, transportation (Esri requires its own export service + subscription).
  • Community overlays — Waymarked hiking / cycling / MTB, OpenSeaMap, OpenSnowMap pistes, OpenRailwayMap, OpenInfraMap power.
  • Government services — BLM Public Lands & PLSS, USFS Motor Vehicle Use & ownership, NWI wetlands, US Census tribal lands / reservations, and USGS contours & watersheds (render-on-demand dynamic services).
  • Live / time-sensitive — NEXRAD weather radar, NIFC active wildfires, avalanche forecast.
For anything online-only, import a provider-approved .pmtiles of the same data via Settings → Offline Maps → Import.

Why PMTiles instead of a tile cache?

  • Single file — random-access via byte ranges. No millions of Cache API entries.
  • Eviction-resistant — when the browser grants persistent storage, the saved file isn't dropped under storage pressure.
  • Sneakernet-friendly — copy a 500 MB regional archive over USB, share it over LAN.
  • Same format used by Protomaps, OpenFreeMap, BBBike — bring your own archive from any of these sources.

What ships with the app

The POI dataset is a PMTiles archive of its own, and the two app families get it differently:

  • Desktop (Windows, Linux) — the archive is inside the installer. Points of interest are searchable offline from the moment the app is installed, with nothing to fetch. It is also why those installers are ~670 MB.
  • Android — the archive is not bundled. At roughly 340 MB it is well past Google Play's base-download limit, so the APK stays around 9 MB and the app range-reads the archive from pois.zyrntopo.com on first run, streaming a copy into device storage in the background. The first launch therefore needs a connection for POI search; every launch after it does not.

No bundled basemap archive is included on any platform — on first launch the default basemap is VersaTiles Eclipse (hosted vector, requires network until cached). To take the map offline, either use the Area Saver to build your own archive, or import any .pmtiles file you have.

Importing a PMTiles file

  1. Open Settings → OFFLINE MAPS (.PMTILES).
  2. Tap Import .pmtiles and pick a file from your device.
  3. The bytes stream-copy into OPFS (multi-GB safe), the header is parsed, and the archive appears in your list with bounds, zoom range, and tile format.
  4. Switch to it in the base-layer dropdown under My Maps (offline). Disable Wi-Fi to confirm — tiles render straight from the file.

Recommended sources: Protomaps, OpenFreeMap, BBBike Extract, or any tippecanoe / pmtiles convert output.

Area Saver — Build Your Own Archive

The Area Saver downloads tiles for the current viewport at any zoom range and packs them into a brand-new .pmtiles archive on your device. This is the answer to "all zoom levels for my area" — without trying to bundle the whole world.

How it works

  1. Pan the map to your area of interest (a county, a search grid, a trail system).
  2. Open Settings → OFFLINE MAPS → Select Area, or tap the offline-tiles button on the map toolbar. A green selection box appears on the map.
  3. Drag the box to reposition and pull any corner to resize, then set the min and max zoom. A live estimate shows tile count + size.
  4. Tap Download Area → confirm the download size → progress bar shows tiles downloaded and bytes written.
  5. The archive lands in your My Maps list automatically, ready to use.

Select Area on Map

For a precise, Google-Maps-style selection, open Settings → OFFLINE MAPS → Select Area. A green box appears on the map — drag it to reposition and pull any corner to resize until it frames exactly the area you want. Pick the min/max zoom (the panel shows a live tile-count and size estimate), then tap Download Area. It downloads that exact rectangle of the current base map — raster or vector — into a .pmtiles archive via the same background, resumable engine.

Download by Region

Don't want to frame the viewport by hand? Open Settings → OFFLINE MAPS → Download Offline Map → Browse and tap Pick a region to download. Choose a preset continent, country, or mountain range, pick the zoom range with a live size estimate, and the current base map is saved for that whole area — no panning required. The same sheet also lists ready-made packages and accepts a direct .pmtiles / .mbtiles URL.

  • Preset regions — continents, countries, and mountain ranges with one-tap download
  • Per-download zoom — choose min/max zoom with a live tile-count & size estimate before you commit
  • Background & resumable — downloads run in the background and resume automatically after an app restart, so a dropped connection or a closed app won't lose progress

Why it scales

  • Streaming write — tiles go straight from the network to OPFS via the FileSystemWritableFileStream. The app never holds the whole archive in memory, so multi-GB regions are safe even on phones.
  • SHA-1 deduplication — identical tiles (ocean, forest canopy, blank areas) are stored once. Typical savings: 30–50% on raster regions with lots of homogeneous terrain.
  • Cancellable — tap Cancel and the partial OPFS file is removed cleanly.
  • Polite — concurrency capped at 4 simultaneous fetches.

Realistic sizing

RegionZoom rangeTilesApprox size
Single county (~50 × 50 km)z6–z14~10K~120 MB
Single countyz6–z16~50K~600 MB
Single statez6–z14~80K~1 GB
Single statez6–z16~400K~5 GB
Bundling every zoom level for the entire world isn't physically possible — z0–z18 worldwide is ~91 billion tiles (~250 TB). The Area Saver is the answer: pick the area you actually need, save it once on Wi-Fi, then go offline with all zoom levels for that region.
What can be saved offline: ZYRNTOPO only pre-downloads sources whose terms allow it — USGS The National Map (public domain) and the VersaTiles vector basemap. Esri ArcGIS, OpenStreetMap, OpenTopoMap, CARTO, and NatGeo are online-only (their terms forbid bulk/offline tile download), and CORS-restricted overlays (Waymarked Trails, BLM, USFS) can't be read by the browser for download anyway. For any of those, import a provider-approved .pmtiles via Settings → Offline Maps → Import. The downloader also backs off automatically if a server signals overload (HTTP 429/503) and sends an identifying User-Agent, to stay a good citizen.

Online Tile Cache (Fallback)

For tiles you happen to view while online — outside any imported PMTiles archive — the service worker still caches them to Cache API storage as a stale-while-revalidate fallback. This is a secondary mechanism; PMTiles archives are the canonical offline source. Opportunistic caching is deliberately restrained so it stays responsive without overwhelming low-end devices.

  • All base layers and overlays cached independently while online
  • Cached tiles continue to serve when the connection drops
  • DEM terrain tiles (for 3D) cached alongside base layers
  • Retina-aware: {r}=@2x URL substitution matches between cache write and live <img> request
  • Viewport preloading warms nearby tiles and adjacent zoom levels, with deduped and bounded queues
  • Cached parent tiles are used as fallback when exact tiles are unavailable, including opaque no-CORS tiles served back through the service worker
  • When connectivity returns, visible tile layers redraw and run a light re-warm pass
  • Subject to OS storage-pressure eviction (which is exactly why PMTiles archives are the canonical mechanism — they're not evicted)
The cache is intentionally aggressive but bounded. It preloads enough around the viewport to make panning and zooming feel smooth, then yields during large batches so lower-end Android devices stay responsive.

Storage Management

Open Settings → OFFLINE MAPS for archive management, or scroll down for online-cache controls:

  • My archive list — name, size, zoom range, and tile format per imported archive. Each row has Use / Remove buttons. Bundled archives are flagged.
  • Tile cache size — total storage used by the online fallback cache
  • Clear tile cache — removes only the online cache; PMTiles archives are unaffected
  • Clear app cache — clears app-level cache (does not delete map objects or archives)
Map objects (markers, lines, polygons), PMTiles archives, and the online tile cache are all stored separately. Clearing one never affects the others.

Team Sync — Overview

Everyone in a session needs 1.13.4 or newer. From 1.13.4 the session code also derives the signalling encryption key, so a device on an older build derives a different key and never sees the session — there is no error message and no "wrong code" warning. Both sides sit in what looks like a working session with the other person simply absent, which in the field reads as "Team Sync is broken" rather than "somebody needs to update". Session codes are also 10 characters now (letters, digits and symbols) rather than 6. If a teammate cannot see you, check their version first.

Team Sync lets multiple ZYRNTOPO users share a live map — markers, lines, and polygons propagate to all connected peers in real time, along with each user's GPS position. Open via More → Team Sync. Team Sync and Team Chat are ZYRNTOPO Pro features; the free Team Members roster (contacts, roles, medical/emergency info) needs no account.

How it works

  • Objects are synced with a last-write-wins CRDT — the most recent edit always wins
  • GPS positions broadcast every 4 seconds; peers appear as colored circles on the map
  • Peer identity is ephemeral per session (no accounts)
  • Four transport modes are available depending on connectivity
ModeInternetLAN onlyOfflineRange
P2P (Nostr)Global
LAN HubLAN (~150m WiFi)
MAVLink DroneWiFi/serial

Team Sync — P2P (Internet)

Uses Trystero over Nostr relays — serverless peer-to-peer WebRTC. No server infrastructure to maintain; peers discover each other via public Nostr relay nodes.

  1. In Team Sync, enter your display name and role, pick a color.
  2. Select Internet / P2P mode (default).
  3. Enter or generate a 6-character session code and share it with teammates (QR code button or copy).
  4. Tap Join Session. Teammates enter the same code.
P2P mode requires each device to have an internet connection to reach the Nostr relay for peer discovery. The actual map data flows peer-to-peer directly after discovery — no relay stores your data.

Team Sync — LAN Hub

For use when all devices are on the same WiFi network — ideal for incident command posts and field ops with a local hotspot. Run relay.mjs on any Node.js device on the network:

node bridge/relay.mjs [port]
# Default port: 8080
# Example output:
#   URL:  ws://192.168.1.45:8080

In Team Sync, switch to Advanced → LAN Hub transport and enter ws://<relay-ip>:8080.

Bonus: relay.mjs automatically joins the ATAK CoT multicast group (239.2.3.1:6969). If SoRD or any ATAK-compatible device is broadcasting detections on the same network, they flow to all connected ZYRNTOPO clients with no extra configuration.

Team Sync — TAK Server

A TAK server is shared infrastructure: everyone connected to the same one sees each other's positions, markers and chat, whether they are running ZYRNTOPO, ATAK or WinTAK. This is different from the ATAK CoT bridge below, which is a LAN multicast link with no server involved.

Connecting

Team Sync → TAK server. It is pre-set to the ZYRNTOPO server, but nothing is sent until you press Connect — joining a server publishes your position to everyone else on it, so it is never done automatically, and it does not resume by itself the next time you open the app.

Choose My own TAK server to point at your own instead, and enter the address your server printed during setup.

Your server and the ZYRNTOPO server are independent. Connecting to one does not disconnect the other, and positions are not forwarded between them. Being connected to both means your position reaches both — if you want your team's data to stay entirely on your own hardware, use only your own server.

Running your own

Free, open, and everything stays on hardware you control. It runs on a Raspberry Pi 4 (4 GB+) or better, any spare 64-bit Linux box or Mac, a small VPS, or Windows 10 and 11. A 32-bit Raspberry Pi OS will not work — several dependencies ship no armv7 wheel.

One command downloads the kit, checks it and runs the installer. You do not need Docker first: the installer adds it — Docker Engine on Linux, colima on macOS, Docker Desktop on Windows — and starts it.

curl -fsSL https://zyrntopo.com/tak-server.sh | sh

On Windows, in PowerShell:

irm https://zyrntopo.com/tak-server.ps1 | iex

To pass the installer an option, give sh a -s -- first — for example to publish it over Tailscale with TLS rather than LAN-only:

curl -fsSL https://zyrntopo.com/tak-server.sh | sh -s -- --tailscale

A script piped into iex has no arguments, so Windows passes them in the environment instead:

$env:ZYRN_TAK_ARGS='-ExposeAdmin'; irm https://zyrntopo.com/tak-server.ps1 | iex

It unpacks to ~/zyrntopo-tak-server. Set ZYRN_TAK_DIR to change that, or ZYRN_TAK_NORUN=1 to unpack without installing. Requirements, the port table and manual verification are on the TAK server page.

Or download the kit and run it yourself — zyrntopo-tak-server-1.15.10.tar.gz (42 KB) · .zip for Windows, which unpacks the same kit and runs the PowerShell script instead:

tar xzf zyrntopo-tak-server-1.15.10.tar.gz
cd zyrntopo-tak-server-1.15.10
./setup-tak-server.sh

The script generates its own secrets, builds and starts OpenTAKServer, creates the certificate authority, provisions everything it needs, and then tests the whole path end to end before telling you it worked. Windows uses .\setup-tak-server.ps1 with the same switches in PowerShell form — -Status, -Stop, -Uninstall, -ExposeAdmin. Re-running it is safe and is the normal way to repair a stack — existing secrets, the CA and issued certificates are never regenerated.

Verify what you downloaded. SHA-256:
d2ce1b5feba9d08463a7b54e4f4a1d9db4368f606409b56ad164570490b4e32e  .tar.gz
af7796e931a96990ba806c871d8da94cbe1e6e499c8d82bd0983d8e0ce83e20f  .zip
sha256sum zyrntopo-tak-server-1.15.10.tar.gz on Linux/Mac, or Get-FileHash on Windows. The curl | sh path above does this for you and refuses to run on a mismatch.

Ports — note that ZYRNTOPO uses 8090, not 8089

A TAK server's client port (8089) is raw TLS over TCP. ZYRNTOPO speaks WebSocket rather than raw TCP on every platform it ships for — browser, Android, Fire OS and desktop. The stack therefore includes a small WebSocket relay on 8090 that sits in front of 8089, and that is the address ZYRNTOPO connects to. ATAK and WinTAK are unaffected and connect straight to 8089 as usual.

PortUsed byOpen by default
8090ZYRNTOPO (WebSocket relay)Yes
8089ATAK / WinTAK (TLS CoT)Yes
8443Certificate enrolment for ATAKYes
8081Admin web UILocalhost only
Before exposing it to the internet. The relay speaks plaintext ws:// and has no authentication of its own — anyone who can reach port 8090 can join your server's feed. Keep it on a VPN such as Tailscale or WireGuard, or put it behind a reverse proxy with TLS. A proxy is also required if your team uses the ZYRNTOPO web app: a browser refuses a ws:// connection from an https:// page, so the address has to be wss://. The installed desktop and Android builds do not have that restriction.

If everything looks healthy but nobody sees anybody

This is the failure worth recognising, because nothing reports an error on either side. OpenTAKServer is three separate processes, and if the one that distributes events is not running, clients connect, authenticate and register perfectly while no position ever arrives. Check all services are up with docker compose ps, and re-run the setup script — it repairs this and reports whether the end-to-end test passed.

Team Sync — ATAK CoT / SoRD Detections

ZYRNTOPO talks ATAK Cursor on Target (CoT) over UDP multicast both ways. Your team members appear on any ATAK device on the same LAN, and inbound detections from ATAK or the SoRD SAR drone platform appear on your map as color-coded markers in real time.

Bidirectional behavior

  • Outbound: when ZYRNTOPO is connected to atak_bridge.py, every GPS update is converted to a CoT 2.0 PLI event of type a-f-G-U-C-I (friendly · ground · unit · civilian · individual) and broadcast to 239.2.3.1:6969. Each peer gets a stable UID (ZYRN.<peerId>) so ATAK updates the same marker rather than spawning new ones, with a 60-second stale attribute so dropped peers auto-clear.
  • Inbound: incoming CoT events are parsed for position, callsign, remarks, detection scores, CoT type, color argb, and the original stale timestamp. Scores are read from the structured <sord> detail block when present and from the legacy remarks text otherwise — see the detection contract. All scores are displayed on the marker label and detection list. A sweeper auto-removes detections after their stale time elapses (default 120 s), matching ATAK's own behavior.

The detection contract

Detection scores used to be recovered by string-matching the CoT <remarks> text for Conf: XX%, Thermal: 0.XX and ArcFace: 0.XX. That worked, but it was an interface with no schema and no failure signal: if the sender renamed a label, ZYRNTOPO simply stopped populating that score, and nothing on screen distinguished “no score was sent” from “the parser stopped matching”. On a life-safety marker that is not an acceptable failure mode — and it was not hypothetical, since SoRD has already replaced ArcFace face embeddings with appearance/re-ID embeddings (at SAR altitudes a face is rarely resolvable).

Structured values now travel in a <sord> element inside <detail>. Stock TAK clients ignore unknown detail elements, so this is invisible to ATAK, and <remarks> stays human-readable for them:

<detail>
  <contact callsign="SoRD-D-0147"/>
  <sord schema="1">
    <mission id="2026-08-03-A" op="1"/>
    <score type="thermal"   value="0.72"/>
    <score type="reid"      value="0.88"/>
    <score type="composite" value="0.85"/>
    <thresholds autoalert="0.85" review="0.60"/>
    <source sensor="lepton35" mode="night" altitude_agl_m="48"/>
    <tier value="auto"/>
  </sord>
  <remarks>SoRD detection · Conf: 85% · Thermal: 0.72 · ReID: 0.88</remarks>
</detail>
  • Structured wins, remarks are the fallback. ZYRNTOPO prefers the block, falls back to remarks parsing when there is none, and records which path produced the numbers. The remarks parser is kept indefinitely rather than retired — field kits are imaged once and then live in a case for a year, so assume an older image is always in circulation somewhere.
  • Thresholds travel with the detection. The auto-alert and review cutoffs are the sender's to choose. Carrying them per-event means retuning them on the sensor side moves the marker colours to match, instead of silently making them wrong.
  • Unknown schema versions warn, they do not fail. A newer schema than this build knows is reported and the recognised fields are still rendered — refusing to draw an event that has perfectly good coordinates would be the worse outcome.
  • Detection UIDs are matched case-insensitively. The soRD- prefix is a wire-format value rather than a label, so a client holding soRD-0147 that begins receiving SoRD-0147 would otherwise render both and leave the stale one on the map until it expired — duplicate pins at one coordinate, mid-incident.

GeoChat & markers

  • GeoChat bridging (both ways): messages you send in Team Chat are emitted as ATAK GeoChat (b-t-f) CoT, so they land in ATAK's chat window; inbound ATAK GeoChat appears in ZYRNTOPO chat tagged (ATAK). Works even in an ATAK-only session with no P2P/LAN room. Echo of your own messages is suppressed via a BAO.F.ZYRN. source tag.
  • Affiliation classification: inbound events now carry an affiliation (friend / hostile / neutral / unknown) derived from the CoT type, so friendly units, neutral contacts and placed markers — not just hostile detections — are distinguished on the map.
  • Outbound markers: the bridge accepts cot_marker frames and emits them as CoT marker events, so a ZYRNTOPO-placed point can be pushed to ATAK.
  • TAK Protocol "Mesh SA" framing: the bridge unwraps the 0xbf TAK Protocol header. Version 0 (CoT XML) is decoded; version 1 (protobuf) is detected and skipped with a one-time notice — set ATAK to "CoT XML" output, or route protobuf mesh through a TAK server.

Detection confidence colors

Marker colour comes from the confidence score, against the thresholds carried on the event (defaulting to 0.85 / 0.60):

  • Red circle — at or above the auto-alert threshold (default ≥85%)
  • Orange circle — between the two thresholds (default 60–85%) — manual verification needed
  • Slate circle — below the review threshold — logged, low confidence
  • Friendly circle — a friendly CoT type (a-f-*), i.e. a PLI / teammate position rather than a detection
Why colour is not taken from CoT affiliation. Detections were previously coloured by the affiliation character in cot_type — red or orange for a-h-* (“hostile”), blue otherwise. SoRD emits a-h-G, a placeholder carried over from early development, and a missing person is not a hostile contact: on a multi-agency incident an outside ATAK operator seeing that would reasonably filter it as a threat track. The fix on the sensor side is to retype detections to something honest such as a-u-G — but under the old rule that retype would have pushed every detection into the friendly branch and drawn it in the same blue as a searcher's own position marker. Colouring from confidence instead means each side can change its CoT typing independently; a-h-* still renders exactly as before.

Automatic connection

The app silently tries ws://localhost:4243 at startup; if the bridge is running on the same machine (or you enter its LAN address), it connects automatically and your team starts publishing CoT.

Manual setup

pip install --user --break-system-packages websockets
python3 bridge/atak_bridge.py --ws-port 4243 --mcast-group 239.2.3.1

In Team Sync, expand ATAK CoT Detections (SoRD) and enter ws://<bridge-ip>:4243.

If you're using the LAN relay (relay.mjs) instead, CoT detections flow automatically as before — but outbound CoT requires atak_bridge.py.

Wired SoRD / ATAK notes

  • If the cable/adapters create a network interface (USB Ethernet, USB tethering, or a radio gateway with IP), keep using atak_bridge.py and enter the bridge address in ZYRN.
  • If the private SoRD stack emits CoT XML over raw serial, the Android APK can read it directly through ATAK detections (SoRD) → Connect USB / Serial. Auto-detect scans supported adapters and common baud rates until valid CoT XML is seen; manual baud selection remains available.
  • If the serial source uses TAK protobuf or a proprietary framing format, route it through a small adapter that emits standard CoT XML first.
  • ZYRNTOPO does not need SoRD internals. It only needs the resulting ATAK/CoT position, callsign, score, and remarks fields.

SoRD platform overview (v5)

SoRD is a compact SAR drone system. The v5 ground station adds a multimodal detection pipeline and per-mission folder management:

  • FLIR Lepton 3.5 thermal sensor → ESP32-S2 DAC → 5.8 GHz VTX video transmitter
  • MS2109 USB capture → YOLOv5n CPU inference → appearance / re-ID embeddings for match scoring
  • Composite score, thermal score and re-ID score reach ZYRNTOPO in a structured CoT detail block and are displayed separately on markers (see ATAK Bridge for the wire format)
  • Scores ≥0.85 trigger automatic ATAK CoT broadcasts to 239.2.3.1:6969 with a 120 s stale window
  • Scores 0.6–0.85 enter an operator review queue (also broadcast as CoT with lower score)
  • Per-mission folders: detections, crops, and thermal frames are saved under a timestamped mission directory; the operator review dialog lets you accept/reject each detection
  • WiFi latency ~260 ms; LoRa relay latency 3–30 s

Team Chat

Chat is available in any Team Sync session. Tap the chat bubble icon to open the chat panel. The input bar sits at the top of the panel so it remains visible when the keyboard is open on mobile.

  • Text messages — sent to all peers in the session
  • Voice messages — tap-and-hold the microphone button to record; release to send; tap to cancel
  • Chat history is stored locally for the current session
  • Unread message badge on the chat button
  • ATAK GeoChat bridge — when the ATAK bridge is connected, text chat is mirrored to/from ATAK's GeoChat, so ZYRNTOPO and ATAK users share one conversation.
GPS is automatically enabled (locked mode) when you join a Team Sync session, so your position broadcasts to teammates immediately without an extra tap.

SAR — Operational Periods

Open More → Operational Periods. Each period represents a planning cycle (typically 12 or 24 hours) in a search operation. Assign map objects to periods to keep your map organized and filterable.

  • Name, start date/time, and status (active/inactive)
  • Toggle visibility of all objects in a period with one switch
  • Export a period's objects as GeoJSON for incident documentation

SAR — Resources & Roles

Open More → Team Members. Each member has:

  • Name and role (team leader, member, medical, air, K9, logistics, etc.)
  • Contact info — radio channel, phone, callsign
  • Assignment — linked to an operational period

Import/export team rosters as JSON or CSV for briefings and ICS documentation.

SAR — Gear / Pack List

Open More → Gear. Build a complete inventory of field equipment with:

  • Item name, category, quantity, weight
  • Total pack weight summary
  • Category grouping (shelter, navigation, medical, comms, etc.)
  • Export as JSON or CSV for logistics planning
  • Import from a previous operation's export

SAR — Clue & Find Logging

Any marker can be flagged as a clue or find using its SAR properties panel:

  • Type: Physical clue, track, trail, scent, shelter, victim found, deceased
  • Description: Detailed notes
  • Time found: Timestamp recorded automatically
  • Assigned to OP: Links to an operational period
  • Photo: Attach a camera photo directly to the clue marker

All clue markers are also exported in GeoJSON with full SAR metadata intact.


Import & Export — GeoJSON

GeoJSON is ZYRNTOPO's primary exchange format. All map objects are serialized to standard GeoJSON with ZYRNTOPO-specific properties in the properties object.

Export

Open More → Export → GeoJSON. Choose to export all objects, a specific folder, or a specific operational period. The file saves to the device's Downloads folder or triggers a browser download.

Import

Open More → Import and select a .geojson or .json file. Objects are merged into the current map — existing objects with the same ID are updated; new IDs are created. Drag-and-drop import is supported in the web app.

Import & Export — KML / KMZ

Export to KML for use in Google Earth, CalTopo, or other GIS tools. KMZ (zipped KML) is also supported for import. Exported KML includes point placemarks, line strings, and polygon features with names and color styling.

Import & Export — GPX

GPX export is ideal for loading routes onto dedicated GPS devices (Garmin, Suunto, etc.). Waypoints export as <wpt> elements; recorded tracks export as <trk>. GPX import reads both <wpt> and <trk>/<rte> elements.

Import & Export — CSV

CSV export writes all marker coordinates and properties to a spreadsheet-compatible file — useful for importing into ArcGIS, QGIS, or sharing data with agencies that use spreadsheet-based tools. Team member rosters and gear lists also export/import as CSV.


Bridge Tools — LAN Relay (relay.mjs)

The LAN relay is a lightweight Node.js WebSocket server for offline LAN-only Team Sync. It requires no internet connection — just Node.js installed on any device on the network.

# Start on default port 8080
node bridge/relay.mjs

# Custom port
node bridge/relay.mjs 9000

The relay prints its WebSocket URLs on startup. Share one with teammates for the LAN Hub URL field in Team Sync. The relay also automatically listens on UDP multicast 239.2.3.1:6969 for ATAK CoT detection events and forwards them to all connected clients — no separate bridge needed.

Run the relay on a laptop acting as a mobile hotspot. Android devices can connect to the hotspot and sync maps without any internet service.

Bridge Tools — ATAK CoT Bridge (atak_bridge.py)

A bidirectional CoT ↔ ZYRNTOPO bridge. Receives ATAK / SoRD detections from UDP multicast and forwards them as JSON to the WS client; in the other direction, it accepts position frames from ZYRNTOPO over WS and re-broadcasts them as proper CoT XML so any ATAK device on the LAN sees your team.

pip install --user --break-system-packages websockets
python3 bridge/atak_bridge.py [--ws-port 4243] [--mcast-group 239.2.3.1] [--mcast-port 6969]

ZYRNTOPO automatically tries ws://localhost:4243 at startup, so if the bridge is running on the same machine it connects automatically.

Outbound CoT (ZYRNTOPO → ATAK)

Position frames received from ZYRNTOPO — in either {t:'pos', d:{name, lat, lng, …}} or {type:'cot_pos', …} shape — are converted to a CoT 2.0 PLI event and sent to 239.2.3.1:6969:

<event version="2.0" uid="ZYRN.<peerId>" type="a-f-G-U-C-I"
       how="m-g" time="..." start="..." stale="+60s (peers)">
  <point lat="..." lon="..." hae="..." ce="9999999" le="9999999"/>
  <detail>
    <contact callsign="..."/>
    <__group name="Cyan" role="Team Member"/>
    <precisionlocation altsrc="GPS" geopointsrc="GPS"/>
    <remarks>ZYRNTOPO</remarks>
  </detail>
</event>

The stable per-peer UID prevents marker duplication on the ATAK side; the 60-second stale means peers auto-clear when they go offline. Echoes of our own outbound frames are filtered on the inbound listener.

Inbound CoT (ATAK → ZYRNTOPO)

Incoming CoT XML is parsed for:

  • Event UID (prepended with soRD-) — stable across updates
  • Position from <point lat lon hae />
  • Callsign from <detail><contact callsign="…"/>
  • Remarks text — full string forwarded to client
  • Composite confidence — from <score type="composite"/>, or Conf: XX% in remarks on a legacy sender
  • thermal_score — from <score type="thermal"/>, or Thermal: 0.XX in remarks
  • rgb_score — from <score type="reid"/>, or the legacy ArcFace: 0.XX remark
  • thresholds, tier, source, mission — structured block only; absent on a legacy sender
  • color — from <color argb="…"/> element
  • cot_type, cot_time, stale, how — passed through to the client

The client honors the stale timestamp (SoRD v5 detections use a 120 s window) and a sweeper auto-removes expired markers every 5 seconds. Marker colour is set from the detection's confidence score against the thresholds on the event; cot_type is used only to recognise a friendly track (a-f-*), which is a teammate position rather than a detection.


Settings — Coordinate Formats

Open More → Settings → Coordinate Format and choose from DD, DMS, DDM, UTM, or MGRS. This affects all coordinate displays: status bar, point info panel, marker info, and CSV export.

Settings — Units & Display

  • Distance — Imperial (miles, feet) or Metric (km, meters)
  • Area — Acres or Hectares
  • Elevation — Feet or Meters
  • Speed — mph or km/h
  • Map title — appears on PDF prints
  • Default base layer — layer shown on first load (default: VersaTiles Eclipse)
  • Terrain exaggeration — 3D mesh vertical scale (1.0–3.0×)

Accessibility

  • Glove mode — enlarges buttons and touch targets across the app for use with gloves or in motion.
  • Colour-blind-safe palette — switches the computed terrain layers (slope angle / aspect) to a colour-blind-friendly ramp; legends update to match.
  • Night mode — a separate 3-state map dimming / red-light cycle (see Night Mode).

Map display toggles

Declutter the map by hiding chrome you don't need — each is an independent on/off switch under Settings → Map display:

  • Coordinates readout, live weather widget, scale bar, the on-map control buttons, and the Tools button can each be shown or hidden.
Offline-aware interface: features that need a connection — trail routing, the live weather widget, and the avalanche / wildfire / weather-radar overlays — hide themselves automatically when you go offline and reappear when you reconnect, so the layer picker and map only show what actually works right now.

Automatic persistence

The following state is saved automatically and restored on every launch — no manual action required:

  • Base layer selection — the active basemap (raster, vector, or imported archive) is remembered; the same layer loads on the next launch.
  • Map position & zoom — the last map center and zoom level are saved (debounced 1.5 s after panning stops) and restored on launch.
  • Hillshade opacity — the global relief opacity slider value is preserved across sessions.
All settings are stored in IndexedDB via localForage under the key zyrntopo_settings. Clearing site data in your browser will reset them to defaults.

Settings — Permissions

On first launch all permissions are requested via native dialogs. If you denied a permission and need to re-enable it:

  • Android: Settings → Apps → ZYRNTOPO → Permissions
  • Web / Chrome: Click the lock icon in the address bar → Permissions

Permissions used by the app:

PermissionUsed forRequired
Location (Fine; background optional while recording)GPS tracking, position broadcasts, track recordingFor GPS features
MicrophoneVoice messages in Team Chat (requested in-context)Optional
Storage (Android ≤9)Legacy file import/export (newer Android uses the system file picker, no permission)Optional
Photos are attached to waypoints through the system file / photo picker, so the app declares no camera permission. The app does not request Camera, Notifications, or Bluetooth permissions.

Platforms — Android

Minimum Android 7.0 (API 24), targeting API 36. One universal binary covers all four ABIs — armeabi-v7a, arm64-v8a, x86 and x86_64.

  • Install from Google Play, or the Amazon Appstore for Fire tablets and devices without Play
  • Every hardware feature the app touches — location, GPS, network location, microphone, USB host — is declared required="false", so no permission removes the app from a device that lacks that hardware. That is what keeps it installable on Wi-Fi-only Fire tablets, which have no GPS receiver
  • Location is foreground-only except while you are recording a GPS track, which may continue in the background with a persistent notification; location is never sent to ZYRNTOPO servers
  • The keyboard uses adjustResize so the chat input bar stays visible when typing
  • Production build hardening: app backup disabled and mixed content disabled; cleartext is intentionally allowed for operator-managed local ws:// field bridges such as LAN, MAVLink, and ATAK integrations
  • Release builds are signed; debug builds use .debug app ID suffix

Platforms — Web / PWA

The web version runs in any modern browser (Chrome, Firefox, Edge, Safari) at app.zyrntopo.com. Install as a PWA for an app-like experience:

  • Chrome / Edge: Click the install icon in the address bar
  • Firefox: Menu → Install as App (Firefox 128+)
  • iOS Safari: Share → Add to Home Screen

The PWA uses a Service Worker for offline-first operation — all app code and viewed tiles are cached automatically. No separate install or download manager needed.

GPS tracking runs while the app is open and in the foreground. When the screen locks or the app is backgrounded, the operating system pauses location updates — keep the screen on during active recording.

ZYRNMAPS — the native Android client

ZYRNMAPS is ZYRNTOPO rebuilt as a native Android application — Kotlin and Jetpack Compose over MapLibre Native 13.4.1, rendering with the GPU rather than inside a web view. It is a separate application from the cross-platform ZYRNTOPO app documented above, with its own package name (com.zyrntopo.maps) and its own store listing. Both can be installed side by side.

Which one should I use? The cross-platform app is the one to reach for if you want the same application on a phone, a laptop and in the browser, with everything behaving identically across all three. ZYRNMAPS is the one to reach for on Android specifically: it draws faster, holds a frame rate on low-tier hardware, starts quicker, and is the only client with the plugin system.

What native buys you

  • The map is drawn by MapLibre Native, not by a canvas inside a web view. Pan and zoom are handled on the render thread, so the map keeps moving while the app is busy doing something else.
  • Location comes from LocationManager directly, through a reference-counted feed and an explicit GPS state machine, rather than through a browser geolocation shim.
  • Storage is SQLite — a Room database holding the object store, the elevation cache, the archive catalogue, the download queue and each archive's attribution. Migrations run from the first schema upward, and each step is asserted on its own against real SQLite.
  • Plugins. Third-party APKs can extend the map, and that exists on no other client.

Requirements

ItemRequirement
Android versionAndroid 7.0 (API 24) or newer
Architecturearm64-v8a, armeabi-v7a, x86, x86_64 — one universal binary
GraphicsVulkan 1.0.3, required by MapLibre Native 13.4.1
Package namecom.zyrntopo.maps
The Vulkan requirement is a floor, not a preference. MapLibre Native 13.4.1 needs Vulkan 1.0.3, which rules out some older and lower-tier devices the cross-platform app runs on perfectly well. If ZYRNMAPS will not install, or shows a black map on a device that otherwise works, check this first — and use the cross-platform app on that hardware.

ZYRNMAPS — feature reference

Where behaviour differs from the cross-platform app, it is called out in place rather than left for you to discover in the field.

The map

  • Basemaps — the full built-in set, the imagery catalogue, and any .pmtiles archive you import. Contour interval is a menu rather than a fixed value.
  • Vector basemaps with on-device styling: contours, borders, trails, power lines and the rest restyle whichever basemap is loaded, and stay on when you switch.
  • 3D terrain from an on-device DEM, with viewshed and line-of-sight computed locally.
  • Overlays — terrain and topography, reference grids, route discovery, weather and hazards, and the US field-data layers.
  • Map labels follow the device language.
  • Night mode follows the device rather than being a separate switch inside the app.

Location and GPS

  • GPS modes — off, follow, and heading-up — behave as documented for the cross-platform app.
  • External GNSS receivers over three transports, with NMEA parsed on-device.
  • Track recording continues in the background, behind a location foreground service and a persistent notification, so a recording survives the screen locking. This is the clearest gain over the PWA, where the operating system pauses location updates as soon as the app is backgrounded.
  • Averaged waypoints, sight-and-track, and a bearing HUD with its own readout.

Objects, drawing and measurement

  • Markers, lines, polygons and recorded tracks, organised in folders, in a persisted object store.
  • Measurement: distance, area, elevation profile, range rings, buffers, heading lines and the bearing tool.
  • Point Info and feature identify — query what is under a tap, including rendered vector features.
  • The object edit dialog carries the full SAR block, a folder picker over the catalogue's folders, and a coordinate field that re-parses through five coordinate formats on every keystroke and moves the point only when one of them parses.

Search and rescue

  • Scent cone, route, clue logging, search periods, photo waypoints, viewshed, gear list and the team roster.
  • Assignments join a period through the search-period selector on the object edit dialog.
  • True-scale PDF map sheets print from the device.

Offline

  • The complete archive format, read and write, with a budget planner that tells you what a region will cost before you commit to it, a region plan, a catalogue of what you already hold, and resumable downloads scheduled through WorkManager.
  • DEM import for offline 3D terrain.
  • Attribution travels with each archive rather than being assumed.
  • A downloaded region renders on a cold start in airplane mode — that is the test it had to pass, not an aspiration.

Sharing and interchange

  • Share a saved map by QR code. Open a saved map's card and choose Show code; the other person scans it from their own Saved Maps panel. Everything travels inside the code, so it needs no signal, no account and no cable.
  • Import and export GeoJSON, GPX, KML/KMZ and CSV, both directions.
  • Data packages, for moving a whole working set at once.

Team Sync and the bridges

  • Team Sync sessions, Cursor-on-Target in both directions, the ATAK bridge, MAVLink drone telemetry, multicast and LAN ingest.
  • Connecting is the switch: joining a server shares your position, markers and chat with everyone on it, and nothing is sent until you press Connect.
ZYRNMAPS is verified on real hardware rather than only in emulation — a Galaxy A14 on Android 14 and a Galaxy Tab A11 on Android 16, the second running an unattended layout audit on every build. Between them those two devices have found eleven defects that no unit test could see.

ZYRNMAPS — installing and managing plugins

A plugin is an ordinary Android app that extends ZYRNMAPS. It ships as its own APK, installs the way any app does, and can add layers, panels, map objects, search results and context-menu actions. Building one is covered in the Plugin SDK below; this section is about running them.

Installing one

  1. Install the plugin's APK — from a store, with adb install, or by opening the file on the device.
  2. Open ZYRNMAPS ▸ MenuPlugins. The plugin appears in the list.
  3. Switch it on. Nothing runs until you do — a discovered plugin is off until a person enables it, with no allow-list that pre-approves anything.

What the Plugins screen tells you

The list shows every plugin found on the device, including ones ZYRNMAPS has decided not to run, each with its reason. That is deliberate: a plugin that is switched off and a plugin that is missing are different problems, and a screen that showed nothing for both would send you looking in the wrong place.

What it saysWhat it meansWhat to do
Not enabledFound, allowed, and waiting for youSwitch it on
Needs a newer ZYRNMAPSBuilt against a host API this version does not haveUpdate ZYRNMAPS
Built for an older ZYRNMAPSBuilt against a host API no longer supportedUpdate the plugin
No entry pointIts manifest names no plugin class, so there is nothing to loadReport it to whoever built it
Could not be loadedThe named class is missing, or is not a ZYRNTOPO pluginReport it, quoting the reason shown
Requires ProThe plugin declares itself Pro and this account has no entitlementUnlock Pro, or remove the plugin

Who signed it

Every row shows where the plugin came from, and ZYRNMAPS records the SHA-256 of its signing certificate either way.

LevelMeaning
First-partySigned with the same certificate as ZYRNMAPS itself — shipped by us, or built by somebody holding our key
Third-partySigned by somebody else. The fingerprint is shown, so you can check it against what the author published
UnknownThe signature could not be read at all. Rare, and it means the package manager refused — not that the APK is suspect
The signature is provenance, not a gate. ZYRNMAPS will load a third-party plugin. It reads the signature, records it and shows it, because who built something is worth knowing — but that is not what decides whether it runs. You are. A plugin runs code inside ZYRNMAPS with access to the map, your position and its own storage, so enable ones you have a reason to trust, and check the fingerprint when the author publishes one.

Turning one off

Switch it off in the same screen. Everything the plugin added — layers, sources, map objects, panels, its search provider, tap handlers and subscriptions — is removed by the host, whether or not the plugin cleaned up after itself, and whether or not its own teardown threw. Objects it drew are namespaced to it, so they go and nothing you drew goes with them.

Uninstalling the APK does the same, and also sweeps any objects it left behind.


Plugin SDK — overview

The ZYRNTOPO Plugin SDK lets you extend ZYRNMAPS with a separately built, separately signed Android APK. Your plugin can add map sources and layers, put objects on the map, contribute a panel, answer search queries, add context-menu actions, react to taps, post notifications and talk on the app's event bus.

This is not the ATAK plugin, and the two get confused constantly. The ATAK plugin is an APK that loads inside ATAK, lives in a separate build, and shares only the word "plugin". This page is about ZYRNTOPO's own extension surface, loading inside ZYRNMAPS. If you are looking to extend ATAK, you are on the wrong page.

The one design decision worth knowing first

Everything your plugin creates through the context it is handed is tracked by the host, and released when the plugin is deactivated — whether or not your teardown ran, and whether or not it threw.

This is deliberate, and it is aimed at a specific well-known failure. ATAK plugins are notorious for leaking listeners across reloads, because onDestroyImpl has to manually undo whatever onCreate did and a human has to remember every line. Here the context records every source, layer, subscription, object, panel and cleanup callback you create, and deactivate() releases them regardless. A plugin that forgets to clean up cannot leak, because it was never the plugin's job.

The practical consequence: your onDeactivate can be empty, and in most plugins it should be. Register things through the context and let the host own their lifetime.

What a plugin cannot do

Named up front, because discovering a wall later is worse than being told about it now.

  • It cannot draw its own UI. A panel is described as rows and the host draws them, in the host's own controls. A plugin that needs a chart or a camera preview cannot have one yet — that is the stated gap, and the reason is in Panels.
  • It cannot consume a map tap. Tap delivery is read-only; a plugin that could swallow the map's primary gesture could make the app look broken with no way to find out why.
  • It cannot set the camera. It requests a move, and the host's camera controller remains the only writer.
  • It cannot choose a notification channel, importance or full-screen intent. A notice is a title and a body. A map layer should not be able to hold the screen on and buzz through a silent profile.
  • It cannot touch MapLibre directly. It gets a narrowed facade, so an engine upgrade is not a plugin-breaking change.

Plugin SDK — quick start

1. Depend on the API, and only on the API

dependencies {
    compileOnly(project(":plugin:api"))   // or the published artifact
}
compileOnly is load-bearing — this is the mistake that costs an afternoon. A plugin that bundles the API ships a second copy of every class under the same names. Loaded through a DexClassLoader, those are not the same types the host holds, so an is ZyrnPlugin check fails and the error reads as a ClassCastException between a type and itself. Compile against the API; never package it. No Compose, no MapLibre, no host internals either.

2. Declare yourself in the manifest

<application>
    <meta-data android:name="zyrn-plugin-api"   android:value="2" />
    <meta-data android:name="zyrn-plugin-class" android:value="com.example.MyPlugin" />

    <!-- Never started. Exists so ZYRNMAPS can SEE this package. -->
    <activity android:name=".PluginMarker" android:exported="true" android:enabled="false">
        <intent-filter>
            <action android:name="com.zyrntopo.plugin.ACTION_PLUGIN" />
        </intent-filter>
    </activity>
</application>
Remove that activity and your plugin becomes undiscoverable, with no error anywhere. Android 11 (API 30) stopped getInstalledApplications returning other people's packages, so a host that only reads manifest meta-data finds nothing at all. The way through is <queries>, which filters by intent rather than by package — so a plugin declares an activity for this action purely to be visible, and the host declares a matching <queries><intent>. The activity is never started and needs no class behind it. ATAK's own template carries the same warning about its equivalent entry, for exactly the same reason.

3. Implement ZyrnPlugin

class MyPlugin : ZyrnPlugin {
    override val id = "com.example.myplugin"
    override val name = "My Plugin"
    override val minHostApi = 2
    override val capabilities = setOf(Capability.PANEL, Capability.MAP_OBJECTS)

    override suspend fun onActivate(ctx: PluginContext) {
        ctx.addObject(PluginObject("home", """
            {"type":"Feature",
             "geometry":{"type":"Point","coordinates":[-122.68,45.52]},
             "properties":{"name":"Base"}}
        """.trimIndent()))
    }
}

4. Build, install, enable

./gradlew :myplugin:assembleDebug
adb install -r myplugin/build/outputs/apk/debug/myplugin-debug.apk

Then ZYRNMAPS ▸ Menu ▸ Plugins ▸ enable it.


Plugin SDK — the interface and lifecycle

What you declare

MemberTypeWhy the host wants it
idStringNamespaces your storage and your map objects
nameStringWhat the Plugins screen shows
minHostApiIntThe lowest host API you work against, so the host can refuse rather than crash
capabilitiesSet<Capability>What you need. Declared, never assumed
tierTierFREE (default) or PRO. The host refuses a Pro plugin without entitlement, and says so
Declare minHostApi honestly. Understating it to load in more places means loading into an older host and then calling a method that is not there — a crash, instead of the plain sentence the Plugins screen would otherwise have shown. A version mismatch found at load time is a sentence; found at runtime it is a stack trace.

Activation

suspend fun onActivate(ctx: PluginContext)

It suspends, and that removes a distinction you would otherwise have to make. A plugin that lazily loads a large payload cannot finish inside a synchronous call, and a naive host marks such a plugin active the instant activation returns — leaving a failed load reporting as active with nothing behind it. A suspend function has one shape and cannot have that bug.

Deactivation

fun onDeactivate() {}   // may be empty, and usually should be

Undo only what you created outside the context — your own timers, threads, or state the API does not model. Everything registered through the context is released by the host afterwards, whether or not this ran, and whether or not it threw.

Why a plugin might be refused

Each of these is a different thing to tell the user, which is why they are separate values rather than one error string.

ReasonMeaning
HostTooOldBuilt against a newer host than this one. The user needs a ZYRNMAPS update
PluginTooOldBuilt against a host API this version no longer speaks. The plugin needs updating
NoEntryPointThe manifest names no plugin class
NotLoadableThe class could not be loaded, or is not a ZyrnPlugin. Carries the reason
NotEnabledDiscovered and allowed — the user simply has not switched it on

Plugin SDK — capabilities

A closed set on purpose. An open string would let a plugin ask for something no host version knows how to grant or refuse, which fails the same way as not declaring anything at all.

CapabilityWhat it grants
MAP_LAYERSAdd sources and layers to the map
MAP_CAMERARead the camera and request moves. Never writes it directly
MAP_QUERYQuery rendered features under a point
MAP_OBJECTSPut objects on the map and take them off again
MAP_EVENTSBe told where the user tapped — read-only
MAP_ACTIONContribute an entry to the map's context menu
PANELContribute a panel to the UI
SEARCHContribute results to the search sheet
NOTIFICATIONPost a notification
LOCATIONRead the user's current position
STORAGENamespaced persistent storage
BUSSubscribe and publish on the integration bus

The last four in the list above — map objects, map events, notifications and search — come from a survey of what ATAK plugins actually register, rather than from invention. Capabilities with no ZYRNTOPO equivalent (radios, contacts, the GL item factory, video layers) are deliberately absent: a capability a plugin can declare and the host cannot honour is worse than one it cannot declare at all.


Plugin SDK — the context

PluginContext is what you are handed on activation. Every registering method on it is tracked, and released on deactivate.

Map sources and layers

ctx.addSource("my-src", SourceSpec(type = "geojson", json = """{"type":"geojson","data":{...}}"""))
ctx.addLayer(LayerSpec(id = "my-layer", json = """{"id":"my-layer","type":"line","source":"my-src"}"""))

Sources and layers are style JSON as text, not SDK objects, for the same reason the map is a facade: a plugin compiled against RasterSource breaks the day the SDK renames it. On teardown, layers are always removed before their sources.

The map facade

interface MapFacade {
    fun hasSource(sourceId: String): Boolean
    fun addSource(sourceId: String, spec: SourceSpec)
    fun removeSource(sourceId: String)
    fun hasLayer(layerId: String): Boolean
    fun addLayer(spec: LayerSpec)
    fun removeLayer(layerId: String)
    fun requestCamera(intent: CameraIntent)   // intent, not a command
    fun camera(): CameraIntent                // read-only
}

Deliberately small: every method here is a promise to keep working across engine upgrades, so the bar for adding one is that a plugin cannot do its job without it. Note there is no camera setter — requestCamera records intent, and the host's camera controller stays the only writer.

Storage, bus and entitlements

ctx.storage.set("last-run", System.currentTimeMillis().toString())
val sub = ctx.subscribe("position") { payload -> /* … */ }
ctx.publish("my.topic", payload)
if (ctx.entitlements.isPro) { /* … */ }

Storage is namespaced by plugin id, so two plugins cannot collide on a key. Subscriptions are released for you; cancelling one twice is not an error, because the host may already have released it.

Anything the API does not model

ctx.onCleanup { myTimer.cancel() }

Plugin SDK — panels

A plugin describes rows; the host draws them, in its own controls, honouring night mode, glove mode and the accent colour.

Why not just hand you a View?

Two reasons, and the cost of the choice is stated rather than hidden. A View crossing a DexClassLoader boundary pins the host's UI toolkit into the plugin contract — every plugin would then compile against our Compose and our theme, and an upgrade to either becomes a breaking change for every plugin installed. And a map app has one design system: a panel drawn by a plugin in its own style does not read as an extension of the app, it reads as a different app inside it, including in the wrong colours on a night-mode map.

The cost, plainly: a plugin that needs a chart or a camera preview cannot have one here yet.

The rows

RowRenders as
Reading(label, value)A label and a value, in the shape the diagnostics screens use
Note(text)A paragraph — an explanation, or a refusal with its reason in it
Section(title)A heading between groups of readings
Action(id, label, enabled, onInvoke)A chip that does something. enabled = false draws it dimmed rather than hiding it
Toggle(id, label, on, onChange)A chip that is lit or not, reporting its new state when pressed

rows is a lambda, and you must call refreshPanels()

ctx.registerPanel(PluginPanel(
    id = "mine",
    title = "My Plugin",
    rows = { listOf(PanelRow.Reading("Queue", "$queued")) },   // read at DRAW time
))

rows is a lambda rather than a list so a panel showing a count, a status or a reading updates when the underlying thing moves — a snapshot taken at registration is a panel that is correct exactly once.

A live lambda is still not enough on its own. The lambda closes over your plugin's own fields — ordinary fields in an ordinary class, which the UI toolkit is not watching. So a panel showing a queue that grew because your backend sent an event keeps showing the old count until something unrelated redraws the screen. Call ctx.refreshPanels() whenever something a row displays has moved. It is cheap, idempotent, and safe to call from a background thread. Calling it when nothing changed costs one recomposition of one screen; not calling it is a panel that is correct once — the exact failure the lambda was chosen to avoid, arriving through the other door.

Plugin SDK — objects, taps, notices and search

Map objects

ctx.addObject(PluginObject(id = "beacon", featureJson = """
    {"type":"Feature",
     "geometry":{"type":"Point","coordinates":[-122.6765,45.5231]},
     "properties":{"name":"Beacon"}}
""".trimIndent()))
ctx.removeObject("beacon")

Geometry is a GeoJSON Feature as text — what the object store already speaks and what every export path understands. The host rejects anything that is not one. Your id is namespaced to plugin:<your-id>:<yours>, so two plugins choosing the same name cannot collide, a deactivate removes exactly what you added, and nothing the user drew is ever touched.

Map taps

ctx.onMapTap { tap -> /* tap.lat, tap.lon, tap.zoom */ }

Read-only, and you cannot consume the event.

Notifications

ctx.notify(PluginNotice(title = "My Plugin", body = "Something happened."))

A title and a body, and nothing else — see what a plugin cannot do for why.

Search providers

ctx.registerSearch { query ->
    if (!query.equals("beacon", ignoreCase = true)) emptyList()
    else listOf(PluginSearchResult("Beacon", "A place only this plugin knows", 45.5231, -122.6765))
}

The case this exists for is a team with its own gazetteer — grid squares, hut names, a numbered pole network — that no general geocoder has heard of, and which is exactly what somebody types into a search box first.

Search providers must be fast and offline. Yours runs beside the coordinate parser, which answers before anything is debounced. A provider that needs the network belongs behind a panel button, not here.

Plugin SDK — signing, trust and versioning

Signing

Sign with your own key. ZYRNMAPS reads the signature, records the SHA-256 fingerprint and shows it, and loads your plugin either way — see Who signed it for what users are told. Publish your fingerprint so people can check it.

Do not test with Android's shared debug keystore. Every debug build uses it, including ZYRNMAPS' own, so under it your APK reads as first-party — and the one path that was never in doubt becomes the only one you have tested. Generate a key of your own and confirm your plugin loads as third-party.
keytool -genkeypair -v -keystore my-plugin.keystore \
  -alias myplugin -keyalg RSA -keysize 2048 -validity 10000 \
  -dname "CN=My Plugin, O=My Org, C=US"

Host API versioning

ConstantValueMeaning
HostApi.VERSION2The current host API
PluginManifest.MIN_PLUGIN_API1The oldest plugin API still loaded

Version 2 is where the surface grew map objects, map taps, notifications, search providers and panel content. A plugin built against 1 declares minHostApi = 1 and still runs, because the comparison is minHostApi > VERSION; a plugin that needs the newer surface says so and is refused with a sentence rather than a NoSuchMethodError at some later moment.

The floor stays at 1 until something is actually removed from the contract. Refusing an old plugin costs a user a working tool; loading one that calls a deleted method costs them a crash — so the floor moves only when the second becomes true.

Manifest keys, in one place

KeyValue
zyrn-plugin-apiThe host API you built against. Doubles as the marker that this APK is a plugin
zyrn-plugin-classFully-qualified class implementing ZyrnPlugin
com.zyrntopo.plugin.ACTION_PLUGINThe intent action your marker activity declares, so the package is visible on Android 11+

The API version and the "is a plugin" marker are one key on purpose — a separate boolean would be a second thing that can disagree with the first.


Plugin SDK — a complete example

This exercises every surface the host honours today: it drops a marker, adds a panel with live counts, answers one search word, listens for taps and posts a notice on the third one.

class SamplePlugin : ZyrnPlugin {
    override val id = "com.example.sample"
    override val name = "Sample Plugin"
    override val minHostApi = 2
    override val capabilities = setOf(
        Capability.PANEL, Capability.STORAGE, Capability.MAP_OBJECTS,
        Capability.MAP_EVENTS, Capability.NOTIFICATION, Capability.SEARCH,
    )

    private var taps = 0
    private var lastTap: MapTap? = null

    override suspend fun onActivate(ctx: PluginContext) {
        ctx.addObject(PluginObject("beacon", """
            {"type":"Feature",
             "geometry":{"type":"Point","coordinates":[-122.6765,45.5231]},
             "properties":{"name":"Sample plugin beacon"}}
        """.trimIndent()))

        ctx.onMapTap { tap ->
            taps++
            lastTap = tap
            ctx.refreshPanels()          // without this the panel is frozen
            if (taps == 3) ctx.notify(
                PluginNotice("Sample plugin", "You have tapped the map $taps times.")
            )
        }

        ctx.registerSearch { query ->
            if (!query.trim().equals("beacon", ignoreCase = true)) emptyList()
            else listOf(PluginSearchResult(
                "Sample plugin beacon", "A place only this plugin knows about",
                45.5231, -122.6765,
            ))
        }

        ctx.registerPanel(PluginPanel(
            id = "sample",
            title = "Sample plugin",
            rows = {
                listOf(
                    PanelRow.Reading("Taps seen", "$taps"),
                    PanelRow.Reading("Last tap", lastTap?.let { "%.5f, %.5f".format(it.lat, it.lon) } ?: "none yet"),
                    PanelRow.Note("Search \"beacon\" to find this plugin's marker."),
                    PanelRow.Action("reset", "Reset count") {
                        taps = 0; lastTap = null; ctx.refreshPanels()
                    },
                )
            },
        ))
    }

    // Nothing here on purpose. The object, the tap handler, the search provider
    // and the panel all went through the context, which tracks them — they are
    // released on deactivate whether or not this class remembers them.
}
Note the empty teardown. That is not an oversight — it is the property the whole API was built around, and a sample that cleaned up by hand would demonstrate the opposite of the point.

Verifying your plugin actually loads

Unit tests cannot prove the part that really fails. Activating an object your test constructed says nothing about whether a separately compiled, separately signed APK loaded through a DexClassLoader sees ZyrnPlugin as the same interface the host holds. Install the real APK on a real device, enable it, and confirm the Plugins screen reports it as third-party and running.