Files
wadamesh/scripts/lua-harness/harness.lua
T
Kaj SchittecatandClaude Opus 5 80f6aa97ea apps: SD Scan 1.1, buttons above the findings list
An infected M9 card turned up with 373 findings. Every list row is a focus
stop on a keyboard board, and the firmware's key navigation collects at most
160 of them per screen, so in 1.0 the Remove all and Scan again buttons below
the list could not be reached on an M9 at all.

1.1 puts the buttons above the list, first in focus order, on the results
and failed-removal screens, and shows at most 60 rows plus an "and N more"
row (the title carries the full count). App-only: no firmware change.

Harness: a 373-finding scenario, and a check on every list screen that the
buttons come first and the focus stops stay under 160. It fails on 1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:13:43 +02:00

1412 lines
67 KiB
Lua

-- 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