feat(lua): standalone EdgeTX Lua compiler (#7275)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raphael Coeffic
2026-06-07 13:09:08 +10:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a672c3c5c6
commit 65f135c25a
8 changed files with 612 additions and 2 deletions
+61
View File
@@ -0,0 +1,61 @@
cmake_minimum_required(VERSION 3.10)
project(edgetx-luac C)
# Native host build of luac that produces bytecode compatible with
# EdgeTX's 32-bit Lua 5.3 configuration (float numbers, int integers).
#
# Usage:
# cmake -S radio/src/thirdparty/Lua -B build-luac
# cmake --build build-luac
#
# The resulting 'edgetx-luac' binary can compile scripts for any EdgeTX radio:
# edgetx-luac -s -o script.luac script.lua
set(LUA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src)
set(LUA_CORE_SRC
${LUA_DIR}/lapi.c
${LUA_DIR}/lcode.c
${LUA_DIR}/lctype.c
${LUA_DIR}/ldebug.c
${LUA_DIR}/ldo.c
${LUA_DIR}/ldump.c
${LUA_DIR}/lfunc.c
${LUA_DIR}/lgc.c
${LUA_DIR}/llex.c
${LUA_DIR}/lmem.c
${LUA_DIR}/lobject.c
${LUA_DIR}/lopcodes.c
${LUA_DIR}/lparser.c
${LUA_DIR}/lstate.c
${LUA_DIR}/lstring.c
${LUA_DIR}/ltable.c
${LUA_DIR}/ltm.c
${LUA_DIR}/lundump.c
${LUA_DIR}/lvm.c
${LUA_DIR}/lzio.c
${LUA_DIR}/lauxlib.c
)
add_executable(edgetx-luac
${LUA_DIR}/luac.c
${LUA_CORE_SRC}
)
target_compile_definitions(edgetx-luac PRIVATE
LUA_HOST_BUILD
LUA_CROSS_COMPILER
LUA_COMPAT_5_2
)
target_include_directories(edgetx-luac PRIVATE ${LUA_DIR})
# Link math library on Unix
if(UNIX)
target_link_libraries(edgetx-luac m)
endif()
set_target_properties(edgetx-luac PROPERTIES
OUTPUT_NAME edgetx-luac
C_STANDARD 99
)
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
#
# Build edgetx-luac.wasm — a browser-ready WASM module that compiles
# Lua source to EdgeTX-compatible bytecode entirely in memory.
#
# Requirements: wasi-sdk (default /opt/wasi-sdk)
#
# Usage:
# cd radio/src/thirdparty/Lua
# ./build-wasm.sh
#
# The output edgetx-luac.wasm is placed in the current directory.
set -euo pipefail
WASI_SDK="${WASI_SDK:-/opt/wasi-sdk}"
CC="${WASI_SDK}/bin/clang"
if [ ! -x "$CC" ]; then
echo "Error: wasi-sdk clang not found at $CC" >&2
echo "Set WASI_SDK to your wasi-sdk installation path." >&2
exit 1
fi
SRCDIR="$(cd "$(dirname "$0")/src" && pwd)"
LUA_CORE_SRC=(
"$SRCDIR/lapi.c"
"$SRCDIR/lcode.c"
"$SRCDIR/lctype.c"
"$SRCDIR/ldebug.c"
"$SRCDIR/ldo.c"
"$SRCDIR/ldump.c"
"$SRCDIR/lfunc.c"
"$SRCDIR/lgc.c"
"$SRCDIR/llex.c"
"$SRCDIR/lmem.c"
"$SRCDIR/lobject.c"
"$SRCDIR/lopcodes.c"
"$SRCDIR/lparser.c"
"$SRCDIR/lstate.c"
"$SRCDIR/lstring.c"
"$SRCDIR/ltable.c"
"$SRCDIR/ltm.c"
"$SRCDIR/lundump.c"
"$SRCDIR/lvm.c"
"$SRCDIR/lzio.c"
"$SRCDIR/lauxlib.c"
)
echo "Building edgetx-luac.wasm..."
"$CC" \
--target=wasm32-wasi \
--sysroot="${WASI_SDK}/share/wasi-sysroot" \
-Os \
-ffunction-sections \
-fdata-sections \
-mllvm -wasm-enable-sjlj \
-DLUA_HOST_BUILD \
-DLUA_CROSS_COMPILER \
-DLUA_COMPAT_5_2 \
-I"$SRCDIR" \
-mexec-model=reactor \
-Wl,--gc-sections \
-Wl,--strip-debug \
-Wl,--export-dynamic \
-Wl,-mllvm,-wasm-enable-sjlj \
-nodefaultlibs -lc -lsetjmp \
-o edgetx-luac.wasm \
"$SRCDIR/luac_wasm.c" \
"${LUA_CORE_SRC[@]}"
SIZE=$(wc -c < edgetx-luac.wasm | tr -d ' ')
echo "Done: edgetx-luac.wasm (${SIZE} bytes)"
# Show WASI imports (ideally very few / none needed at runtime)
if command -v wasm-objdump &>/dev/null; then
echo ""
echo "WASI imports:"
wasm-objdump -j Import -x edgetx-luac.wasm 2>/dev/null | grep wasi || echo " (none)"
fi
+3 -2
View File
@@ -9,7 +9,9 @@
#include "lprefix.h"
#if defined(__GNUC__) || defined(__clang__)
#include <alloca.h>
#endif
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
@@ -21,7 +23,6 @@
#include "lualib.h"
#include "lauxlib.h"
#include "ldebug.h"
#include "lnodemcu.h"
#include "lobject.h"
#include "lstate.h"
#include "lstring.h"
@@ -163,7 +164,7 @@ static int pmain(lua_State *L) {
if (argc == 0)
return 0;
for (i = 0; i < argc; i++) {
const char *filename = IS("-") ? NULL : filelist[i];
const char *filename = IS("-") ? NULL : argv[i];
if (luaL_loadfile(L, filename) != LUA_OK)
fatal(lua_tostring(L, -1));
}
+120
View File
@@ -0,0 +1,120 @@
/*
** EdgeTX Lua cross-compiler — WASM entry point
**
** Exposes a minimal API for compiling Lua source to bytecode entirely
** in memory, suitable for running in a browser via WebAssembly.
**
** Exported functions:
** compile(src_ptr, src_len, strip) → positive length on success, negative on error
** get_output_ptr() → pointer to compiled bytecode
** get_error_ptr() → pointer to error message string
** get_error_len() → length of error message
** wasm_malloc(size) → allocate memory (for JS to write source into)
** wasm_free(ptr) → free allocated memory
*/
#define luac_c
#define LUA_CORE
#include "lprefix.h"
#include <stdlib.h>
#include <string.h>
#include "lua.h"
#include "lauxlib.h"
/* --------------- output buffer --------------- */
static unsigned char *out_buf;
static size_t out_len;
static size_t out_cap;
static int writer(lua_State *L, const void *p, size_t size, void *ud) {
(void)L; (void)ud;
if (out_len + size > out_cap) {
size_t new_cap = out_cap ? out_cap * 2 : 4096;
while (new_cap < out_len + size)
new_cap *= 2;
unsigned char *nb = realloc(out_buf, new_cap);
if (!nb) return 1;
out_buf = nb;
out_cap = new_cap;
}
memcpy(out_buf + out_len, p, size);
out_len += size;
return 0;
}
/* --------------- error message --------------- */
static char error_msg[512];
static int error_len;
/* --------------- exported API --------------- */
#define EXPORT __attribute__((export_name(#name)))
/* clang wasm export attribute helper */
#define WASM_EXPORT(name) __attribute__((export_name(#name))) name
/*
** Compile Lua source to bytecode.
** Returns the bytecode length (>0) on success, or a negative error code:
** -1 could not create Lua state
** -2 syntax / compile error (call get_error_ptr/len for message)
** -3 bytecode dump failed
*/
int WASM_EXPORT(compile)(const char *source, int source_len, int strip) {
error_msg[0] = '\0';
error_len = 0;
/* reset output */
free(out_buf);
out_buf = NULL;
out_len = 0;
out_cap = 0;
lua_State *L = luaL_newstate();
if (!L) return -1;
int status = luaL_loadbuffer(L, source, (size_t)source_len, "input");
if (status != LUA_OK) {
const char *err = lua_tostring(L, -1);
if (err) {
size_t n = strlen(err);
if (n >= sizeof(error_msg)) n = sizeof(error_msg) - 1;
memcpy(error_msg, err, n);
error_msg[n] = '\0';
error_len = (int)n;
}
lua_close(L);
return -2;
}
/* The compiled chunk is a function at the top of the stack.
* lua_dump serialises it via the writer callback. */
status = lua_dump(L, writer, NULL, strip);
lua_close(L);
return status == 0 ? (int)out_len : -3;
}
unsigned char *WASM_EXPORT(get_output_ptr)(void) {
return out_buf;
}
char *WASM_EXPORT(get_error_ptr)(void) {
return error_msg;
}
int WASM_EXPORT(get_error_len)(void) {
return error_len;
}
void *WASM_EXPORT(wasm_malloc)(int size) {
return malloc((size_t)size);
}
void WASM_EXPORT(wasm_free)(void *ptr) {
free(ptr);
}
+2
View File
@@ -462,10 +462,12 @@
#undef lua_str2number
#define lua_str2number(s,p) ((lua_Number)strtod((s), (p)))
#if !defined(LUA_HOST_BUILD)
// For print
#include "debug.h"
#define lua_writestring(s,l) TRACE_DEBUG_WP("%s", s);
#define lua_writeline() TRACE_DEBUG_WP("\n");
#define lua_writestringerror(s,p) TRACE_DEBUG_WP(s, p);
#endif
#endif
+2
View File
@@ -0,0 +1,2 @@
.wrangler
*.wasm
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+342
View File
@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EdgeTX Lua Compiler</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--primary: #0C7BCD;
--primary-dark: #0a6ab3;
--bg: #1c1c1c;
--surface: #2a2a2a;
--surface-light: #333;
--border: #444;
--text: #f0f0f0;
--text-dim: #999;
--success: #4ade80;
--error: #f87171;
--mono: "Roboto Mono", "SF Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.header img {
height: 40px;
width: auto;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
}
.subtitle {
color: var(--text-dim);
font-size: 0.85rem;
margin-bottom: 1.5rem;
}
.container {
width: 100%;
max-width: 720px;
}
textarea {
width: 100%;
height: 300px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
font-family: var(--mono);
font-size: 0.9rem;
line-height: 1.5;
resize: vertical;
tab-size: 2;
}
textarea:focus { outline: 1px solid var(--primary); border-color: var(--primary); }
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.75rem 0;
flex-wrap: wrap;
}
button {
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1.25rem;
font-family: "Roboto", sans-serif;
font-size: 0.9rem;
cursor: pointer;
font-weight: 500;
transition: background 0.15s;
}
button:hover { background: var(--primary-dark); }
button:disabled { opacity: 0.4; cursor: not-allowed; background: var(--primary); }
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-dim);
cursor: pointer;
}
input[type="checkbox"] { accent-color: var(--primary); }
.file-input-wrapper {
position: relative;
}
.file-input-wrapper input[type="file"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-input-wrapper .btn-file {
background: var(--surface-light);
border: 1px solid var(--border);
pointer-events: none;
}
.file-input-wrapper .btn-file:hover { background: var(--surface-light); }
#status {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-family: var(--mono);
font-size: 0.8rem;
white-space: pre-wrap;
display: none;
}
#status.error { display: block; background: #2d1515; color: var(--error); border: 1px solid #5c2020; }
#status.success { display: block; background: #0d2818; color: var(--success); border: 1px solid #1a4028; }
#status.info { display: block; background: var(--surface); color: var(--text-dim); border: 1px solid var(--border); }
.drop-target {
outline: 2px dashed var(--primary);
outline-offset: -4px;
}
footer {
margin-top: auto;
padding-top: 2rem;
color: var(--text-dim);
font-size: 0.75rem;
}
footer a { color: var(--primary); text-decoration: none; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="header">
<img src="edgetx-logo.png" alt="EdgeTX">
<h1>Lua Compiler</h1>
</div>
<p class="subtitle">Compile Lua scripts to EdgeTX bytecode — runs entirely in your browser</p>
<div class="container">
<textarea id="source" spellcheck="false" placeholder="-- Paste or drop your Lua script here...
local function hello()
print('Hello from EdgeTX!')
end
return { run = hello }
"></textarea>
<div class="toolbar">
<button id="compile-btn" disabled>Loading WASM...</button>
<label><input type="checkbox" id="strip" checked> Strip debug info</label>
<div class="file-input-wrapper">
<input type="file" id="file-input" accept=".lua,.txt">
<button class="btn-file">Open file...</button>
</div>
</div>
<div id="status"></div>
</div>
<footer>
Powered by <a href="https://github.com/EdgeTX/edgetx">EdgeTX</a> Lua 5.3
&mdash; compiled to WebAssembly, no server needed
</footer>
<script>
(function () {
"use strict";
const sourceEl = document.getElementById("source");
const compileBtn = document.getElementById("compile-btn");
const stripEl = document.getElementById("strip");
const statusEl = document.getElementById("status");
const fileInput = document.getElementById("file-input");
let wasm = null; // WASM instance exports
let currentFileName = "script.lua"; // track loaded filename
/* ---- WASI stubs (never actually called at runtime) ---- */
const wasiStubs = {
args_get: () => 0,
args_sizes_get: () => 0,
clock_time_get: () => 0,
fd_close: () => 0,
fd_prestat_get: () => 8, /* EBADF — no preopens */
fd_prestat_dir_name: () => 8,
fd_read: () => 0,
fd_seek: () => 0,
fd_write: (fd, iovs, iovs_len, nwritten) => {
/* minimal fd_write so Lua error messages via fprintf(stderr,...) don't crash */
const mem = new DataView(wasm.memory.buffer);
let written = 0;
for (let i = 0; i < iovs_len; i++) {
const ptr = mem.getUint32(iovs + i * 8, true);
const len = mem.getUint32(iovs + i * 8 + 4, true);
written += len;
}
mem.setUint32(nwritten, written, true);
return 0;
},
proc_exit: (code) => { throw new Error("proc_exit(" + code + ")"); },
random_get: () => 0,
};
/* ---- Load WASM module ---- */
async function loadWasm() {
const importObject = {
wasi_snapshot_preview1: wasiStubs,
};
const resp = await fetch("edgetx-luac.wasm");
const { instance } = await WebAssembly.instantiateStreaming(resp, importObject);
wasm = instance.exports;
/* reactor model: call _initialize once */
if (wasm._initialize) wasm._initialize();
compileBtn.textContent = "Compile";
compileBtn.disabled = false;
}
/* ---- Compile ---- */
function compile() {
const source = sourceEl.value;
if (!source.trim()) {
showStatus("error", "No Lua source to compile.");
return;
}
const encoder = new TextEncoder();
const srcBytes = encoder.encode(source);
/* allocate WASM memory and copy source in */
const srcPtr = wasm.wasm_malloc(srcBytes.length);
if (!srcPtr) {
showStatus("error", "Failed to allocate WASM memory.");
return;
}
const mem = new Uint8Array(wasm.memory.buffer);
mem.set(srcBytes, srcPtr);
const strip = stripEl.checked ? 1 : 0;
const result = wasm.compile(srcPtr, srcBytes.length, strip);
wasm.wasm_free(srcPtr);
if (result > 0) {
/* success — read output bytecode */
const outPtr = wasm.get_output_ptr();
const bytecode = new Uint8Array(wasm.memory.buffer, outPtr, result).slice();
/* trigger download — use original filename with .luac extension */
const name = currentFileName.replace(/\.[^.]+$/, "") + ".luac";
const blob = new Blob([bytecode], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showStatus("success", "Compiled successfully (" + bytecode.length + " bytes) — downloading " + name);
} else {
/* error — read message */
const errLen = wasm.get_error_len();
let msg = "Compilation failed (code " + result + ")";
if (errLen > 0) {
const errPtr = wasm.get_error_ptr();
const errBytes = new Uint8Array(wasm.memory.buffer, errPtr, errLen);
msg = new TextDecoder().decode(errBytes);
}
showStatus("error", msg);
}
}
/* ---- UI helpers ---- */
function showStatus(type, msg) {
statusEl.className = type;
statusEl.textContent = msg;
}
function loadFile(file) {
currentFileName = file.name;
file.text().then((text) => {
sourceEl.value = text;
showStatus("info", "Loaded " + file.name + " (" + text.length + " bytes)");
});
}
compileBtn.addEventListener("click", compile);
/* File input */
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
loadFile(file);
fileInput.value = ""; /* allow re-uploading the same file */
});
/* Drag & drop */
sourceEl.addEventListener("dragover", (e) => {
e.preventDefault();
sourceEl.classList.add("drop-target");
});
sourceEl.addEventListener("dragleave", () => {
sourceEl.classList.remove("drop-target");
});
sourceEl.addEventListener("drop", (e) => {
e.preventDefault();
sourceEl.classList.remove("drop-target");
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
});
/* Ctrl/Cmd+Enter to compile */
sourceEl.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!compileBtn.disabled) compile();
}
});
/* Boot */
loadWasm().catch((err) => {
showStatus("error", "Failed to load WASM module: " + err.message);
});
})();
</script>
</body>
</html>