The demo app, and how to debug the renderer
Everything about scripts/android-dev: the two screens, the intent extras, the probes that work,
and the measurement discipline. CLAUDE.md
routes here rather than carrying it, because none of it is needed to answer an ordinary question.
The app has TWO screens, and the bench is NOT the one it opens on
| Activity | What it is |
|---|---|
.MainActivity | the example gallery — one file per example, on the facade API. Not for debugging. See examples.md |
.ExampleActivity | runs one example: --es example <id>, plus --es ui false, --es globe true and --es lon/lat/zoom/tilt/rotation |
.BenchActivity | the composable debugging/measurement map — every layer switch, every intent extra, DemoLive |
A debugging run names .BenchActivity explicitly; am start with no activity opens the gallery.
The fast loop
cd scripts/android-dev && ./gradlew :app:assembleDebug -x lint # ~40 s incremental (native included)
adb install -r -t app/build/outputs/apk/debug/app-debug.apk # -t: the APK is test-only
adb shell am force-stop com.massifmaps.MassifDemo
adb shell am start -n com.massifmaps.MassifDemo/.BenchActivity --es ui false --es drape false
-PabiFilters=arm64-v8a narrows the native build to one ABI — enough for a device or an arm64
emulator, and much faster than the default four.
- Install from
app/build/outputs/apk/debug/.app/build/intermediates/apk/debug/also holds anapp-debug.apkand it is stale. Verify withunzip -p <apk> classes*.dex | strings | grep <new symbol>(the demo's Java lands in classes5.dex). pm clear com.massifmaps.MassifDemoresets camera/caches. Without it the persistent tile caches stay warm, which is usually what you want — but two runs that differ only in cache state are not a valid A/B.
Settle time
Tiles need 60–90 s to settle before a screenshot means anything (network + persistent cache + label placement). For anything involving 3D extrusions — shadows above all — this is not optional: buildings arrive asynchronously, and before they cast there are no shadows to be wrong. A frame taken too early shows a clean scene that looks exactly like a fix.
The iOS demo as a Mac app
scripts/ios-dev also builds as a native Mac Catalyst app that takes the same knobs as launch arguments and
captures screenshots and frame bursts in-process, with the app's own Massif style and offline data, so a
rendering bug reproduces in a minute without the simulator or NativeScript. See
the Catalyst demo loop.
The demo files
File (under app/src/main/java/com/massifmaps/MassifDemo/) | Role |
|---|---|
demo/DemoConfig.java | every default, one static field per knob + the intent-extra key map |
demo/DemoCfg.java | cfgBool/cfgFloat/cfgInt/cfgStr/cfgColor/cfgEnum intent readers (--es key value) |
demo/DemoMap.java | builds/updates the map through the facade only (the bench carries no object API): layer registry, shared sources, terrain/light/sky, camera |
demo/DemoStyles.java | style specs (dir / zip / inline CartoCSS / style project) + demo shaders |
demo/DemoSky.java | day-cycle sun/sky + generated sky shader |
demo/DemoPanel.java | on-screen panel — writes DemoConfig, then calls a DemoMap.apply*() |
demo/DemoTests.java | one-shot actions (routing, search, GeoJSON) |
ui/main/SecondFragment.java | Android glue only (view, permissions, map listener) |
Change defaults in DemoConfig only. These files may carry uncommitted local edits (camera,
per-demo knobs): read before touching, keep changes additive, never restore from a backup or an
older commit.
Intent extras
Standalone layers (map = the base map, satLayer, hillshade, hypso, contourLayer, contourTiles,
spans, routes, elements, bugs) toggle with --es <name> true|false (--es satUnder true puts the
satLayer raster UNDER the base map, as a hybrid style's imagery); the base map has --es base plain|composite and
--es style dir|zip|inline|project. dir reads the style from a FOLDER via DirAssetPackage
(/sdcard/alpimaps_mbtiles/osm), falling back to osm.zip then to inline CartoCSS.
radar is the animated raster layer over LibreWXR
(RainViewer's API): the past hour and the nowcast, 18 steps. A frame clock advances the position at
--es radarFps 2 steps a second; the RADAR LAYER section of the panel has playing, a position slider that
scrubs (and follows the clock), the step's time (marked forecast past now) and the rate. --es radarPlay false
starts paused. Judged at --es radar true --es lon 5.72 --es lat 45.19 --es zoom 8 --es tilt 0: no pop at a
step boundary, no darkening where two steps overlap. Needs the network.
The gallery example custom-layer-heatmap (Android and iOS) is the same layer with a 3D toggle, a Drape toggle and an opacity slider; --es drape 0 starts it undraped on Android. customOverlay and customScene are the custom layers of
app/src/main/cpp/custom_layer_demo.c, written against MassifApiC.h alone (the app builds that file with
CMake, and looks the mm_* functions up in the loaded libmassif.so). The scene layer is a heatmap draped on
the terrain: DemoMap.createHeatmapLayer samples the DEM on a 160 x 160 grid over 8 km around the start position
(off the UI thread), a few synthetic hot spots are the data, and the C layer draws the grid depth tested, so a ridge in
front hides it. A tap on it logs massif-demo with the cell and value (hit_test marches the ray over the grid) and
dims the heatmap. The overlay is the colour legend, in screen space, tinted by the layer's opacity. Judged at
--es customOverlay true --es customScene true --es terrain true --es lon 5.76 --es lat 45.24 --es zoom 12.2 --es tilt 35
(3D; tilt is the angle from the horizon, 90 is straight down) and the same with --es terrain false --es tilt 90 (2D):
the heatmap follows the relief with only slivers cut out on the steepest ridge edges, and sits flat in 2D. Mercator
metres are converted to world units in C, so the grid is right whatever the base projection.
models is the 3D MODELS layer (DemoMap.createModelsLayer): a jittered grid of trees as ONE
MultiPoint around the start position and three spinning wind turbines 500 m north-east of it, drawn
from assets/style/models/*.glb by the Massif model rules; a tap tints the model hit
(setModelHighlight). The base map does the same for the Massif style's poi trees and turbines.
--es vectorFile rhones_alpes/rhone-alpes.pmtiles reads the base map from a .pmtiles under the data
root instead of vectorUrl - offline OpenMapTiles data with every mapped tree:
--es base plain --es style assetzip --es styleAssetZip massif --es vectorFile rhones_alpes/rhone-alpes.pmtiles \
--es lon 5.7305 --es lat 45.1855 --es zoom 17.8 --es tilt 60
bugs is the STYLE REGRESSION repro layer (DemoStyles.bugStyle, DemoMap.createBugsLayer):
synthetic GeoJSON on the start camera, one feature per reported symptom, each with its A/B knob —
bugLabelSize, bugBackOpacity, bugLineColor, bugLineBorder (pair with --es bugBackOpacity -1),
bugAllowOverlap/bugTextClip. --es bugs true --es ui false shows all four.
It also carries the bridge / tunnel span (bugbridge, bugtunnel): one chord across the
Bastille ridge, 207 m at its south end and 641 m at its north, with the ground bulging ~60 m ABOVE
the chord in between — so "goes straight" is unmistakable, and a tunnel on the same chord is
genuinely inside the hill at mid-span. They are their own vt layers so the drape bake can be turned
off by name:
--es bugs true --es terrain true --es noDrape '^contour|bug(bridge|tunnel)'
Quote the filter for the DEVICE shell (the | is re-parsed there), and note noDrape needs a
RELAUNCH — an already-baked drape texture is not invalidated when the filter changes. Real bridges
are a poor substitute: Millau's DEM sits 220 m above the actual deck and the viaduct is longer than
a z14 tile. The span keeps INTERMEDIATE vertices on purpose (bugSpanVertices) — a two-point line
is already straight once it leaves the bake and would show a fix that is not there.
--es demo terrain|project|composite is a legacy shorthand for base/style. Every knob read in
DemoConfig.applyIntentOverrides is an intent extra, so most experiments need no rebuild:
- camera:
lon lat zoom tilt rotation,fovY(0 = the SDK default);zoomTo <zoom>,<seconds>(CONFIG broadcast only) animates the zoom, the scripted stand-in for a slow pinch - terrain:
drape drapeLines drapeResolution meshResolution exaggeration,viewDistance viewDistanceMeters,autoFlatten autoFlattenTilt autoFlattenMs,stitch - composite overlays:
hs sat satZoom contour; buildings:bld3d - stand-alone raster layer (
satLayer):rasterUrl rasterCacheDb rasterMaxZoom rasterBias;rasterBiasis live (CONFIG broadcast, or the "satellite zoom bias" panel slider). The tile cache is not keyed by URL: changerasterCacheDbwith it - light/shadow:
daycycle sunHour sunAzimuth sunAltitude shadow terrainShadows(shadows needterrainLight true) - labels:
textOcclusion,roadLabelOcclusion(a re-decode) ui false(hide the panel),anim zoom|pan|rotate|zoomseq|approachshowFps true(bench and.ExampleActivity): frames drawn per second and the GL-thread draw time, bottom-left and once a second in logcat (devtap.py logs android --grep fps); WHEN_DIRTY draws on request only, so it readsidleon a still map
--es buildings 0|1|2 drives a COMPILED style's own buildings parameter (none / footprints /
extrusions), which is what a converted Mapbox Standard gates its 3D on. --es bld3d sets it too.
--es vectorZoomBias -1 for a source authored for MapBox's 512 px tiles. This SDK's tiles are
256 px, so at the same view it asks for a level DEEPER than mapbox-gl does. The on-screen readout
prints z=<sdk> (mb <sdk-1>) for this reason.
A second DEM behind an OrderedTileDataSource: demEncoding, dem2Url dem2Encoding dem2MaxZoom. dem_encoding is resolved per TILE. Launch-only, and the encoding is stored WITH
the cached tile — a relabelled source keeps rendering with the old one until pm clear.
Changing a knob on the RUNNING app
adb shell am broadcast -a com.massifmaps.MassifDemo.CONFIG --es fog false
adb shell am broadcast -a com.massifmaps.MassifDemo.CONFIG --es fogPreset dusk --es zoom 14
Same keys as the launch extras; only the option groups whose keys arrive are re-applied, and the
camera is left alone unless a camera key is sent. This is how an A/B gets run without the tile set
changing underneath it. am start on an already-running bench does the same thing — the
activity is singleTop and BenchActivity.onNewIntent feeds its extras back through DemoLive.
The gallery has the same channel through ExampleLive (same short keys), plus --es styleParam name=value, a style parameter on the example's basemap layer (buildings=1 turns Mapbox Standard's
extrusions into footprints). A light, fog or sky knob on an example that never set one builds a
default object first (as tools/style-preview/massif-pane.html does); a terrain knob needs the
example's own elevation source and is ignored with a warning. --es flattened false --es tilt 38
switches a flat example to 3D in place; the 2D/3D button in terrain-2d-3d flies to its summit. Two traps:
ExampleLiveapplies the writes on its own worker thread: a receiver runs on the main thread with a deadline, and a write blocks on whatever the render thread holds, so writing inline ANRed the app.- The receiver must be registered
RECEIVER_EXPORTED—adb shell am broadcastruns as the shell uid, and aNOT_EXPORTEDreceiver drops every one silently (result=0, no log).
ExampleActivity logs the camera on every move, in the form the launch extras take:
python3 scripts/devtap.py logs android --grep camera
Things that look like bugs and are not
- A style knob needs a re-decode, not just an option apply. Anything written into the CartoCSS
(
style styleLight bld3d bldLight bldAmbient bldGradient bldGradientHeight) is carried by the TILES, soDemoLiverebuilds the base layer for those keys. The inline style's whole sun/shadow/ building block is gated on--es styleLight true. - A screenshot after a state change needs the map to have drawn TWICE. The surface is double-buffered and WHEN_DIRTY draws exactly what was requested, so one frame lands in the back buffer and the screen keeps the old state — permanently, not briefly. If a change "sometimes does not apply", check the frame count before suspecting the change (01-frame.md).
- Camera clamp: the terrain keeps a
cameraClearanceabove the ground, so a start position inside a slope auto-zooms out and you get an empty grid screen. Zoom out a notch instead of assuming the render broke. --es shadowDistancewith a large value hangs the app — the caster ring is sized by the shadow throw, so a big cutout explodes the tile count.
Debugging the renderer — what actually works
Measure first, look second. Reading a screenshot, or a raw log, is by far the most expensive
thing in a debugging loop — in wall-clock and, for an agent, in context. scripts/devtap.py does
the bulk work on disk and prints a summary; the full artifact lands in build/devtap/ for grepping.
Use it instead of adb logcat, adb screencap, xcrun simctl log show or opening a .ips:
python3 scripts/devtap.py logs android --max 20 # 2087 raw lines -> 10
python3 scripts/devtap.py logs ios --since 3m # 15193 -> 8
python3 scripts/devtap.py crash android # tombstone, ndk-stack symbolicated
python3 scripts/devtap.py crash ios # 112 KB .ips -> faulting thread, 17 lines
python3 scripts/devtap.py shot android --compare before.png
python3 scripts/devtap.py diff before.png after.png
logs android scopes to the demo app's pid, drops the system noise tags, and collapses lines that
differ only in numbers into one xN entry — the header names what it dropped, so a surprising
count is itself a signal. --grep REGEX bypasses every filter for one specific probe.
--device (or ANDROID_SERIAL) is mandatory when several emulators are attached; devtap refuses to guess.
shot and diff print per-band mean/stddev and the changed-region bbox, not the image.
mean 0.00 max 0 means the two frames are identical — that is the answer, and it is how you catch
a knob that never applied. Only open the png when a number moved, and crop first (--crop x0,y0,x1,y1) rather than reading a full 1080×2400 frame.
Counting label churn on a device
A label blink is either the placement pass flipping a label, or something feeding it a different
input. The SDK counts both, behind MASSIF_VT_RENDER_STATS, and prints one RenderStats: line set a
second. Build any app with the counters in and read them as a timeline:
# Alpi Maps, SDK from source (MASSIF_SDK_HOME = the checkout under test)
MASSIF_SDK_HOME=<sdk checkout> DEBUGMASSIF=1 ns run android --gradleArgs=-PprofileRender
# the demo: ./gradlew :app:assembleDebug -PprofileRender
python3 scripts/devtap.py stats android --device <serial> --package <the app's id>
One row a second (cullUpd, tileRecalc, cullPasses, visFlips, occFlips, lineLayouts...), the
intervals with visFlips > 0 marked, and the mean of every counter in those intervals against the
quiet ones. Reproduce the blink, run it straight after, and read which counter jumps with the flips:
| Counter that moves with the flips | The cause |
|---|---|
cullUpd, tileRecalc | the view changed: MapRenderer::viewChanged re-culled, and the tile set was recalculated, which asks for a placement pass |
cullPasses without them | a placement pass the view did not ask for: consumeLabelPlacementOwed / consumeLabelReanchorOwed, a zoom step of 0.25, an option setter |
occFlips, occluded | the building occlusion verdict re-ranks labels (06-labels) |
lineLayouts, reVisible | a line run (road name, arrow) re-laid on a new view matrix, or re-anchored |
labelMaps up, labelMapSkips flat | labels rebuilt on a tile-set change |
The counters are process-wide and logcat keeps a few minutes, so read it right after the repro.
- A/B by feature, per screen row band, before probing the plumbing. Screenshot with a layer on
and off (
--es hs false,--es sat false,--es drape false) anddevtap diffthe two. If a band is 0.0% different, that content is not being drawn there; if it differs, it is drawn and the problem is elsewhere. This is what separated "tile never loaded" from "tile drawn but depth-rejected". - Probes go in these places, in this order — layer culling → draw data → vt render tiles → draw:
TileLayer::buildFetchTiles(visible set + cache misses,typeid(*this).name()tells you which layer),TileRenderer::refreshTiles(per-zoom tiles/bitmaps/geometries, logthisto separate a composite's children),RasterTileLayer::FetchTask::loadTile,ElevationTextureCache::getTexture, andGLTileRenderer::renderGeometry2D. - vt has no logger — use
__android_log_print(4, "massif", ...). When throttling a probe with a frame counter shared by several renderer instances, use a prime modulus:% 120with 4 instances always logs the same one. Shader::getUniformLocreturns 0 for a uniform the compiler dropped, and 0 is a valid location — writing to it clobbers whatever lives at 0.SkyRendererusesglGetUniformLocationand>= 0guards; copy that pattern.setDebugWireframe/setDebugSurfacePrefillinTileRenderer.cppare hardwired false and mostly wash the frame out; the A/B diff above is more informative.- Strip every probe before committing.
OpenGL ES 3 is a hard requirement
Both platforms create an ES3 context and nothing falls back to ES2:
android/java/com/massifmaps/ui/ConfigChooser.java asks for EGL_OPENGL_ES3_BIT in every EGL
config, and ios/objc/ui/MapView.mm uses kEAGLRenderingAPIOpenGLES3. GLES3/gl3.h is included
directly (renderers/utils/GLContext.h).
So an ES3-only format or entry point needs no extension check and no ES2 path — GL_R8,
glInvalidateFramebuffer, MRT, sampler2DShadow. What is still version-dependent is the shader
language: programs are built at #version 100 unless they ask for ESSL3_FLAG, and a driver that
refuses the 3.00 variant falls back to 1.00 (GLTileRenderer::hasShaderVersionFallback), so a
shader written for ESSL 3.00 must still have a 1.00 form. Note also that precision mediump float
blocks overflow on functions like tan() near π/2 — write the numerically safe form.
Terrain, fog and sky wiring
Detail: 08-lighting-sky-fog.md. The pointers worth knowing before reading it:
- One resolution point for both:
all/native/components/StyleEnvironment.h—resolveLighting()andresolveFog()merge the app'sLightOptions/TerrainOptionswith the style's Map-block values, andresolveFogalso lights the fog. Every consumer (TileRenderer→ vt,BackgroundRenderer,SkyRenderer) must go through them, or the ground and the sky end up with different fog. SkyRendererdraws a full-screen ray-direction quad. Apps can replace the body withSkyOptions.setShaderSource; the wrapper declaresu_sunDir/u_sunColor/...plus the fog block ofFogOptions.setShaderSource(fogHorizonBlend(dir), ...) — redeclaring any of them is a compile error and the renderer silently falls back to the built-in sky.BackgroundRendererdraws the flat z=0 plane past the terrain. It usesOptions.getBackgroundBitmap()— not the CartoCSSMap { background-color }.TerrainOptions.ViewDistanceFactorends the ground (tangram's rule). Pair a short one with fog or the ground ends on a hard edge.- Fog is
FogOptions, independent of the terrain.--es fog <color>or--es fog falseis the master switch;fogRangeStart fogRangeEndare in multiples of the camera-to-focus distance.fogBlendscales the GROUND as well as the sky — one angular term for both is what makes the two meet at the skyline with no seam. - The sky is a physical atmosphere by default.
--es skyType gradientgets the two-colour ramp back; the raymarch runs per fragment of VISIBLE SKY, so a low tilt is what pays for it.
Comparing against older SDK code
A/B-ing a regression takes two steps, not one:
git checkout <sha> -- all/ libs-massif/ # 1. old sources (libs-massif is in-tree since 2026-08-23)
cd scripts && python3 swigpp-java.py --profile "standard+objectapi+valhalla+geocoding+routing+packagemanager" \
--swig /Volumes/dev/carto/mobile-swig/swig # 2. regenerate wrappers, else the build fails
generated/ is gitignored, not tracked: there is no git checkout that brings it back, and a
swigpp-java.py run overwrites the tree's wrappers with whatever --profile you passed. Restore
the same way (git checkout HEAD -- all/ libs-massif/, regenerate).
A temporary WIP commit is the safe way to set work aside for a comparison — the git stash stack is shared with the main checkout and every other worktree.