-- Mock of the wada.* host (mirrors LuaAppHost.cpp argument checks) + scenarios. local debug, io, loadfile, print, error, assert, pcall, type, tostring, string, math, table, ipairs, pairs = debug, io, loadfile, print, error, assert, pcall, type, tostring, string, math, table, ipairs, pairs local APP_PATH, SCENARIO = APP_PATH, SCENARIO local BUDGET, INIT_BUDGET = 100000, 500000 local clock_ms = 1000 local toasts, fails, maxinstr = {}, 0, 0 local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not an integer: " .. tostring(v)) end local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end local function valid_utf8(text) local i, n = 1, #text while i <= n do local first = text:byte(i) local width = first <= 0x7F and 1 or (first >= 0xC2 and first <= 0xDF and 2) or (first >= 0xE0 and first <= 0xEF and 3) or (first >= 0xF0 and first <= 0xF4 and 4) or 0 if width == 0 or i + width - 1 > n then return false end for j = 2, width do local byte = text:byte(i + j - 1) if byte < 0x80 or byte > 0xBF then return false end end local second = width > 1 and text:byte(i + 1) or 0 if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then return false end i = i + width end return true end -- instruction budget like guardedCall(): error() out of the hook, pcall catches local function guarded(budget, fn, ...) local count = 0 debug.sethook(function() count = count + budget; error("instruction budget exceeded (app tick too long)", 0) end, "", budget) local t0 = os and os.clock() or 0 local ok, err = pcall(fn, ...) debug.sethook() if not ok then fails = fails + 1; print(" APP ERROR: " .. tostring(err)) end return ok end -- ---- mock wada ---------------------------------------------------------- local cfg = {} -- per-scenario device config local widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil } local drawlog = { text = {}, circles = {}, ops = 0 } local storekv = {} local audio_state = { state = "stopped", path = "", source = "", format = "", error = nil } local audio_log = {} local function audio_host_close() audio_state.state = "stopped" audio_log[#audio_log + 1] = "release" end local function mkcanvas(w, h) checkint(w, "canvas w"); checkint(h, "canvas h") assert(w > 0 and w <= 480 and h > 0 and h <= 480, "canvas size 1..480") widgets.canvases = widgets.canvases + 1 local c = { w = w, h = h } function c:fill(col) checkcol(col, "fill"); drawlog.ops = drawlog.ops + 1 end function c:rect(x, y, rw, rh, col, filled, radius) checkint(x, "rect x"); checkint(y, "rect y"); checkint(rw, "rect w"); checkint(rh, "rect h"); checkcol(col, "rect") drawlog.ops = drawlog.ops + 1 end function c:line(x1, y1, x2, y2, col, width) checkint(x1, "line x1"); checkint(y1, "line y1"); checkint(x2, "line x2"); checkint(y2, "line y2"); checkcol(col, "line") if width ~= nil then checkint(width, "line width") end drawlog.ops = drawlog.ops + 1 end function c:circle(x, y, r, col, filled, sw) checkint(x, "circle x"); checkint(y, "circle y"); checkint(r, "circle r"); checkcol(col, "circle") if sw ~= nil then checkint(sw, "circle stroke") end drawlog.circles[#drawlog.circles + 1] = { x = x, y = y, r = r, col = col, filled = filled } drawlog.ops = drawlog.ops + 1 end function c:text(x, y, s, col, size) checkint(x, "text x"); checkint(y, "text y"); checkstr(s, "text"); checkcol(col, "text") if size ~= nil then checkint(size, "text size") end drawlog.text[#drawlog.text + 1] = tostring(s); drawlog.ops = drawlog.ops + 1 end function c:pos(x, y) checkint(x, "canvas pos x"); checkint(y, "canvas pos y"); c.x, c.y = x, y end return c end local labels = {} local function mklabel(text, x, y, size, col) checkstr(text, "label text"); if x then checkint(x, "label x") end; if y then checkint(y, "label y") end if size then checkint(size, "label size") end; checkcol(col, "label") widgets.labels = widgets.labels + 1 widgets.seq = (widgets.seq or 0) + 1 local l = { text = tostring(text), x = x, y = y, size = size, seq = widgets.seq } function l:set(s) checkstr(s, "label:set"); l.text = tostring(s) end function l:pos(px, py) checkint(px, "label:pos"); checkint(py, "label:pos") end function l:color(c) checkcol(c, "label:color") end function l:width(w) checkint(w, "label:width"); l.w = w end labels[#labels + 1] = l return l end local buttons = {} local function mkbutton(text, x, y, w, h, fn) checkstr(text, "button text"); checkint(x, "button x"); checkint(y, "button y") if w then checkint(w, "button w") end; if h then checkint(h, "button h") end widgets.buttons = widgets.buttons + 1 local b = mklabel(text, x, y, 14, nil); b.fn = fn; buttons[#buttons + 1] = b return b end -- wada.ui.list: rows with optional callbacks, same argument checks as uiList/lsAdd local lists = {} local function mklist(x, y, w, h) checkint(x, "list x"); checkint(y, "list y"); checkint(w, "list w"); checkint(h, "list h") assert(w > 0 and h > 0, "list: width and height must be positive") widgets.seq = (widgets.seq or 0) + 1 local l = { rows = {}, x = x, y = y, w = w, h = h, seq = widgets.seq } function l:add(text, fn) checkstr(text, "list:add text"); assert(fn == nil or type(fn) == "function", "list:add fn") l.rows[#l.rows + 1] = { text = tostring(text), fn = fn }; return #l.rows end function l:set(i, text) checkint(i, "list:set"); checkstr(text, "list:set"); if l.rows[i] then l.rows[i].text = tostring(text) end end function l:color(i, c) checkint(i, "list:color"); checkcol(c, "list:color") end function l:clear() l.rows = {} end function l:count() return #l.rows end function l:select(i) checkint(i, "list:select") end function l:selected() return 0 end function l:pos(px, py) checkint(px, "list:pos"); checkint(py, "list:pos") end lists[#lists + 1] = l return l end -- ---- mock SD card: mirrors wada.sd in LuaAppHost.cpp ------------------------- -- cfg.sdtree is built by mktree(): a dir is { dir = {name -> node}, order = {names} }, -- a file is { data = "bytes" }. Paging, path rules, error strings and the threat -- policy (SdThreat.h) all follow the firmware, so an app tested here behaves the -- same on a card. local SD_PAGE_MAX = 192 local SD_THREAT_EXT = { exe = "Windows program", scr = "Windows program", com = "Windows program", pif = "Windows program", cpl = "Windows program", msi = "Windows program", msp = "Windows program", dll = "Windows program", jar = "Windows program", bat = "Windows script", cmd = "Windows script", vbs = "Windows script", vbe = "Windows script", js = "Windows script", jse = "Windows script", wsf = "Windows script", wsh = "Windows script", hta = "Windows script", ps1 = "Windows script", reg = "Windows script", msc = "Windows script", lnk = "Windows shortcut", url = "Windows shortcut", scf = "Windows shortcut", } local function sd_safe(path) if type(path) ~= "string" or path == "" or path:sub(1, 1) ~= "/" or #path >= 192 then return false end if #path > 1 and path:sub(-1) == "/" then return false end if path:find("[\0-\31\127\\]") or path:find("//", 1, true) then return false end for seg in path:gmatch("[^/]+") do if seg == "." or seg == ".." then return false end end return true end local function sd_node(path) local node = cfg.sdtree or { dir = {}, order = {} } for seg in path:gmatch("[^/]+") do if not node.dir then return nil end node = node.dir[seg] if not node then return nil end end return node end local function sd_classify(path, node) local name = path:match("[^/]+$") if name:lower() == "autorun.inf" then return "autorun file" end local e = name:match("%.([^.]+)$") if e and SD_THREAT_EXT[e:lower()] then return SD_THREAT_EXT[e:lower()] end local d = node.data if #d >= 64 and d:sub(1, 2) == "MZ" then local off = d:byte(0x3D) | (d:byte(0x3E) << 8) | (d:byte(0x3F) << 16) | (d:byte(0x40) << 24) if off >= 64 and off <= 65536 and off <= #d - 4 and d:sub(off + 1, off + 4) == "PE\0\0" then return "renamed Windows program" end end return nil end local function sd_file(path) if not sd_safe(path) or path == "/" then return nil, "bad path" end if cfg.sd_nocard then return nil, "no sd" end local node = sd_node(path) if not node then return nil, "not found" end if node.dir then return nil, "not a file" end return node end local function sdmock_list(path, start, max) if path == nil then path = "/" end checkstr(path, "sd.list path") start = start or 1; checkint(start, "sd.list start") assert(start >= 1, "sd.list: start must be 1 or more") max = max or SD_PAGE_MAX; checkint(max, "sd.list max") assert(max >= 1 and max <= SD_PAGE_MAX, "sd.list: max must be 1..192") if not sd_safe(path) then return nil, "bad path" end if cfg.sd_nocard then return nil, "no sd" end local node = sd_node(path) if not node then return nil, "not found" end if not node.dir then return nil, "not a directory" end cfg.sd_calls = cfg.sd_calls or {} cfg.sd_calls[#cfg.sd_calls + 1] = { path = path, start = start, max = max } local out, count, i = {}, 0, start while node.order[i] do if count >= max then out.truncated = true; out.next = start + count; break end local name = node.order[i] local child = node.dir[name] count = count + 1 out[count] = { name = name, type = child.dir and "dir" or "file", size = child.data and #child.data or 0, mtime = 0 } i = i + 1 end return out end local function sdmock_check(path) checkstr(path, "sd.check path") local node, err = sd_file(path) if not node then return nil, err end return sd_classify(path, node) or false end local function sdmock_remove(path) checkstr(path, "sd.remove path") local node, err = sd_file(path) if not node then return nil, err end local why = sd_classify(path, node) if not why then return nil, "not a threat" end if cfg.sd_remove_fail and cfg.sd_remove_fail[path] then return nil, cfg.sd_remove_fail[path] end local parent = sd_node(path:match("^(.*)/[^/]+$") ~= "" and path:match("^(.*)/[^/]+$") or "/") local name = path:match("[^/]+$") parent.dir[name] = nil for i, n in ipairs(parent.order) do if n == name then table.remove(parent.order, i); break end end cfg.sd_removed = cfg.sd_removed or {} cfg.sd_removed[#cfg.sd_removed + 1] = path return why end -- Build a card from { ["/path/file"] = "bytes" | true (an empty folder) }. local function mktree(files) local root = { dir = {}, order = {} } local paths = {} for path in pairs(files) do paths[#paths + 1] = path end table.sort(paths) for _, path in ipairs(paths) do local node, segs = root, {} for seg in path:gmatch("[^/]+") do segs[#segs + 1] = seg end for i, seg in ipairs(segs) do if not node.dir[seg] then local is_dir = i < #segs or files[path] == true node.dir[seg] = is_dir and { dir = {}, order = {} } or { data = files[path] } node.order[#node.order + 1] = seg end node = node.dir[seg] end end return root end local function sd_exists(path) return sd_node(path) ~= nil end local function build_wada() local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} } wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B } wada.ui.canvas = mkcanvas wada.ui.label = mklabel wada.ui.button = mkbutton wada.ui.scroll = function(on) widgets.scroll = (on == nil) or on end wada.ui.text_h = function(sz) return (sz or 12) + 4 end -- upstream's exact measurement: ~0.48 of the line height per character wada.ui.text_w = function(str, sz) local n = 0 for _ in str:gmatch('[%z\1-\127\194-\244]') do n = n + 1 end return math.floor(n * ((sz or 12) + 4) * 0.48) end wada.ui.chart = function() error("chart not mocked") end wada.ui.list = mklist wada.ui.clear = function() labels, buttons, lists = {}, {}, {}; widgets.cleared = (widgets.cleared or 0) + 1 end wada.ui.text_lines = function(str, width, sz) checkstr(str, "text_lines"); checkint(width, "text_lines width") if width <= 0 then return 1 end local lines = 0 for part in (tostring(str) .. "\n"):gmatch("(.-)\n") do local w = wada.ui.text_w(part, sz or 14) lines = lines + math.max(1, (w + width - 1) // width) end return lines end wada.sys.millis = function() return clock_ms end wada.sys.keep_awake = function(on) cfg.awake = (on == nil) or on end wada.sys.toast = function(msg, ms) checkstr(msg, "toast"); toasts[#toasts + 1] = tostring(msg) end wada.sys.board = function() return { w = cfg.w, h = cfg.h, touch = cfg.caps.touch } end wada.sys.random = function(lo, hi) lo = lo or 0; hi = hi or 0; if hi <= lo then return 12345 end return lo end wada.sys.epoch = function() return nil end wada.sys.datetime = function() return nil end wada.sys.beep = function() return false end wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard, touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass, accel = cfg.caps.accel, discover = cfg.caps.discover, sd_list = cfg.caps.sd_list or false, sd_clean = cfg.caps.sd_clean or false, audio = cfg.caps.audio or false, audio_wav = cfg.caps.audio or false, audio_mp3 = cfg.caps.audio or false, audio_sd = cfg.caps.audio_sd or false } end if cfg.caps.sdk_ext then wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end end if cfg.caps.compass then wada.sys.compass = function() return cfg.compass and cfg.compass() or nil end end if cfg.caps.accel then wada.sys.accel = function() return cfg.accel and cfg.accel() or { x = 0, y = 0, z = 1 } end end wada.mesh.contacts = function() return cfg.contacts or {} end wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end wada.mesh.stats = function() return {} end wada.mesh.rx_log = function() return {} end wada.mesh.discover = function() return "probe-1" end wada.mesh.discovered = function() return cfg.discovered or {} end wada.mesh.discover_clear = function() cfg.discovered = {} end wada.fs.append = function(name, data) checkstr(name, "fs.append name"); checkstr(data, "fs.append data"); return true end wada.fs.remove = function(name) checkstr(name, "fs.remove name"); return true end wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string") assert(v == nil or type(v) == "string" or type(v) == "number", "store values must be strings or numbers") storekv[k] = v end wada.timer.every = function(ms) checkint(ms, "timer.every"); if ms < 33 then ms = 33 end; widgets.timer_ms = ms end wada.timer.stop = function() widgets.timer_ms = nil end if cfg.caps.audio then wada.audio = {} wada.audio.play = function(path) assert(type(path) == "string", "audio.play path must be a string") local source = "app" if path:sub(1, 3) == "sd:" then if not cfg.caps.audio_sd then return nil, "no sd" end local card_path = path:sub(4) if card_path:sub(1, 1) ~= "/" or card_path:find("//", 1, true) or card_path:find("/../", 1, true) or card_path:sub(-3) == "/.." or card_path:sub(-2) == "/." or card_path:sub(-1) == "/" then return nil, "bad path" end source = "sd" elseif #path == 0 or #path > 32 or path:sub(1, 1) == "." or not path:match("^[A-Za-z0-9._-]+$") then return nil, "bad path" end local format = path:lower():sub(-4) if format ~= ".wav" and format ~= ".mp3" then return nil, "unsupported format" end audio_state = { state = "playing", path = path, source = source, format = format:sub(2), error = nil } audio_log[#audio_log + 1] = "play:" .. path return true end wada.audio.pause = function() if audio_state.state ~= "playing" then return false end audio_state.state = "paused"; audio_log[#audio_log + 1] = "pause"; return true end wada.audio.resume = function() if audio_state.state ~= "paused" then return false end audio_state.state = "playing"; audio_log[#audio_log + 1] = "resume"; return true end wada.audio.stop = function() if audio_state.state ~= "playing" and audio_state.state ~= "paused" then return false end audio_state.state = "stopped"; audio_log[#audio_log + 1] = "stop"; return true end wada.audio.status = function() local out = {} for key, value in pairs(audio_state) do out[key] = value end return out end end if cfg.caps.sd_list then wada.sd = { list = sdmock_list } if cfg.caps.sd_clean then wada.sd.check = sdmock_check; wada.sd.remove = sdmock_remove end end return wada end -- ---- scenario driver ------------------------------------------------------------- local function load_app() local chunk, err = loadfile(APP_PATH) assert(chunk, err) local app assert(guarded(INIT_BUDGET, function() app = chunk() end), "chunk failed") assert(type(app) == "table", "app did not return a table") return app end local function send(app, ev) if app.on_input then guarded(BUDGET, app.on_input, ev) end end local function tick(app, n, dt) for _ = 1, (n or 1) do clock_ms = clock_ms + (dt or 150); if app.on_tick then guarded(BUDGET, app.on_tick, dt or 150) end end end local function key(app, k, code) send(app, { type = "key", key = k, code = code or (k and k:byte()) or 0 }) end local function swipe(app, dir) send(app, { type = "swipe", dir = dir, x = 0, y = 0 }) end local function tap(app, x, y) send(app, { type = "down", x = x, y = y }); send(app, { type = "up", x = x, y = y }) end local function drag(app, x, y, x2, y2, dir) send(app, { type = "down", x = x, y = y }); send(app, { type = "swipe", dir = dir, x = 0, y = 0 }); send(app, { type = "up", x = x2, y = y2 }) end local function enter(app, w, h) tap(app, w // 2, h // 2); key(app, "enter", 13) end local function label_dump() local out = {} for _, l in ipairs(labels) do if l.text ~= "" then out[#out + 1] = l.text end end return table.concat(out, " | ") end local function reset_world() widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil } labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 } lists = {} clock_ms = 1000 audio_state = { state = "stopped", path = "", source = "", format = "", error = nil } audio_log = {} end -- simulated magnetometer: Earth field 0.45 G at true heading `deg` (device frame == -- sensor frame, orient 0), plus a hard-iron bias; tilt ignored. local BIAS = { x = 1.5, y = -0.8, z = 0.3 } -- the real M9's accelerometer reads ~0.08 g high on Y lying flat: about 5 -- degrees of tilt that is not there local ABIAS = { x = -0.02, y = 0.08, z = -0.01 } -- Physical simulation of the M9 in a known attitude, in the MEASURED frames: -- body/accel: +X top edge (forward), +Y right edge, +Z into the screen (down) -- magnetometer: +Y forward, +X left, +Z down -> raw = (-by, bx, bz) + bias -- Field: San Francisco-like, 0.24 G horizontal north, 0.42 G down (dip ~60). -- The accelerometer reads specific force, so the skyward axis reads +1 and the -- level device reads z = -1, exactly as the real one was measured to. local BH, BV = 0.24, 0.42 local function attitude(hdg, roll, pitch) local h, r, p = math.rad(hdg), math.rad(roll or 0), math.rad(pitch or 0) -- earth (north, east, down) -> body, ZYX yaw-pitch-roll local function e2b(n, e, d) local x1 = n * math.cos(h) + e * math.sin(h) -- yaw local y1 = -n * math.sin(h) + e * math.cos(h) local z1 = d local x2 = x1 * math.cos(p) - z1 * math.sin(p) -- pitch local y2 = y1 local z2 = x1 * math.sin(p) + z1 * math.cos(p) local x3 = x2 -- roll local y3 = y2 * math.cos(r) + z2 * math.sin(r) local z3 = -y2 * math.sin(r) + z2 * math.cos(r) return x3, y3, z3 end local bx, by, bz = e2b(BH, 0, BV) -- magnetic field in body axes local gx, gy, gz = e2b(0, 0, 1) -- "down" in body axes return { x = -by + BIAS.x, y = bx + BIAS.y, z = bz + BIAS.z }, -- magnetometer raw { x = -gx + ABIAS.x, y = -gy + ABIAS.y, z = -gz + ABIAS.z } -- accelerometer raw end local contacts_fixture = { { name = "Repeater A", type = 2, ago_s = 10, lat = 37.80000, lon = -122.40000 }, { name = "Bob", type = 1, ago_s = 300, lat = 37.70000, lon = -122.50000 }, { name = "NoPos", type = 1, ago_s = 5, lat = 0, lon = 0 }, { name = "Carol", type = 1, ago_s = 60, lat = 38.00000, lon = -122.00000 }, } local scenarios = {} scenarios.wardrive_utf8 = function() cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true }, discovered = { { pubkey = "01020304", name = "Ouderkerk☀️", type = 2, rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 } } } cfg.gps = function() return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000, sats = 9, alt_m = 3, time = 1787620000 } end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing") assert(guarded(BUDGET, buttons[1].fn)) tick(app, 1, 100) tick(app, 1, 4000) local found = false for _, label in ipairs(labels) do assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8") if label.text:find("Ouderkerk☀", 1, true) then found = true end end assert(found, "emoji-bearing repeater name was not rendered") if app.on_close then guarded(BUDGET, app.on_close) end end -- The declination model, as it actually ships. Rather than testing a copy in -- out/wmm, this pulls the do-block straight out of the app file that gets -- sideloaded, so an inlining mistake fails here instead of on the device. -- Reference values are NOAA's own calculator for WMM2025 at epoch 2026.6. scenarios.declination = function() local src = assert(io.open(APP_PATH)):read("a") local a = src:find("-- WMM-GEN BEGIN", 1, true) assert(a, "the declination module is missing from the app") local e = src:find("end -- WMM-GEN END", a, true) assert(e, "the generated block is not closed by its END marker") local chunk = src:sub(a, e + #"end -- WMM-GEN END") .. "\nreturn declination" local declination = assert(load(chunk, "decl"))() local REF = { { 37.75, -122.45, 12.8470, 22928.0, "harness fixture SF" }, { 43.66, -70.26, -14.4653, 20159.9, "Portland ME" }, { -33.87, 151.21, 12.8236, 24625.1, "Sydney" }, { 64.13, -21.90, -11.0771, 13242.4, "Reykjavik" }, { -70.00, 100.00, -103.9366, 12624.7, "Antarctic (steep)" }, { 82.00, -100.00, -34.1519, 1905.5, "82N: weak-field zone" }, } local worst = 0 for _, r in ipairs(REF) do local lat, lon, want_d, want_h, name = r[1], r[2], r[3], r[4], r[5] local got_d, got_h = declination(lat, lon, 2026.6) local err = math.abs(((got_d - want_d + 540) % 360) - 180) if err > worst then worst = err end print(string.format(" %-22s %9.4f vs %9.4f (%.4f) H=%.0f nT", name, got_d, want_d, err, got_h)) assert(err < 0.02, "declination wrong at " .. name) assert(math.abs(got_h - want_h) < 30, "horizontal field wrong at " .. name) end print(string.format(" worst error vs NOAA: %.4f deg", worst)) -- the secular variation has to be live, or this is a frozen 2025 snapshot local d25 = declination(43.66, -70.26, 2025.0) local d30 = declination(43.66, -70.26, 2030.0) assert(math.abs(d30 - d25) > 0.2, string.format("secular variation is not applied (%.3f -> %.3f)", d25, d30)) print(string.format(" Portland ME drifts %+.2f deg across the model's 5 years", d30 - d25)) end -- Pressing A means "this direction is TRUE north". With no fix the app has no -- declination to work with, so the offset it stores quietly contains one; if -- the model then added its own the heading would be wrong by twice the -- declination -- the very error that sent every marker 22 deg west, but on the -- dial instead. The offset has to give the declination back when the fix lands. scenarios.align_nofix = function() local FIX_DECL = 12.847 -- no self position either: on a real node the stored one is usually enough to -- get a declination before GPS locks, so being truly blind takes both gone cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true }, contacts = contacts_fixture } local true_heading, true_roll, true_pitch = 0, 0, 0 cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end local have_fix = false cfg.gps = function() if not have_fix then return nil end return { lat = 37.75, lon = -122.45, sats = 9, alt_m = 42 } end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) key(app, "c") -- calibrate with no fix at all for i = 1, 140 do true_heading = (i * 360 / 23) % 360 true_roll = (i * 360 / 11) % 360 true_pitch = ((i * 360 / 17) % 360) - 180 tick(app, 1, 150) end true_roll, true_pitch = 0, 0 assert(storekv.cal_ox, "calibration must still work with no GPS") local function shown() tick(app, 15) return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end -- the user believes they are facing true north and says so true_heading = 30 -- i.e. magnetic 30; with no model the app cannot know tick(app, 15) -- settle: A reads the live tilt, not the sweep's last key(app, "a"); tick(app, 4) assert(storekv.align_pd, "an offset set with no declination must be remembered as such") assert(toasts[#toasts]:find("magnetic"), "the toast must admit it: " .. tostring(toasts[#toasts])) assert(math.abs(((shown() - 0 + 540) % 360) - 180) <= 2, "must read 000 where north was set") assert(drawlog.text[#drawlog.text - 1] == "M", "with no model the dial is magnetic") -- the fix arrives: the model now knows the declination the offset swallowed have_fix = true tick(app, 20) assert(not storekv.align_pd, "the pending flag must clear once the model has a value") assert(math.abs(((shown() - 0 + 540) % 360) - 180) <= 2, string.format("the fix must not move a direction the user already fixed (%d)", shown())) assert(drawlog.text[#drawlog.text - 1] == "T", "the dial must repaint as TRUE the moment the model has a declination") print(string.format(" align %d, decl %+.1f: %d before the fix, %d after", storekv.align, FIX_DECL, 0, shown())) -- and a quarter turn still counts a quarter turn true_heading = 30 + 90 assert(math.abs(((shown() - 90 + 540) % 360) - 180) <= 3, "a right turn must count UP") app.on_close() end -- Is a contact MIRRORED rather than merely rotated? A flip and a rotation look -- identical at one heading, so the existing marker test cannot tell them apart: -- it checks the dot against the app's OWN bearing, which stays self-consistent -- even if that bearing has east and west swapped. This checks the bearing -- against an absolute compass direction instead -- contacts placed due north, -- east, south, west and north-east of the fixture -- and then checks the dot -- against it. A longitude sign error puts east at 270; a lat/lon transpose -- puts north-east at 51.7 instead of 38.3. scenarios.bearings_absolute = function() local ME_LAT, ME_LON = 37.75, -122.45 local POINTS = { { name = "N", lat = 37.85, lon = -122.45, brg = 0.000 }, { name = "E", lat = 37.75, lon = -122.30, brg = 89.954 }, { name = "S", lat = 37.65, lon = -122.45, brg = 180.000 }, { name = "W", lat = 37.75, lon = -122.60, brg = 270.046 }, { name = "NE", lat = 37.85, lon = -122.35, brg = 38.284 }, } local marks = {} for i, pt in ipairs(POINTS) do marks[i] = { name = pt.name, type = 1, ago_s = 10, lat = pt.lat, lon = pt.lon } end cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true }, contacts = marks, self_lat = ME_LAT, self_lon = ME_LON } local true_heading, true_roll, true_pitch = 0, 0, 0 cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end cfg.gps = function() return { lat = ME_LAT, lon = ME_LON, sats = 9, alt_m = 42 } end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) key(app, "c") -- calibrate so the dial is live for i = 1, 140 do true_heading = (i * 360 / 23) % 360 true_roll = (i * 360 / 11) % 360 true_pitch = ((i * 360 / 17) % 360) - 180 tick(app, 1, 150) end true_roll, true_pitch = 0, 0 true_heading = 0 tick(app, 20) local D2 = 144 / 2 local by_name = {} for _, pt in ipairs(POINTS) do by_name[pt.name] = pt end local seen = 0 -- the app picks its own target order (nearest first), so go by the name it -- reports rather than assuming the contact list order for _ = 1, #POINTS do swipe(app, "right"); tick(app, 6) local dump = label_dump() local shown_name = dump:match("TGT | ([%w]+) |") or dump:match("TGT | ([%w]+)") local pt = by_name[shown_name] assert(pt, "unexpected target selected: " .. tostring(shown_name)) by_name[shown_name] = nil; seen = seen + 1 local brg = tonumber(dump:match("mi%s+(%d%d%d)") or dump:match("km%s+(%d%d%d)") or dump:match("m%s+(%d%d%d)")) assert(brg, "could not read a bearing for " .. pt.name .. ": " .. dump:sub(1, 130)) local err = math.abs(((brg - pt.brg + 540) % 360) - 180) -- the drawn dot, as a screen angle clockwise from up local dot for _, c in ipairs(drawlog.circles) do if c.col == 0xE8A33D and c.filled then dot = c end end assert(dot, "no marker drawn for " .. pt.name) local ang = math.deg(math.atan(dot.x - D2, -(dot.y - D2))) % 360 local hdg = tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) local want_ang = (pt.brg - hdg) % 360 local aerr = math.abs(((ang - want_ang + 540) % 360) - 180) print(string.format(" %-2s (%s): bearing %3d want %6.2f err %4.1f | dot %6.1f want %6.2f err %4.1f", pt.name, tostring(shown_name), brg, pt.brg, err, ang, want_ang, aerr)) assert(err <= 1.5, string.format("%s reads %d, should be %.1f -- bearings are wrong, not just offset", pt.name, brg, pt.brg)) assert(aerr <= 3, string.format("%s is drawn %.1f deg from where its own bearing puts it", pt.name, aerr)) end assert(seen == #POINTS, "every contact must be reachable, saw " .. seen) app.on_close() end scenarios.m9 = function() local FIX_DECL = 12.847 -- WMM2025 at the fixture position, epoch 2026.6 cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true }, contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 } local true_heading, true_roll, true_pitch = 0, 0, 0 cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end local moving = false cfg.gps = function() local g = { lat = 37.75000, lon = -122.45000, sats = 9, alt_m = 42 } if moving then g.speed_kmh = 23.4; g.course = 215.0 end return g end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) print(" widgets:", "canvases=" .. widgets.canvases, "labels=" .. widgets.labels, "buttons=" .. widgets.buttons, "timer=" .. tostring(widgets.timer_ms)) assert(widgets.timer_ms and widgets.timer_ms >= 33) tick(app, 10) print(" uncal:", label_dump()) -- calibration: press C, rotate through 360 over 20 s, auto-finish key(app, "c") assert(cfg.awake == true, "calibration must hold the screen awake") for i = 1, 140 do true_heading = (i * 360 / 23) % 360 -- rotated every way, in one place true_roll = (i * 360 / 11) % 360 true_pitch = ((i * 360 / 17) % 360) - 180 tick(app, 1, 150) end true_roll, true_pitch = 0, 0 assert(cfg.awake == false, "the hold must be released when calibration ends") assert(storekv.cal_ox, "calibration did not persist; last toast: " .. tostring(toasts[#toasts])) print(string.format(" cal offsets: %.3f %.3f r=%.3f (bias %.3f %.3f)", storekv.cal_ox, storekv.cal_oy, storekv.cal_r, BIAS.x, BIAS.y)) assert(math.abs(storekv.cal_ox - BIAS.x) < 0.02 and math.abs(storekv.cal_oy - BIAS.y) < 0.02 and math.abs(storekv.cal_oz - BIAS.z) < 0.02, "hard-iron offsets wrong") assert(storekv.cal_r and math.abs(storekv.cal_r - 0.484) < 0.02, "fitted field radius wrong: " .. tostring(storekv.cal_r)) -- Heading accuracy after calibration. The simulated field points at MAGNETIC -- north, so a correct app shows magnetic + declination: at the fixture's -- position (37.75, -122.45) WMM2025 gives +12.85 deg east for 2026.6, which -- is the clock the app falls back to because this harness returns no -- datetime. Asserting the offset is present is the regression test for the -- fault Chris saw on hardware -- every contact sitting ~22 deg west. for _, th in ipairs({ 0, 45, 90, 180, 270, 359 }) do true_heading = th; tick(app, 15) -- drawn order at the centre: digits, degree sign, T/M reference, cardinal local shown = drawlog.text[#drawlog.text - 3] local num = tonumber(shown:match("^(%d+)$")) assert(num, "expected bare digits at the dial centre, got: " .. tostring(shown)) assert(drawlog.text[#drawlog.text - 2] == "\194\176", "degree sign must follow the digits") assert(drawlog.text[#drawlog.text - 1] == "T", "with a fix the heading must be marked true, got: " .. tostring(drawlog.text[#drawlog.text - 1])) local want = (th + FIX_DECL) % 360 local err = math.abs(((num - want + 540) % 360) - 180) print(string.format(" mag %3d -> true %6.1f shown %s (err %.0f)", th, want, shown, err)) assert(err <= 2, "heading error too large at " .. th) end -- targets: d-pad right/left arrive as swipes; enter = tap at body centre swipe(app, "right"); tick(app, 2); print(" target1:", label_dump()) swipe(app, "right"); swipe(app, "right"); swipe(app, "right"); tick(app, 2); print(" wrap:", label_dump()) swipe(app, "left"); tick(app, 2) -- OK (synthetic down/up + an enter key event) switches the SELECTED row's -- units exactly once; up/down moves the selection between ALT and SPD assert(storekv.u_alt == nil and label_dump():find("ft"), "altitude must default to imperial") enter(app, cfg.w, cfg.h); tick(app, 2) assert(storekv.u_alt == 0 and label_dump():find("42 m"), "OK must switch altitude to metric exactly once: " .. label_dump()) enter(app, cfg.w, cfg.h); tick(app, 2) assert(storekv.u_alt == 1 and label_dump():find("ft"), "OK must switch it back") swipe(app, "down"); tick(app, 1); clock_ms = clock_ms + 400 enter(app, cfg.w, cfg.h); tick(app, 2) assert(storekv.u_spd == 0 and storekv.u_alt == 1, "down then OK must switch SPEED, not altitude") swipe(app, "up"); tick(app, 1); clock_ms = clock_ms + 400 enter(app, cfg.w, cfg.h); tick(app, 2) assert(storekv.u_alt == 0, "up then OK must switch altitude again") enter(app, cfg.w, cfg.h); tick(app, 2) -- back to imperial for the rest swipe(app, "right"); tick(app, 2); print(" target:", label_dump()) -- saturation flag: heading must hold, src line must say so local keep = cfg.compass cfg.compass = function() local m = attitude(true_heading, 0, 0); m.ovfl = true; return m end tick(app, 3); print(" saturated:", label_dump()); assert(label_dump():find("Field saturated")) cfg.compass = keep; tick(app, 3); assert(not label_dump():find("Field saturated")) -- every readout string must fit the 320-wide column (~23 glyphs at 12 px) for _, l in ipairs(labels) do if l.x ~= 0 then assert(#l.text <= 23, "too wide for the M9 column: " .. l.text) end end -- orientation / flip / clear / unknown keys -- A PARTIAL turn must be REFUSED: an arc leaves the centre almost anywhere, -- which is exactly how a silently-accepted bad fit broke north on hardware. do local saved = { storekv.cal_ox, storekv.cal_oy } key(app, "c") for i = 1, 80 do true_heading = (i * 60 / 80) % 360; tick(app, 1, 150) end -- flat, 60 deg only key(app, "c"); tick(app, 2) assert(toasts[#toasts]:find("Not calibrated"), "a partial turn must be refused, got: " .. toasts[#toasts]) assert(storekv.cal_ox == saved[1] and storekv.cal_oy == saved[2], "a refused calibration must not overwrite the stored one") end -- the measured default must be right out of the box: no A press, no offset do local function shown0() return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end for _, th in ipairs({ 0, 90, 180, 270 }) do true_heading = th; tick(app, 15) local err = math.abs(((shown0() - (th + FIX_DECL) + 540) % 360) - 180) assert(err <= 2, string.format("default mapping wrong at %d: showed %d", th, shown0())) end assert(storekv.align == nil or storekv.align == 0, "no offset should be needed by default") assert(storekv.mirror == nil or storekv.mirror == 0, "no mirror should be needed by default") end -- the accelerometer's own zero-g offset must come out of the same sweep, -- or the tilt correction is applied against a gravity vector that is 5 -- degrees wrong assert(storekv.acc_by and math.abs(storekv.acc_by - ABIAS.y) < 0.02, "accel bias not recovered: " .. tostring(storekv.acc_by)) do true_heading, true_roll, true_pitch = 0, 0, 0 tick(app, 10) local t = label_dump():match("north%s+tilt%s+(%d+)") -- "MAG north tilt 0°" assert(t and tonumber(t) <= 2, "flat device should read ~0 tilt, got: " .. tostring(t)) end -- TILT COMPENSATION, the whole point of the IMU: heading must hold while the -- device is tipped. Without it the error is ~1.5 deg per deg of tilt here. do local function shown() return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end for _, hdg in ipairs({ 0, 90, 200 }) do true_heading, true_roll, true_pitch = hdg, 0, 0 tick(app, 15) local level = shown() for _, tilt in ipairs({ { 20, 0 }, { 0, 20 }, { -15, 15 } }) do true_roll, true_pitch = tilt[1], tilt[2] tick(app, 15) local err = math.abs(((shown() - level + 540) % 360) - 180) assert(err <= 3, string.format("tilt %d/%d at heading %d moved the reading %d deg (%d -> %d)", tilt[1], tilt[2], hdg, err, level, shown())) end true_roll, true_pitch = 0, 0 end print(" tilt: heading held within 3 deg through 20 deg of roll and pitch") end -- WAYPOINT PLACEMENT: with a known heading and a known target bearing, the -- amber dot must sit at (bearing - heading) clockwise from the top of the -- dial. AMBER is 0xE8A33D; the dial centre is (D/2, D/2). do true_heading, true_roll, true_pitch = 0, 0, 0 swipe(app, "right"); tick(app, 12) -- pick the first target print(" rows: " .. label_dump()) -- the target row reads like "7.09 km brg 038° NE": take THAT number, not -- the first degree sign on the page (the course readout also has one) local brg = tonumber((label_dump():match("mi%s+(%d%d%d)")) or (label_dump():match("km%s+(%d%d%d)"))) assert(brg, "could not read the target bearing from the panel") for _, hdg in ipairs({ 0, 90, 210 }) do true_heading = hdg; tick(app, 20) local dot for _, c in ipairs(drawlog.circles) do if c.col == 0xE8A33D and c.filled then dot = c end end assert(dot, "no waypoint marker drawn") -- screen angle of the dot, clockwise from up, around the dial centre local D2 = 144 / 2 -- landscape D on the M9 body local ang = math.deg(math.atan(dot.x - D2, -(dot.y - D2))) % 360 local want = (brg - hdg) % 360 local err = math.abs(((ang - want + 540) % 360) - 180) print(string.format(" waypoint: heading %3d bearing %3d -> want %3d, drawn %6.1f, err %.1f", hdg, brg, want, ang, err)) end end -- DIAGNOSTICS must fit: at 12 px the panel is ~22 characters wide in normal -- layout and ~26 with the key column reclaimed, and a longer line wraps onto -- the row beneath it. do key(app, "d"); tick(app, 4) for _, l in ipairs(labels) do if l.text ~= "" and l.x ~= 0 then assert(#l.text <= 26, "diagnostic row too wide (" .. #l.text .. "): " .. l.text) end end print(" diag rows: " .. label_dump()) key(app, "d"); tick(app, 2) end -- align north: with the device pointing at an arbitrary true heading, one -- press must make THAT direction read 000 and keep the dial turning the -- right way (a later true heading must read back as itself minus the offset) true_heading = 137; tick(app, 8) key(app, "a"); tick(app, 8) assert(storekv.align ~= nil, "align must persist") assert(storekv.mirror == nil, "mirror is gone: the frames are measured, not hand-flipped") assert(toasts[#toasts] == "North set here", "A must set the offset: " .. tostring(toasts[#toasts])) local function shown_deg() return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end assert(math.abs(((shown_deg() - 0 + 540) % 360) - 180) <= 2, "the aligned direction must read 000") true_heading = 137 + 90; tick(app, 15) assert(math.abs(((shown_deg() - 90 + 540) % 360) - 180) <= 3, "a right turn must count UP") -- F and O must do NOTHING now: both frames are measured, so a key that -- hand-flips handedness can only break a correct compass local before_f = { storekv.align, storekv.mirror, storekv.orient, storekv.flip } key(app, "f"); key(app, "F"); key(app, "o"); key(app, "O"); tick(app, 5) assert(storekv.align == before_f[1] and storekv.mirror == before_f[2] and storekv.orient == before_f[3] and storekv.flip == before_f[4], "F and O must have no effect") key(app, "a"); tick(app, 8) true_heading = 0 key(app, nil, 0x87); key(app, "backspace", 8); key(app, "z"); tick(app, 2) -- moving: GPS course appears in the readout while the compass still drives the rose moving = true; tick(app, 5); print(" moving:", label_dump()) -- compass drops out -> GPS course takes over, then nothing cfg.compass = function() return nil end; tick(app, 15); print(" mag lost:", label_dump()) assert(label_dump():find("GPS course"), "expected GPS-course fallback") moving = false; tick(app, 5); print(" still:", label_dump()) cfg.gps = function() return nil end; tick(app, 5); print(" no fix:", label_dump()) key(app, "x"); tick(app, 1); assert(storekv.cal_ox == nil, "clear cal") if app.on_close then guarded(BUDGET, app.on_close) end print(" toasts:", table.concat(toasts, " / ")) end scenarios.r8 = function() cfg = { w = 240, h = 276, caps = { sdk_ext = true, keyboard = false, touch = true, compass = false }, contacts = contacts_fixture, self_lat = 0, self_lon = 0 } local moving = true cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 6, alt_m = 10, speed_kmh = 5.5, course = 90.0 } end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) print(" widgets:", "canvases=" .. widgets.canvases, "labels=" .. widgets.labels, "buttons=" .. widgets.buttons, "scroll=" .. tostring(widgets.scroll)) assert(widgets.buttons == 1, "portrait touch board without compass should get one button") tick(app, 10); print(" gps heading:", label_dump()) assert(label_dump():find("GPS course")) tap(app, 120, 60); tick(app, 2); print(" tap rose:", label_dump()) drag(app, 120, 60, 40, 62, "left"); tick(app, 2) -- swipe handled once (left), not also as a tap print(" drag:", label_dump()); assert(label_dump():find("none"), "swipe must cycle exactly once") local before = label_dump() send(app, { type = "down", x = 120, y = 60 }); send(app, { type = "up", x = 150, y = 60 }); tick(app, 1) -- 30 px drag, no swipe event assert(label_dump() == before, "a drag without a swipe must be ignored") tap(app, 120, 270); tick(app, 2) -- below the rose: no-op buttons[1].fn(); tick(app, 2); print(" button:", label_dump()) -- portrait rows sit under the dial: ALT/SPD near y=280/298, so tap those to -- switch units and something well clear of them to prove it is bounded local u0 = storekv.u_alt tap(app, 20, 282); tick(app, 2) assert(storekv.u_alt ~= u0, "a tap on the ALT row must switch its units") local u1, s1 = storekv.u_alt, storekv.u_spd tap(app, 20, 440); tick(app, 2) -- well below every row assert(storekv.u_alt == u1 and storekv.u_spd == s1, "a tap outside the rows must not change units") local b2 = label_dump(); swipe(app, "right"); swipe(app, "right"); tick(app, 1) -- duplicate within 300 ms print(" dbl swipe:", label_dump()); assert(label_dump() ~= b2, "first swipe must count") clock_ms = clock_ms + 400; local b3 = label_dump(); swipe(app, "right"); tick(app, 1); assert(label_dump() ~= b3, "a later swipe must count") if app.on_close then guarded(BUDGET, app.on_close) end end scenarios.v4 = function() cfg = { w = 240, h = 276, caps = { sdk_ext = false, keyboard = false, touch = true, compass = false }, contacts = {}, self_lat = 37.1, self_lon = -122.1 } storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) tick(app, 10); print(" base sdk:", label_dump()) swipe(app, "right"); tick(app, 1) if app.on_close then guarded(BUDGET, app.on_close) end end scenarios.pager = function() cfg = { w = 480, h = 178, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false }, contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 } cfg.gps = function() return nil end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) tick(app, 10); print(" pager:", label_dump()) key(app, "c"); tick(app, 1) if app.on_close then guarded(BUDGET, app.on_close) end end scenarios.pager_portrait_jumbo = function() cfg = { w = 222, h = 436, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false }, contacts = { { name = "WADAMESH-BASE-NODE-EXTRA", type = 2, ago_s = 1, lat = 37.8, lon = -122.4 } }, self_lat = 37.75, self_lon = -122.45 } cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 7, alt_m = 5, speed_kmh = 12.3, course = 215 } end storekv = {} wada = build_wada(); wada.ui.text_h = function(sz) return ({ [12] = 21, [14] = 22, [16] = 27 })[sz] or 21 end local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) tick(app, 3); swipe(app, "right"); tick(app, 3); print(" pager-portrait-jumbo:", label_dump()) for _, l in ipairs(labels) do if l.x ~= 0 then assert(#l.text <= 19, "too wide for a 210 px column at 18 px: " .. l.text) end end if app.on_close then guarded(BUDGET, app.on_close) end end scenarios.tanmatsu = function() cfg = { w = 480, h = 756, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false }, contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 } cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 12, alt_m = 1203 } end storekv = {} wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) tick(app, 5); swipe(app, "right"); tick(app, 5); print(" tanmatsu:", label_dump()) if app.on_close then guarded(BUDGET, app.on_close) end end scenarios.audio_api = function() cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, audio = true, audio_sd = true, sd_list = true } } wada = build_wada() local caps = wada.sys.caps() assert(caps.audio and caps.audio_wav and caps.audio_mp3 and caps.audio_sd, "audio capability flags do not match the WAV/MP3 contract") assert(wada.audio and wada.audio.play and wada.audio.pause and wada.audio.resume and wada.audio.stop and wada.audio.status, "wada.audio API is incomplete") assert(wada.audio.play("track.wav")) local status = wada.audio.status() assert(status.state == "playing" and status.path == "track.wav" and status.source == "app" and status.format == "wav", "app-storage play status is wrong") assert(wada.audio.pause() and wada.audio.status().state == "paused", "pause failed") assert(wada.audio.resume() and wada.audio.status().state == "playing", "resume failed") assert(wada.audio.play("next.wav") and wada.audio.status().path == "next.wav", "a second play must replace the active track") assert(wada.audio.play("sd:/Music/card.wav")) assert(wada.audio.status().source == "sd", "sd: source was not reported") local value, err = wada.audio.play("../escape.wav") assert(value == nil and err == "bad path", "app sandbox traversal was accepted") value, err = wada.audio.play("sd:/Music/../escape.wav") assert(value == nil and err == "bad path", "SD traversal was accepted") assert(wada.audio.play("track.mp3") and wada.audio.status().format == "mp3", "MP3 playback was not accepted or reported") value, err = wada.audio.play("track.flac") assert(value == nil and err == "unsupported format", "unknown audio format was accepted") assert(wada.audio.stop() and wada.audio.status().state == "stopped", "stop failed") assert(wada.audio.play("close.wav")) audio_host_close() assert(wada.audio.status().state == "stopped" and audio_log[#audio_log] == "release", "host close did not release playback") print(" transport + storage sandbox: PASS (" .. #audio_log .. " host calls)") if APP_PATH:match("audio_test%.lua$") then local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(#buttons == 5, "audio test app did not create all transport/source buttons") buttons[1].fn(); assert(wada.audio.status().state == "playing", "Play button failed") buttons[2].fn(); assert(wada.audio.status().state == "paused", "Pause button failed") buttons[3].fn(); assert(wada.audio.status().state == "playing", "Resume button failed") buttons[4].fn(); assert(wada.audio.status().state == "stopped", "Stop button failed") buttons[5].fn(); buttons[1].fn() assert(wada.audio.status().source == "app", "source switch did not select app storage") guarded(BUDGET, app.on_tick) guarded(BUDGET, app.on_close) assert(wada.audio.status().state == "stopped", "test app close did not stop playback") print(" scripts/audio_test.lua UI: PASS") end end -- instruction cost of one heavy tick (redraw forced every tick by spinning the heading) scenarios.cost = function() cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true }, contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 } local th = 0 cfg.compass = function() th = th + 7; return (attitude(th, 0, 0)) end cfg.accel = function() local _, a = attitude(th, 0, 0); return a end cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 9, alt_m = 42, speed_kmh = 10, course = 100 } end storekv = { cal_ox = BIAS.x, cal_oy = BIAS.y, cal_oz = BIAS.z, cal_r = 0.484 } wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) swipe(app, "right") local worst = 0 for i = 1, 50 do local n = 0 debug.sethook(function() n = n + 1000 end, "", 1000) clock_ms = clock_ms + 150 if i == 25 then clock_ms = clock_ms + 10000 end -- force a contacts refresh inside a tick local ok, err = pcall(app.on_tick, 150) debug.sethook() assert(ok, err) if n > worst then worst = n end end print(string.format(" worst tick ~%d instructions (budget %d), draw ops so far %d", worst, BUDGET, drawlog.ops)) assert(worst < BUDGET / 4, "tick too expensive") end -- ---- SD Scan (deploy/apps/sdscan) -------------------------------------------- local PNG = "\137PNG\r\n\26\n" .. string.rep("\0", 120) -- A file carrying real DOS + PE headers and nothing else: no code at all. local function pe_bytes(n, e_lfanew) e_lfanew = e_lfanew or 0x80 local t = {} for i = 1, n do t[i] = "\0" end t[1], t[2] = "M", "Z" for k = 0, 3 do t[0x3C + 1 + k] = string.char((e_lfanew >> (8 * k)) & 0xFF) end t[e_lfanew + 1], t[e_lfanew + 2] = "P", "E" return table.concat(t) end local NBSP = "\194\160" -- the worm's blank-looking folder name -- The M9 card as the advisory describes it, plus wadamesh's own data and tiles. local function infected_card(extra) local f = { ["/autorun.inf"] = "[autorun]\r\nopen=" .. NBSP .. "\\svchost.exe\r\n", ["/" .. NBSP .. "/svchost.exe"] = pe_bytes(512), ["/tiles.lnk"] = "L\0\0\0\1\20\2\0" .. string.rep("\0", 80), ["/RECYCLER/S-1-5-21-1004/desktop.dat"] = pe_bytes(300, 0x78), -- a program under a harmless name ["/copyright.png"] = PNG, ["/test file.txt"] = "test", ["/wadamesh/contacts3"] = "MZ" .. string.rep("\165", 200), -- starts with MZ by chance ["/wadamesh/ui_threads_v1.bin"] = string.rep("\1", 300), ["/wadamesh/lang/hu.lang"] = "# ver: 22\n", ["/autorun"] = true, -- an empty "vaccine" folder } for z = 8, 10 do for x = 130, 133 do for y = 80, 83 do f[string.format("/maps/osm/%d/%d/%d.png", z, x, y)] = PNG end end end for k, v in pairs(extra or {}) do f[k] = v end return mktree(f) end local function sd_cfg(w, h, extra_caps) local c = { w = w or 320, h = h or 196, caps = { sdk_ext = true, keyboard = true, touch = false, sd_list = true, sd_clean = true } } for k, v in pairs(extra_caps or {}) do c.caps[k] = v end return c end local function press(text) for i = #buttons, 1, -1 do if buttons[i].text == text then assert(guarded(BUDGET, buttons[i].fn), "button '" .. text .. "' failed") return end end error("no '" .. text .. "' button; screen: " .. label_dump()) end local function has_button(text) for _, b in ipairs(buttons) do if b.text == text then return true end end return false end local function screen_has(text) return label_dump():find(text, 1, true) ~= nil end local function scan_until(app, text, max_ticks) for _ = 1, max_ticks or 4000 do if screen_has(text) then return end tick(app, 1, 50) end error("never reached '" .. text .. "'; screen: " .. label_dump()) end local function found_paths() local out = {} for _, l in ipairs(lists) do for _, r in ipairs(l.rows) do out[#out + 1] = r.text end end return out end local function rows_mention(needle) for _, r in ipairs(found_paths()) do if r:find(needle, 1, true) then return true end end return false end -- Wrapped text on the current screen must end above every button below it. local function assert_no_overlap(where) for _, l in ipairs(labels) do if l.w and l.text ~= "" then local bottom = l.y + wada.ui.text_lines(l.text, l.w, l.size or 12) * wada.ui.text_h(l.size or 12) for _, b in ipairs(buttons) do if b.y > l.y then assert(bottom <= b.y, string.format("%s: text runs into the '%s' button (%d > %d): %s", where, b.text, bottom, b.y, l.text:sub(1, 40))) end end end end end local NOTICE_START = "SD Scan only removes files it recognises" -- SD Scan 1.1: on a keyboard board the focus order is creation order, and the -- firmware's key navigation collects at most kNavMax = 160 focus stops per -- screen. So the buttons must be created before (and sit above) any list, and -- a screen must stay well under 160 stops however many files were found. local NAV_MAX = 160 local function assert_buttons_first(where) local stops = #buttons for _, l in ipairs(lists) do stops = stops + #l.rows for _, b in ipairs(buttons) do assert(b.seq < l.seq, where .. ": the '" .. b.text .. "' button comes after the list in focus order") assert(b.y + 32 <= l.y, where .. ": the '" .. b.text .. "' button is not above the list") end end assert(stops < NAV_MAX, where .. ": " .. stops .. " focus stops, the firmware collects " .. NAV_MAX) end scenarios.sdscan_layouts = function() for _, dims in ipairs({ { 320, 196 }, { 240, 276 }, { 480, 178 }, { 222, 436 } }) do reset_world() cfg = sd_cfg(dims[1], dims[2]) cfg.sdtree = infected_card() wada = build_wada() local app = load_app() local where = dims[1] .. "x" .. dims[2] assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(screen_has(NOTICE_START), where .. ": the start screen must carry the notice") assert_no_overlap(where .. " start") press("Scan") scan_until(app, "threats found") assert_no_overlap(where .. " results") assert_buttons_first(where .. " results") press("Remove all") assert_no_overlap(where .. " confirm") press("Remove") scan_until(app, "Removed 4 files.") assert(screen_has(NOTICE_START), where .. ": the removal screen must carry the notice") assert(not screen_has("safe to put"), where .. ": do not tell people the card is safe") assert_no_overlap(where .. " removed") press("Scan again") press("Scan") scan_until(app, "No known Windows malware found.") assert(screen_has(NOTICE_START), where .. ": a clean result must carry the notice") assert_no_overlap(where .. " clean") end end -- The card root of a real infected M9 (photographed 2026-09-18), autorun.inf text -- as it was on the card: Sality's pattern, with the payload as xlfqf.pif. local SALITY_AUTORUN = table.concat({ ";jedgRfgxKpOwxYkkslpwEyPfQXwXltdBYsJ", "XCmmWo aRguMqaH", "sHell\\EXplore\\COmmanD=xlfqf.pif", "sHELl\\OpEN\\Default=1", ";qaApt hKlVtplou hjPQkb", "shell\\opEn\\CoMmanD = xlfqf.pif", ";vpWCt WxgC", "ShelL\\AUTopLay\\ComMaNd=xlfqf.pif", ";", "" }, "\r\n") scenarios.sdscan_real_m9 = function() cfg = sd_cfg(320, 196) local f = { ["/autorun.inf"] = SALITY_AUTORUN, ["/xlfqf.pif"] = pe_bytes(4096), ["/copyright.png"] = PNG, ["/bl/list"] = "0", ["/meshcomod/contacts3"] = string.rep("\3", 300), ["/meshcomod/ui_threads_v1.bin"] = string.rep("\1", 300), } for x = 130, 133 do for y = 80, 83 do f[string.format("/tiles/8/%d/%d.png", x, y)] = PNG end end cfg.sdtree = mktree(f) wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "threats found") assert(screen_has("2 threats found"), label_dump()) assert(rows_mention("autorun file: /autorun.inf")) assert(rows_mention("Windows program: /xlfqf.pif")) press("Remove all") press("Remove") scan_until(app, "Removed 2 files.") assert(not sd_exists("/autorun.inf") and not sd_exists("/xlfqf.pif")) for _, keep in ipairs({ "/copyright.png", "/bl/list", "/meshcomod/contacts3", "/meshcomod/ui_threads_v1.bin", "/tiles/8/130/80.png" }) do assert(sd_exists(keep), "removed something that is not the worm: " .. keep) end press("Scan again") press("Scan") scan_until(app, "No known Windows malware found.") end scenarios.sdscan_many = function() cfg = sd_cfg(320, 196) local f = { ["/autorun.inf"] = SALITY_AUTORUN, ["/copyright.png"] = PNG, ["/meshcomod/contacts3"] = "x" } for i = 1, 372 do f[string.format("/RECYCLER/p%03d.pif", i)] = pe_bytes(256) end cfg.sdtree = mktree(f) wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "threats found") assert(screen_has("373 threats found"), label_dump()) assert(#lists == 1) local rows = lists[1].rows assert(#rows == 61, "expected 60 rows plus the summary row, got " .. #rows) assert(rows[61].text:find("and 313 more", 1, true), "last row: " .. rows[61].text) assert_buttons_first("373 findings") press("Remove all") press("Remove") scan_until(app, "Removed 373 files.", 2000) assert(sd_exists("/copyright.png") and sd_exists("/meshcomod/contacts3")) assert(not sd_exists("/autorun.inf")) end scenarios.sdscan_m9 = function() cfg = sd_cfg(320, 196) cfg.sdtree = infected_card() wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(widgets.timer_ms and widgets.timer_ms <= 100, "the scan needs a steady tick") press("Scan") assert(cfg.awake, "the screen must stay on while scanning") scan_until(app, "threats found") assert(not cfg.awake, "keep_awake must be released when the scan ends") assert(screen_has("4 threats found"), "expected exactly 4 threats; " .. label_dump()) assert(rows_mention("autorun file: /autorun.inf")) assert(rows_mention("Windows program: /" .. NBSP .. "/svchost.exe")) assert(rows_mention("Windows shortcut: /tiles.lnk")) assert(rows_mention("renamed Windows program"), "a program under another name was missed") assert(not rows_mention("contacts3"), "a data file that starts with MZ is not a program") assert(screen_has("map tiles skipped"), "a quick scan should say it skipped the tile folders") for _, c in ipairs(cfg.sd_calls) do assert(c.max <= 24, "pages must stay small: " .. c.max) end press("Remove all") assert(screen_has("Remove 4 files?")) assert(buttons[1].text == "Cancel", "Cancel must come first so a stray Enter deletes nothing") press("Cancel") assert(screen_has("4 threats found") and not cfg.sd_removed, "Cancel must not remove anything") press("Remove all") press("Remove") scan_until(app, "Removed 4 files.") assert(#cfg.sd_removed == 4) for _, keep in ipairs({ "/copyright.png", "/test file.txt", "/wadamesh/contacts3", "/wadamesh/ui_threads_v1.bin", "/wadamesh/lang/hu.lang", "/maps/osm/8/130/80.png", "/autorun" }) do assert(sd_exists(keep), "removed something that is not a threat: " .. keep) end assert(not sd_exists("/autorun.inf") and not sd_exists("/tiles.lnk")) press("Scan again") press("Scan") scan_until(app, "No known Windows malware found.") end scenarios.sdscan_paging_and_full = function() cfg = sd_cfg(320, 196) local extra = { ["/maps/osm/8/130/evil.exe"] = pe_bytes(200), ["/wadamesh/history/zz.vbs"] = "WScript.Echo 1" } for i = 1, 100 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end cfg.sdtree = infected_card(extra) wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "threats found") assert(rows_mention("zz.vbs"), "the 101st file of a folder was never reached: paging broken") assert(not rows_mention("evil.exe"), "a quick scan should not walk the tiles") local paged = 0 for _, c in ipairs(cfg.sd_calls) do if c.path == "/wadamesh/history" and c.start > 1 then paged = paged + 1 end end assert(paged >= 4, "expected the history folder to be read in pages, got " .. paged) press("Scan again") press("Full scan") scan_until(app, "threats found") assert(rows_mention("evil.exe"), "a full scan must walk the tile folders") assert(not screen_has("map tiles skipped")) end scenarios.sdscan_old_firmware = function() cfg = sd_cfg(320, 196, { sd_clean = false }) cfg.sdtree = infected_card() wada = build_wada() assert(not wada.sd.remove and not wada.sd.check) local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(screen_has("can only find them"), "the intro should say removal needs newer firmware") press("Scan") scan_until(app, "threats found") assert(screen_has("3 threats found"), "by name: autorun, program, shortcut; " .. label_dump()) assert(not has_button("Remove all"), "no Remove button without firmware support") assert(has_button("Scan again")) assert(screen_has("format the card")) end scenarios.sdscan_no_access = function() cfg = sd_cfg(320, 196, { sd_list = false, sd_clean = false }) wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) assert(screen_has("no SD card access"), label_dump()) assert(not has_button("Scan")) end scenarios.sdscan_no_card = function() cfg = sd_cfg(320, 196) cfg.sd_nocard = true wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "No SD card found.") assert(has_button("Scan again")) end scenarios.sdscan_remove_fails = function() cfg = sd_cfg(320, 196) cfg.sdtree = infected_card() cfg.sd_remove_fail = { ["/tiles.lnk"] = "remove failed" } wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "threats found") press("Remove all") press("Remove") scan_until(app, "could not be removed") assert(screen_has("Removed 3, 1 could not be removed."), label_dump()) assert(rows_mention("/tiles.lnk") and rows_mention("remove failed")) end scenarios.sdscan_portrait = function() cfg = sd_cfg(240, 276, { keyboard = false, touch = true }) cfg.sdtree = infected_card() wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Scan") scan_until(app, "threats found") assert_buttons_first("portrait") end scenarios.sdscan_cost = function() cfg = sd_cfg(320, 196) local extra = {} for i = 1, 400 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end cfg.sdtree = infected_card(extra) wada = build_wada() local app = load_app() assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) press("Full scan") local worst, ticks = 0, 0 while not screen_has("threats found") and ticks < 5000 do local n = 0 debug.sethook(function() n = n + 100 end, "", 100) clock_ms = clock_ms + 50 local ok, err = pcall(app.on_tick, 50) debug.sethook() assert(ok, err) if n > worst then worst = n end ticks = ticks + 1 end print(string.format(" full scan of %d files: %d ticks, worst tick ~%d instructions (budget %d)", 400 + 60, ticks, worst, BUDGET)) assert(worst < BUDGET / 4, "tick too expensive") end local order = APP_PATH:find("/sdscan/", 1, true) and { "sdscan_real_m9", "sdscan_many", "sdscan_m9", "sdscan_layouts", "sdscan_paging_and_full", "sdscan_old_firmware", "sdscan_no_access", "sdscan_no_card", "sdscan_remove_fails", "sdscan_portrait", "sdscan_cost" } or APP_PATH:find("/wardrive/", 1, true) and { "wardrive_utf8" } or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" } for _, name in ipairs(order) do if SCENARIO == "all" or SCENARIO == name then print("== " .. name) reset_world() local ok, err = pcall(scenarios[name]) if not ok then fails = fails + 1; print(" SCENARIO FAILED: " .. tostring(err)) end end end print(fails == 0 and "ALL OK" or ("FAILURES: " .. fails)) if fails ~= 0 then error("harness failures") end