diff --git a/.github/workflows/android-runtime-capacity.yml b/.github/workflows/android-runtime-capacity.yml new file mode 100644 index 000000000..51b9822cd --- /dev/null +++ b/.github/workflows/android-runtime-capacity.yml @@ -0,0 +1,68 @@ +name: Android runtime capacity candidates +on: + pull_request: + paths: + - 'tools/mobile-runtime-check/**' + - 'tools/runtime-upvalue-check/check.lua' + - 'tools/runtime-upvalue-check/c_api.c' + - 'mobile/android/love/src/jni/LuaJIT-2.1/**' + - '.github/workflows/android-runtime-capacity.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: android-runtime-capacity-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86, x86_64] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install host multilib and the pinned Android toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-multilib + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install 'ndk;25.2.9519653' + - name: Build stock and extended candidates with matching boundary probes + run: >- + python3 tools/mobile-runtime-check/build_android.py + --ndk "$ANDROID_HOME/ndk/25.2.9519653" + --abi '${{ matrix.abi }}' + --output '${{ runner.temp }}/android-runtime/candidate' + - name: Enable the owned emulator's KVM access + if: matrix.abi == 'x86_64' + run: sudo chmod a+rw /dev/kvm + - name: Execute the target VM and C API probes + if: matrix.abi == 'x86_64' + uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d + with: + api-level: 30 + arch: x86_64 + target: default + avd-name: gen2-runtime-qa + emulator-port: 5554 + script: >- + python3 tools/mobile-runtime-check/check_android_target.py + --serial emulator-5554 --abi x86_64 + --build '${{ runner.temp }}/android-runtime/candidate' + --output '${{ runner.temp }}/android-runtime-target/candidate' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: android-runtime-${{ matrix.abi }} + path: | + ${{ runner.temp }}/android-runtime/candidate/report.json + ${{ runner.temp }}/android-runtime/candidate/*.log + ${{ runner.temp }}/android-runtime/candidate/artifacts/ + ${{ runner.temp }}/android-runtime-target/candidate/*.json + ${{ runner.temp }}/android-runtime-target/candidate/*.log + if-no-files-found: error + retention-days: 7 diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, third-party engine work, +other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/tools/mobile-runtime-check/README.md b/tools/mobile-runtime-check/README.md new file mode 100644 index 000000000..552bf1987 --- /dev/null +++ b/tools/mobile-runtime-check/README.md @@ -0,0 +1,42 @@ +# Native mobile runtime capacity + +Android currently links the vendored `android//libluajit.a` archives. +Source/header edits do not replace those archives. This builder creates new +isolated stock (60 captures) and experimental extended (120 captures) libraries, +matching public headers, an Android interpreter and a linked C API probe for +each selected ABI. It preserves the vendored source, existing binaries and +LuaJIT copyright notice. No application or player data is copied. + +The source version is bound to the existing Android headers. Compilation uses +NDK 25.2.9519653 in CI, API 23 by default and Lua-5.2 compatibility in both +variants. Linux uses a matching 32/64-bit host toolchain; macOS supports the +64-bit cross-builds. The command layout follows the upstream +[LuaJIT cross-compilation instructions](https://luajit.org/install.html). + +```sh +python3 tools/mobile-runtime-check/build_android.py \ + --ndk /path/to/android/ndk/25.2.9519653 \ + --abi arm64-v8a --output .local/qa/android-runtime-test/candidate +``` + +Every candidate includes the existing closure/debug/bytecode probe and a C API +executable linked against the same headers and static library. The builder +checks target ELF architecture and records hashes. Its reports explicitly keep +`targetExecutionQualified` and `releaseSelectable` false: Android execution and +the actual LÖVE/JNI/thread/FFI boundary must be exercised before packaging can +select a candidate. The existing application continues to use its current +runtime until that qualification and selection work is complete. + +The workflow also executes both x86-64 candidates on an owned API-30 Android +emulator through `check_android_target.py`. Transfers are hash-checked before +the closure/debug/bytecode/GC and linked C API tests run. The tool requires an +explicit emulator serial, writes only its unique temporary directory, and +removes its own files afterward. ARM and 32-bit execution remain separate +requirements. This standalone VM test does not replace the LÖVE/JNI/thread/FFI +host gate, so its report also keeps release selection disabled. + +iOS needs separately built Apple framework slices and interpreter-mode +qualification. Switch needs a rebuilt runtime and target execution. These +build artifacts establish neither full platform parity nor original-mod +startup, rendering or gameplay acceptance. Local QA uses the shared 20 GiB +reserve and owned output leases. diff --git a/tools/mobile-runtime-check/build_android.py b/tools/mobile-runtime-check/build_android.py new file mode 100644 index 000000000..9afe7044d --- /dev/null +++ b/tools/mobile-runtime-check/build_android.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Build isolated Android LuaJIT candidates and target-side boundary probes.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse +import atexit +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shlex +import shutil +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +ABIS = { + 'arm64-v8a': ('aarch64-linux-android', 'aarch64-linux-android', 64, 'AArch64'), + 'armeabi-v7a': ('armv7a-linux-androideabi', 'arm-linux-androideabi', 32, 'ARM'), + 'x86': ('i686-linux-android', 'i686-linux-android', 32, 'Intel 80386'), + 'x86_64': ('x86_64-linux-android', 'x86_64-linux-android', 64, 'Advanced Micro Devices X86-64'), +} +HEADERS = ('lua.h', 'luaconf.h', 'lauxlib.h', 'lualib.h', 'luajit.h') +RELVER = '1700008891' # Matches the vendored Android public headers. + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--ndk', type=Path, required=True) + p.add_argument('--source', type=Path, default=ROOT/'mobile/android/love/src/jni/LuaJIT-2.1') + p.add_argument('--abi', action='append', choices=ABIS) + p.add_argument('--api', type=int, default=23) + p.add_argument('--jobs', type=int, default=4) + p.add_argument('--output', type=Path) + a = p.parse_args() + if platform.system() not in ('Linux', 'Darwin'): + p.error('Use a POSIX host toolchain; Linux multilib is required for 32-bit targets') + if not 23 <= a.api <= 35 or not 1 <= a.jobs <= 8: + p.error('API must be 23..35 and jobs must be 1..8') + abis = a.abi or (list(ABIS) if platform.system() == 'Linux' else ['arm64-v8a', 'x86_64']) + if len(abis) != len(set(abis)): + p.error('Choose each ABI once') + if platform.system() == 'Darwin' and any(ABIS[x][2] == 32 for x in abis): + p.error('32-bit targets require a Linux multilib host') + source, ndk = a.source.resolve(strict=True), a.ndk.resolve(strict=True) + host = 'linux-x86_64' if platform.system() == 'Linux' else 'darwin-x86_64' + binaries = ndk/'toolchains/llvm/prebuilt'/host/'bin' + for name in ('llvm-ar', 'llvm-readelf', 'llvm-strip'): + if not (binaries/name).is_file(): p.error('Missing NDK tool: '+name) + for abi in abis: + if not (binaries/(ABIS[abi][0]+str(a.api)+'-clang')).is_file(): p.error('Missing NDK compiler for '+abi) + header = source/'android'/abi/'luajit.h' + if header.exists() and ('luaJIT_version_2_1_'+RELVER) not in header.read_text(): + p.error('Vendored Android header version changed; audit the source/version binding') + inputs = [source/'Makefile', source/'COPYRIGHT'] + for directory in ('src', 'dynasm'): + inputs.extend(path for path in (source/directory).rglob('*') if path.is_file()) + if any(path.is_symlink() for path in inputs): p.error('Source inputs must not be symlinks') + if any(path.suffix in ('.o', '.a', '.so', '.dll', '.exe') for path in inputs): + p.error('Use an unbuilt source tree; generated binaries are not source inputs') + hashes = {str(path.relative_to(source)): sha(path) for path in inputs} + probe_names = ('tools/runtime-upvalue-check/check.lua', 'tools/runtime-upvalue-check/c_api.c', + 'src/link/Json.lua', 'LICENSE', 'LICENSE.MD', 'LICENSE-ROXAS2712.md') + probe_inputs = {name: (ROOT/name).read_bytes() for name in probe_names} + limit = (source/'src/lj_def.h').read_text() + if len(re.findall(r'^#define LJ_MAX_UPVAL\s+60\s', limit, re.M)) != 1: + p.error('Expected the vendored stock 60-upvalue source') + output = (a.output or ROOT/'.local/qa'/('gen2-mobile-runtime-'+str(time.time_ns()))/'candidate').resolve() + run = subprocess.run + if not os.getenv('GITHUB_ACTIONS'): + sys.path.insert(0, str(ROOT/'tools')) + from qa_storage import Storage + storage = Storage(ROOT); atexit.register(storage.close) + for path in (ROOT, source, ndk): storage.source(path) + storage.cache(output.parent); storage.copy(output) + run = storage.run + output.mkdir(parents=True, exist_ok=False) + results = {} + + def command(label, argv, cwd): + result = run([str(x) for x in argv], cwd=cwd, capture_output=True, text=True, timeout=300) + (output/(label+'.log')).write_text(result.stdout+result.stderr) + if result.returncode: print((result.stdout+result.stderr)[-12000:], flush=True) + result.check_returncode() + return result.stdout + + for variant, capacity in (('stock', 60), ('extended', 120)): + for abi in abis: + triple, prefix, bits, machine = ABIS[abi] + label = variant+'-'+abi + build = output/'build'/variant/abi; build.mkdir(parents=True) + for relative in hashes: + target = build/relative; target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source/relative, target) + (build/'.relver').write_text(RELVER+'\n') + if capacity == 120: + changed, count = re.subn(r'(^#define LJ_MAX_UPVAL\s+)60(?=\s)', r'\g<1>120', limit, flags=re.M) + assert count == 1 + (build/'src/lj_def.h').write_text(changed) + cc = binaries/(triple+str(a.api)+'-clang') + host_cc = 'clang' if platform.system() == 'Darwin' else 'gcc -m'+str(bits) + command(label+'-build', ['make', '-j'+str(a.jobs), 'BUILDMODE=static', 'TARGET_SYS=Linux', + 'HOST_CC='+host_cc, 'CROSS='+shlex.quote(str(binaries/(prefix+'-'))), + 'STATIC_CC='+shlex.quote(str(cc))+' -fPIC', 'DYNAMIC_CC='+shlex.quote(str(cc))+' -fPIC', + 'TARGET_LD='+shlex.quote(str(cc)), 'TARGET_AR='+shlex.quote(str(binaries/'llvm-ar'))+' rcus', + 'TARGET_STRIP='+shlex.quote(str(binaries/'llvm-strip')), 'XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT'], build) + artifact = output/'artifacts'/variant/abi; artifact.mkdir(parents=True) + for name in HEADERS: shutil.copy2(build/'src'/name, artifact/name) + for name in ('libluajit.a', 'luajit'): shutil.copy2(build/'src'/name, artifact/name) + shutil.copy2(source/'COPYRIGHT', artifact/'COPYRIGHT') + (artifact/'check.lua').write_bytes(probe_inputs['tools/runtime-upvalue-check/check.lua']) + (artifact/'c_api.c').write_bytes(probe_inputs['tools/runtime-upvalue-check/c_api.c']) + (artifact/'src/link').mkdir(parents=True) + (artifact/'src/link/Json.lua').write_bytes(probe_inputs['src/link/Json.lua']) + for name in ('LICENSE', 'LICENSE.MD', 'LICENSE-ROXAS2712.md'): + (artifact/name).write_bytes(probe_inputs[name]) + command(label+'-c-api-build', [cc, '-I'+str(artifact), artifact/'c_api.c', + artifact/'libluajit.a', '-lm', '-ldl', '-o', artifact/'c-api-upvalues'], build) + elf = command(label+'-elf', [binaries/'llvm-readelf', '-h', artifact/'luajit', artifact/'c-api-upvalues'], build) + machines = re.findall(r'^\s*Machine:\s*(.+)$', elf, re.M) + if machines != [machine, machine]: raise RuntimeError('Unexpected target machine: '+repr(machines)) + results[label] = {'abi': abi, 'api': a.api, 'capacityRequested': capacity, 'buildPassed': True, + 'targetExecutionQualified': False, 'cApiLinked': True, 'machine': machine, + 'files': {str(p.relative_to(artifact)): sha(p) for p in artifact.rglob('*') if p.is_file()}} + print(label, 'BUILD/LINK PASS; target execution pending', flush=True) + if any(sha(source/name) != digest for name,digest in hashes.items()): + raise RuntimeError('Source changed during runtime build') + if any((ROOT/name).read_bytes() != data for name,data in probe_inputs.items()): + raise RuntimeError('Probe inputs changed during runtime build') + report = {'sourceRelease': RELVER, 'sourceSha256': hashes, + 'probeInputSha256': {name: hashlib.sha256(data).hexdigest() for name,data in probe_inputs.items()}, + 'ndkPropertiesSha256': sha(ndk/'source.properties'), 'results': results, + 'scope': 'Isolated stock/extended Android libraries, matching headers, interpreter and C API executables. Build/link/architecture evidence; execution on Android and actual LOVE host ABI remain required before release selection.', + 'playerProfilesUsed': False, 'minimumFreeGiB': 20, 'releaseSelectable': False} + (output/'report.json').write_text(json.dumps(report, indent=2)+'\n') + print('report', output/'report.json', flush=True) + + +if __name__ == '__main__': main() diff --git a/tools/mobile-runtime-check/check_android_target.py b/tools/mobile-runtime-check/check_android_target.py new file mode 100644 index 000000000..312712085 --- /dev/null +++ b/tools/mobile-runtime-check/check_android_target.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Execute matching stock/extended boundary probes on an owned Android emulator.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse +import atexit +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import time +import uuid + +ROOT = Path(__file__).resolve().parents[2] +ABIS = ('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64') + + +def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--adb', default='adb') + p.add_argument('--serial', required=True) + p.add_argument('--abi', choices=ABIS, required=True) + p.add_argument('--build', type=Path, required=True) + p.add_argument('--output', type=Path) + a = p.parse_args() + if not re.fullmatch(r'emulator-[0-9]{4,5}', a.serial): p.error('Choose an explicit owned emulator serial') + adb = Path(shutil.which(a.adb) or a.adb).resolve(strict=True) + build = a.build.resolve(strict=True); source = build/'report.json' + report = json.loads(source.read_text()); source_hash = sha(source) + selected = {} + for variant,capacity in (('stock',60),('extended',120)): + entry = report['results'][variant+'-'+a.abi] + if not entry['buildPassed'] or entry['capacityRequested'] != capacity: raise ValueError('Candidate build failed') + folder = build/'artifacts'/variant/a.abi + for name in ('luajit','c-api-upvalues','check.lua','src/link/Json.lua'): + if sha(folder/name) != entry['files'][name]: raise ValueError('Candidate bytes changed: '+name) + selected[variant] = (folder,entry['files']) + output = (a.output or ROOT/'.local/qa'/('gen2-android-target-'+str(time.time_ns()))/'candidate').resolve() + run = subprocess.run + if not os.getenv('GITHUB_ACTIONS'): + sys.path.insert(0,str(ROOT/'tools')) + from qa_storage import Storage + storage=Storage(ROOT);atexit.register(storage.close) + for path in (ROOT,build,adb): storage.source(path) + storage.cache(output.parent);storage.copy(output) + run=storage.run + output.mkdir(parents=True,exist_ok=False) + sequence=0 + + def command(label,args): + nonlocal sequence + sequence+=1 + result=run([str(adb),'-s',a.serial,*args],capture_output=True,text=True,timeout=120) + (output/('%02d-'%sequence+label+'.log')).write_text(result.stdout+result.stderr) + if result.returncode:print(result.stdout+result.stderr,flush=True) + result.check_returncode();return result.stdout.strip() + + if command('emulator',['shell','getprop','ro.kernel.qemu']) != '1': raise ValueError('Target is not an emulator') + supported=command('abi',['shell','getprop','ro.product.cpu.abilist']).split(',') + if a.abi not in supported: raise ValueError('Emulator does not support the selected ABI') + remote='/data/local/tmp/gen2-runtime-'+uuid.uuid4().hex + command('mkdir',['shell','mkdir',remote]) + results={} + try: + for variant,capacity in (('stock',60),('extended',120)): + folder,expected_hashes=selected[variant] + command('push-'+variant,['push',str(folder/'luajit'),str(folder/'c-api-upvalues'),str(folder/'check.lua'),remote+'/']) + command('json-dir',['shell','mkdir','-p',remote+'/src/link']) + command('json',['push',str(folder/'src/link/Json.lua'),remote+'/src/link/Json.lua']) + for name in ('luajit','c-api-upvalues','check.lua','src/link/Json.lua'): + observed=command('sha',['shell','sha256sum',remote+'/'+name]).split()[0] + if observed!=expected_hashes[name] or sha(folder/name)!=expected_hashes[name]:raise RuntimeError('Emulator transfer changed '+name) + command('chmod',['shell','chmod','700',remote+'/luajit',remote+'/c-api-upvalues']) + command('jit-'+variant,['shell',remote+'/luajit -e '+shlex.quote('assert(jit.status())')]) + text=command('closures-'+variant,['shell','cd '+remote+' && ./luajit check.lua '+str(capacity)]) + line=next((s for s in text.splitlines() if s.startswith('RUNTIME_UPVALUES ')),None) + if not line:raise RuntimeError('Missing target closure report') + details=json.loads(line[len('RUNTIME_UPVALUES '):]) + if details.get('passed') is not True or details.get('minimum')!=capacity:raise RuntimeError('Target closure gate failed') + c_api=command('c-api-'+variant,['shell',remote+'/c-api-upvalues',str(capacity)]) + if c_api!='C_API_UPVALUES '+str(capacity)+' PASS':raise RuntimeError('Target C API gate failed') + results[variant]={'passed':True,'capacity':capacity,'closureReport':details,'cApiPassed':True} + print(variant,a.abi,capacity,'ANDROID EXECUTION PASS',flush=True) + finally: + # Only the explicitly named files created in this fresh test directory. + command('cleanup',['shell','rm','-f',remote+'/luajit',remote+'/c-api-upvalues',remote+'/check.lua',remote+'/src/link/Json.lua']) + command('cleanup-dirs',['shell','rmdir',remote+'/src/link',remote+'/src',remote]) + if sha(source)!=source_hash:raise RuntimeError('Build receipt changed during target execution') + for folder,expected in selected.values(): + for name in ('luajit','c-api-upvalues','check.lua','src/link/Json.lua'): + if sha(folder/name)!=expected[name]:raise RuntimeError('Candidate changed during target execution') + (output/'report.json').write_text(json.dumps({'passed':True,'abi':a.abi,'buildReportSha256':source_hash, + 'results':results,'emulator':True,'playerProfilesUsed':False,'loveHostAbiQualified':False,'releaseSelectable':False, + 'scope':'Android interpreter/JIT, debug/bytecode/GC and C API boundary execution. Actual LOVE/JNI/thread/FFI host and other architectures require separate acceptance.'},indent=2)+'\n') + print('report',output/'report.json',flush=True) + + +if __name__=='__main__':main() diff --git a/tools/runtime-upvalue-check/c_api.c b/tools/runtime-upvalue-check/c_api.c new file mode 100644 index 000000000..d9839047a --- /dev/null +++ b/tools/runtime-upvalue-check/c_api.c @@ -0,0 +1,35 @@ +/* Public Lua 5.1 C API boundary probe: no engine/mod/ROM inputs. */ +#include +#include +#include "lua.h" +#include "lauxlib.h" +#include "lualib.h" +static int capacity; +static int captured_sum(lua_State *L) { + lua_Number sum = 0; + int i; + for (i = 1; i <= capacity; ++i) sum += lua_tonumber(L, lua_upvalueindex(i)); + lua_pushnumber(L, sum); + return 1; +} +int main(int argc, char **argv) { + lua_State *L = luaL_newstate(); + int i, round; + capacity = argc > 1 ? atoi(argv[1]) : 60; + if (!L || capacity < 1 || capacity > 120) return 2; + luaL_openlibs(L); + if (!lua_checkstack(L, capacity + 4)) return 3; + for (i = 1; i <= capacity; ++i) lua_pushnumber(L, i); + lua_pushcclosure(L, captured_sum, capacity); + lua_setglobal(L, "captured_sum"); + for (round = 0; round < 10000; ++round) { + lua_getglobal(L, "captured_sum"); + if (lua_pcall(L, 0, 1, 0)) return 4; + if (lua_tonumber(L, -1) != capacity * (capacity + 1) / 2) return 5; + lua_pop(L, 1); + if (round % 251 == 0) lua_gc(L, LUA_GCCOLLECT, 0); + } + lua_close(L); + printf("C_API_UPVALUES %d PASS\n", capacity); + return 0; +} diff --git a/tools/runtime-upvalue-check/check.lua b/tools/runtime-upvalue-check/check.lua new file mode 100644 index 000000000..7f0616597 --- /dev/null +++ b/tools/runtime-upvalue-check/check.lua @@ -0,0 +1,72 @@ +-- ROM-free runtime probe. A higher minimum is an explicit experimental gate; +-- the stock runtime is still expected to support the default minimum of 60. +local minimum = tonumber(arg and arg[1]) or 60 +assert(minimum >= 1 and minimum <= 120, 'minimum must be between 1 and 120') +local Json = require('src.link.Json') +local compile = loadstring or load +local report = {runtime = jit and jit.version or _VERSION, minimum = minimum, + cases = {}, checks = 0} +local function check(value, reason) + assert(value, reason); report.checks = report.checks + 1 +end +local function factory(count) + local lines, terms = {}, {} + for i = 1, count do + lines[#lines + 1] = ('local v%d = %d'):format(i, i) + terms[#terms + 1] = 'v' .. i + end + local sum = table.concat(terms, '+') + lines[#lines + 1] = ('return function(d) v%d=v%d+d; return %s end, ' + .. 'function(d) v1=v1+d; return %s end'):format(count, count, sum, sum) + return compile(table.concat(lines, '\n'), '@synthetic-shared-upvalues-' .. count) +end +local wasJit = jit and jit.status() +for _, count in ipairs({1, 8, 32, 60, 61, 64, 96, 120}) do + local chunk, why = factory(count) + local result = {upvalues = count, compiles = chunk ~= nil} + if not chunk then + check(count > minimum, 'required closure rejected: ' .. tostring(why)) + check(tostring(why):find('upvalues', 1, true), 'unexpected compiler rejection') + else + for _, mode in ipairs({'interpreter', 'jit'}) do + if mode == 'interpreter' then if jit then jit.off(); jit.flush() end + elseif jit then jit.on(); jit.flush(); jit.opt.start('hotloop=1', 'hotexit=1') end + local first, second = chunk() + local found = 0 + while debug.getupvalue(first, found + 1) do found = found + 1 end + check(found == count, 'closure captures the requested number of values') + local expected = count * (count + 1) / 2 + for step = 1, 10000 do + expected = expected + 1 + check(first(1) == expected, 'first closure retains all captured values') + expected = expected + 1 + check(second(1) == expected, 'sibling closure shares the captured cells') + if step % 251 == 0 then collectgarbage('collect') end + end + local name, value = debug.getupvalue(first, found) + check(name ~= nil and type(value) == 'number', 'high-index debug read') + -- Match stock LuaJIT's debug-surgery contract: a previously constant + -- upvalue may be embedded in a trace. Flush before replacing it. + -- https://github.com/LuaJIT/LuaJIT/issues/968 + if jit then jit.flush() end + check(debug.setupvalue(first, found, value + 7) == name, 'high-index debug write') + expected = expected + 7 + local observed = second(0) + check(observed == expected, ('debug write reaches the shared sibling cell (%d, %s: %s vs %s)') + :format(count, mode, tostring(observed), tostring(expected))) + local dumped = string.dump(first) + local restored = assert(compile(dumped, '@restored-shared-upvalues')) + if jit then jit.flush() end + for index = 1, found do + local _, current = debug.getupvalue(first, index) + check(debug.setupvalue(restored, index, current) ~= nil, 'restore bytecode upvalue') + end + check(restored(0) == expected, 'bytecode round trip retains high-index values') + result[mode] = true + end + end + report.cases[#report.cases + 1] = result +end +if jit then if wasJit then jit.on() else jit.off() end end +report.passed = true +print('RUNTIME_UPVALUES ' .. Json.encode(report))