mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-06 20:47:25 +00:00
Tracks total distance (Haversine accumulation), elapsed time, current speed, max speed, and altitude from live GPS fixes. Start/Stop button pauses accumulation; Reset clears the trip. The current trip persists across app restarts via wada.store. Also adds 6 harness scenarios (no-fix, T-Deck, moving, persist, reset, cost) and registers the app in apps.json. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1930 lines
88 KiB
Lua
1930 lines
88 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 dm_sent = {} -- send_dm call log: { {to, text}, ... }
|
|
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.mesh.send_dm = function(name, text)
|
|
checkstr(name, "send_dm name"); checkstr(text, "send_dm text")
|
|
dm_sent[#dm_sent + 1] = { to = name, text = text }
|
|
return true
|
|
end
|
|
|
|
do
|
|
local R = 6371000.0
|
|
local function hav(a) local s = math.sin(a / 2); return s * s end
|
|
local function geo_dist(la1, lo1, la2, lo2)
|
|
local p1, p2 = math.rad(la1), math.rad(la2)
|
|
local dp, dl = math.rad(la2 - la1), math.rad(lo2 - lo1)
|
|
local a = hav(dp) + math.cos(p1) * math.cos(p2) * hav(dl)
|
|
return R * 2 * math.atan(math.sqrt(a), math.sqrt(1 - a))
|
|
end
|
|
local function geo_bear(la1, lo1, la2, lo2)
|
|
local p1, p2 = math.rad(la1), math.rad(la2)
|
|
local dl = math.rad(lo2 - lo1)
|
|
local y = math.sin(dl) * math.cos(p2)
|
|
local x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
|
return (math.deg(math.atan(y, x)) + 360) % 360
|
|
end
|
|
local CARDS = {"N","NE","E","SE","S","SW","W","NW"}
|
|
wada.geo = {
|
|
distance = geo_dist,
|
|
bearing = geo_bear,
|
|
cardinal = function(d) return CARDS[math.floor((d + 22.5) / 45) % 8 + 1] end,
|
|
}
|
|
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
|
|
dm_sent = {}
|
|
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
|
|
|
|
-- ---- Tetris (deploy/apps/tetris) --------------------------------------------
|
|
|
|
scenarios.tetris_tdeck = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.canvases == 2, "expected 2 canvases (board + side), got " .. widgets.canvases)
|
|
assert(widgets.timer_ms == 700, "initial tick rate must be 700 ms, got " .. tostring(widgets.timer_ms))
|
|
tick(app, 5)
|
|
swipe(app, "right"); swipe(app, "left"); swipe(app, "up"); swipe(app, "down")
|
|
tick(app, 5)
|
|
print(" t-deck: OK canvases=" .. widgets.canvases .. " timer=" .. tostring(widgets.timer_ms))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.tetris_v4 = function()
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false } }
|
|
storekv = {}
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.canvases == 2, "expected 2 canvases")
|
|
tick(app, 20)
|
|
print(" v4: OK timer=" .. tostring(widgets.timer_ms))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.tetris_keyboard = function()
|
|
cfg = { w = 480, h = 178, caps = { touch = false, keyboard = true } }
|
|
storekv = {}
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
tick(app, 5)
|
|
key(app, "left"); key(app, "right"); key(app, "up"); key(app, "down"); key(app, "enter")
|
|
tick(app, 5)
|
|
print(" keyboard: OK timer=" .. tostring(widgets.timer_ms))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
local function tetris_text_has(s)
|
|
for i = #drawlog.text, math.max(1, #drawlog.text - 30), -1 do
|
|
if drawlog.text[i] == s then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function tetris_run_to_gameover(app)
|
|
-- hard-drop repeatedly; draw_board() adds "tap to retry" before draw_side()
|
|
-- overwrites the tail of drawlog.text, so scan the last 30 entries
|
|
for _ = 1, 2000 do
|
|
tick(app, 1); swipe(app, "down")
|
|
if tetris_text_has("tap to retry") then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
scenarios.tetris_logic = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(tetris_run_to_gameover(app), "game over never triggered in 2000 swipe-drops")
|
|
local ops_before = drawlog.ops
|
|
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
|
|
assert(drawlog.ops > ops_before, "tap after game over must redraw")
|
|
assert(widgets.timer_ms == 700, "restart must reset timer to 700 ms")
|
|
print(" logic: game-over triggered, restart OK")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.tetris_hiscore = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = { hiscore = 0 }
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(tetris_run_to_gameover(app), "game over never triggered")
|
|
local saved = storekv.hiscore
|
|
assert(type(saved) == "number", "hiscore must be saved as a number, got " .. type(saved))
|
|
print(" hiscore: saved=" .. tostring(saved))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.tetris_cost = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}
|
|
wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
local worst = 0
|
|
for i = 1, 100 do
|
|
local n = 0
|
|
debug.sethook(function() n = n + 1000 end, "", 1000)
|
|
clock_ms = clock_ms + 700
|
|
local ok, err = pcall(app.on_tick, 700)
|
|
debug.sethook()
|
|
assert(ok, err)
|
|
if n > worst then worst = n end
|
|
end
|
|
print(string.format(" worst tick ~%d instructions (budget %d), draw ops %d", worst, BUDGET, drawlog.ops))
|
|
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
|
|
end
|
|
|
|
-- ---- Breakout (deploy/apps/breakout) ----------------------------------------
|
|
|
|
local function breakout_text_has(s)
|
|
for i = #drawlog.text, math.max(1, #drawlog.text - 20), -1 do
|
|
if drawlog.text[i] == s then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function breakout_drive_to_gameover(app)
|
|
-- keep launching (swipe up is a no-op once launched) and ticking;
|
|
-- scan backward through the last 20 text entries for "tap to restart"
|
|
for _ = 1, 3000 do
|
|
swipe(app, "up"); tick(app, 1)
|
|
if breakout_text_has("tap to restart") then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
scenarios.breakout_tdeck = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.canvases == 1, "expected 1 canvas, got " .. widgets.canvases)
|
|
assert(widgets.timer_ms == 33, "timer must be 33 ms, got " .. tostring(widgets.timer_ms))
|
|
swipe(app, "left"); swipe(app, "right"); swipe(app, "up")
|
|
tick(app, 20)
|
|
print(" t-deck: OK timer=" .. tostring(widgets.timer_ms) .. " ops=" .. drawlog.ops)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.breakout_v4 = function()
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false } }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
swipe(app, "up"); tick(app, 20)
|
|
print(" v4: OK ops=" .. drawlog.ops)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.breakout_keyboard = function()
|
|
cfg = { w = 480, h = 178, caps = { touch = false, keyboard = true } }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
key(app, "left"); key(app, "right"); key(app, "up")
|
|
tick(app, 20)
|
|
print(" keyboard: OK ops=" .. drawlog.ops)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.breakout_logic = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(breakout_drive_to_gameover(app), "game over never triggered in 3000 ticks")
|
|
local ops_before = drawlog.ops
|
|
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
|
|
assert(drawlog.ops > ops_before, "tap after game over must redraw")
|
|
print(" logic: game-over triggered, restart OK")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.breakout_hiscore = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = { hiscore = 0 }; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(breakout_drive_to_gameover(app), "game over never triggered")
|
|
local saved = storekv.hiscore
|
|
assert(type(saved) == "number", "hiscore must be saved as a number, got " .. type(saved))
|
|
print(" hiscore: saved=" .. tostring(saved))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.breakout_cost = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
swipe(app, "up"); tick(app, 2) -- launch
|
|
local worst = 0
|
|
for i = 1, 100 do
|
|
local n = 0
|
|
debug.sethook(function() n = n + 1000 end, "", 1000)
|
|
clock_ms = clock_ms + 33
|
|
local ok, err = pcall(app.on_tick, 33)
|
|
debug.sethook()
|
|
assert(ok, err)
|
|
if n > worst then worst = n end
|
|
end
|
|
print(string.format(" worst tick ~%d instructions (budget %d), draw ops %d", worst, BUDGET, drawlog.ops))
|
|
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
-- ---- Trip Odometer (deploy/apps/tripodometer) --------------------------------
|
|
|
|
scenarios.trip_v4 = function()
|
|
-- portrait touch board, no GPS fix → shows "no fix", all zeros
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
cfg.gps = function() return nil end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.buttons == 2, "expected 2 buttons (Start/Stop + Reset)")
|
|
tick(app, 5)
|
|
local dump = label_dump()
|
|
print(" no fix:", dump)
|
|
assert(dump:find("no fix") or dump:find("no GPS"), "expected no-fix indicator")
|
|
assert(dump:find("0:00"), "expected zero elapsed time")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.trip_tdeck = function()
|
|
-- landscape T-Deck with GPS fix, start tracking
|
|
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 8, alt_m = 42, speed_kmh = 25.0, course = 90.0 } end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
tick(app, 2)
|
|
local dump = label_dump()
|
|
print(" fix before start:", dump)
|
|
assert(dump:find("25") or dump:find("25.0"), "expected speed displayed")
|
|
-- start tracking
|
|
buttons[1].fn(); tick(app, 3)
|
|
dump = label_dump()
|
|
print(" tracking:", dump)
|
|
assert(dump:find("tracking"), "expected tracking status after Start")
|
|
-- stop
|
|
buttons[1].fn(); tick(app, 1)
|
|
dump = label_dump()
|
|
print(" paused:", dump)
|
|
assert(dump:find("paused"), "expected paused status after Stop")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.trip_moving = function()
|
|
-- simulate movement: distance and max speed accumulate
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
local step = 0
|
|
cfg.gps = function()
|
|
step = step + 1
|
|
return { lat = 37.75 + step * 0.001, lon = -122.45, sats = 8, alt_m = 100 + step, speed_kmh = 30.0 + step }
|
|
end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
buttons[1].fn() -- start tracking
|
|
tick(app, 20) -- 20 GPS fixes, each ~111 m north → ~2.2 km total
|
|
local dump = label_dump()
|
|
print(" after 20 ticks:", dump)
|
|
assert(not dump:find("| 0 m |"), "expected non-zero distance after movement")
|
|
assert(dump:find("km/h"), "expected max speed displayed")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.trip_persist = function()
|
|
-- on_close saves; on_open restores
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
local step = 0
|
|
cfg.gps = function()
|
|
step = step + 1
|
|
return { lat = 37.75 + step * 0.001, lon = -122.45, sats = 7, alt_m = 50, speed_kmh = 15.0 }
|
|
end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
guarded(BUDGET, app.on_open, cfg.w, cfg.h)
|
|
buttons[1].fn(); tick(app, 10) -- run ~1 km
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
assert(storekv.trip_km_x1000 ~= nil and storekv.trip_km_x1000 > 0, "expected trip_km_x1000 saved")
|
|
-- re-open same store → distance should be restored
|
|
wada = build_wada()
|
|
local app2 = load_app()
|
|
guarded(BUDGET, app2.on_open, cfg.w, cfg.h)
|
|
local dump = label_dump()
|
|
print(" restored:", dump)
|
|
assert(not dump:find("| 0 m |"), "expected restored non-zero distance")
|
|
if app2.on_close then guarded(BUDGET, app2.on_close) end
|
|
end
|
|
|
|
scenarios.trip_reset = function()
|
|
-- Reset button clears everything
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
local step = 0
|
|
cfg.gps = function()
|
|
step = step + 1
|
|
return { lat = 37.75 + step * 0.001, lon = -122.45, sats = 6, alt_m = 50, speed_kmh = 20.0 }
|
|
end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
guarded(BUDGET, app.on_open, cfg.w, cfg.h)
|
|
buttons[1].fn(); tick(app, 10) -- accumulate some distance
|
|
buttons[2].fn(); tick(app, 1) -- Reset
|
|
local dump = label_dump()
|
|
print(" after reset:", dump)
|
|
assert(dump:find("0:00"), "expected zero time after reset")
|
|
assert(dump:find("| 0 m |") or dump:find("0.00"), "expected zero distance after reset")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.trip_cost = function()
|
|
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
|
|
local step = 0
|
|
cfg.gps = function()
|
|
step = step + 1
|
|
return { lat = 37.75 + step * 0.0001, lon = -122.45, sats = 8, alt_m = 42, speed_kmh = 10.0 }
|
|
end
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
buttons[1].fn() -- start tracking
|
|
local worst = 0
|
|
for i = 1, 100 do
|
|
local n = 0
|
|
debug.sethook(function() n = n + 1000 end, "", 1000)
|
|
clock_ms = clock_ms + 2000
|
|
local ok, err = pcall(app.on_tick, 2000)
|
|
debug.sethook()
|
|
assert(ok, err)
|
|
if n > worst then worst = n end
|
|
end
|
|
print(string.format(" worst tick ~%d instructions (budget %d)", worst, BUDGET))
|
|
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
-- ---- Ping (deploy/apps/ping) ------------------------------------------------
|
|
local function deliver_dm(app, sender, text)
|
|
if app.on_message then
|
|
guarded(BUDGET, app.on_message, { kind = "dm", sender = sender, text = text, channel = "" })
|
|
end
|
|
end
|
|
|
|
-- contacts used by all ping scenarios
|
|
local PING_CONTACTS = {
|
|
{ name = "Node-Alpha", lat = 48.9000, lon = 2.4000 },
|
|
{ name = "Node-Beta", lat = 48.8000, lon = 2.3000 },
|
|
}
|
|
local MY_GPS = { lat = 48.8566, lon = 2.3522, lat_e6 = 48856600, lon_e6 = 2352200, sats = 9 }
|
|
|
|
scenarios.ping_tdeck = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
|
|
contacts = PING_CONTACTS, gps = MY_GPS }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.labels >= 3, "expected at least 3 labels (gps + 2 names), got " .. widgets.labels)
|
|
assert(widgets.buttons >= 2, "expected 2 Ping buttons, got " .. widgets.buttons)
|
|
assert(widgets.scroll, "scroll should be enabled")
|
|
assert(widgets.timer_ms, "timer not started")
|
|
print(string.format(" labels=%d buttons=%d timer=%dms", widgets.labels, widgets.buttons, widgets.timer_ms))
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.ping_v4 = function()
|
|
cfg = { w = 240, h = 276, caps = { touch = true, sdk_ext = true },
|
|
contacts = PING_CONTACTS, gps = MY_GPS }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.buttons >= 2, "expected 2 Ping buttons on V4")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.ping_no_contacts = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true }, contacts = {} }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
assert(widgets.buttons == 0, "no Ping buttons when no contacts")
|
|
local found = false
|
|
for _, l in ipairs(labels) do if l.text:find("No contacts") then found = true end end
|
|
assert(found, "should show 'No contacts' label")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.ping_round_trip = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
|
|
contacts = PING_CONTACTS, gps = MY_GPS }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
|
|
-- click the first Ping button (Node-Alpha)
|
|
local btn = buttons[1]; assert(btn and btn.fn, "no ping button")
|
|
assert(guarded(BUDGET, btn.fn))
|
|
|
|
-- verify a WADAPING DM was sent
|
|
assert(#dm_sent == 1, "expected 1 DM sent, got " .. #dm_sent)
|
|
assert(dm_sent[1].to == "Node-Alpha", "wrong target: " .. tostring(dm_sent[1].to))
|
|
local ping_msg = dm_sent[1].text
|
|
assert(ping_msg:sub(1, 9) == "WADAPING:", "wrong prefix: " .. ping_msg:sub(1, 9))
|
|
local ts_s, lat_s, lon_s = ping_msg:sub(10):match("(-?%d+):(-?%d+):(-?%d+)")
|
|
assert(ts_s, "could not parse PING message: " .. ping_msg)
|
|
print(" sent: " .. ping_msg)
|
|
|
|
-- wait a bit, then deliver a PONG with Node-Alpha's position
|
|
clock_ms = clock_ms + 250
|
|
local their_lat6, their_lon6 = 48900000, 2400000 -- 48.9°N 2.4°E
|
|
local pong = string.format("WADAPONG:%s:%d:%d", ts_s, their_lat6, their_lon6)
|
|
deliver_dm(app, "Node-Alpha", pong)
|
|
|
|
-- find the Node-Alpha result label and check it was updated
|
|
local result_ok = false
|
|
for _, l in ipairs(labels) do
|
|
if l.text:find("ms") then
|
|
print(" result: " .. l.text)
|
|
result_ok = true
|
|
end
|
|
end
|
|
assert(result_ok, "no RTT label found after PONG")
|
|
assert(toasts[#toasts]:find("Node-Alpha"), "toast should mention sender")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.ping_auto_reply = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
|
|
contacts = PING_CONTACTS, gps = MY_GPS }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
|
|
-- someone sends us a PING
|
|
local remote_ts = 999888
|
|
local remote_lat6, remote_lon6 = 48900000, 2400000
|
|
local ping_in = string.format("WADAPING:%d:%d:%d", remote_ts, remote_lat6, remote_lon6)
|
|
dm_sent = {} -- reset so we can check the auto-reply
|
|
deliver_dm(app, "Node-Alpha", ping_in)
|
|
|
|
assert(#dm_sent == 1, "expected auto-reply DM, got " .. #dm_sent)
|
|
assert(dm_sent[1].to == "Node-Alpha", "reply must go back to sender")
|
|
local reply = dm_sent[1].text
|
|
assert(reply:sub(1, 9) == "WADAPONG:", "reply must start with WADAPONG:")
|
|
-- timestamp in reply must match original
|
|
local rts = reply:sub(10):match("(-?%d+):")
|
|
assert(rts == tostring(remote_ts),
|
|
string.format("reply ts %s != original %d", rts, remote_ts))
|
|
print(" auto-reply: " .. reply)
|
|
|
|
-- non-DM messages are ignored
|
|
local before = #dm_sent
|
|
guarded(BUDGET, app.on_message, { kind = "channel", sender = "x", text = ping_in, channel = "c" })
|
|
assert(#dm_sent == before, "channel messages must not trigger auto-reply")
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
end
|
|
|
|
scenarios.ping_cost = function()
|
|
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
|
|
contacts = PING_CONTACTS, gps = MY_GPS }
|
|
storekv = {}; wada = build_wada()
|
|
local app = load_app()
|
|
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
|
local worst = 0
|
|
for _ = 1, 20 do
|
|
local n = 0
|
|
debug.sethook(function() n = n + 1000 end, "", 1000)
|
|
clock_ms = clock_ms + 5000
|
|
local ok, err = pcall(app.on_tick, 5000)
|
|
debug.sethook()
|
|
assert(ok, err)
|
|
if n > worst then worst = n end
|
|
end
|
|
print(string.format(" worst tick ~%d instructions (budget %d)", worst, BUDGET))
|
|
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
|
|
if app.on_close then guarded(BUDGET, app.on_close) end
|
|
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 APP_PATH:find("/tetris/", 1, true)
|
|
and { "tetris_tdeck", "tetris_v4", "tetris_keyboard", "tetris_logic", "tetris_hiscore", "tetris_cost" }
|
|
or APP_PATH:find("/breakout/", 1, true)
|
|
and { "breakout_tdeck", "breakout_v4", "breakout_keyboard", "breakout_logic", "breakout_hiscore", "breakout_cost" }
|
|
or APP_PATH:find("/tripodometer/", 1, true)
|
|
and { "trip_v4", "trip_tdeck", "trip_moving", "trip_persist", "trip_reset", "trip_cost" }
|
|
or APP_PATH:find("/ping/", 1, true)
|
|
and { "ping_tdeck", "ping_v4", "ping_no_contacts", "ping_round_trip", "ping_auto_reply", "ping_cost" }
|
|
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
|