From d1e407db0f28fc3e8c2f2e32e34c5ed642f89ed2 Mon Sep 17 00:00:00 2001 From: pr-io Date: Thu, 1 Oct 2026 10:02:45 -0400 Subject: [PATCH 01/15] Add delete action for imported apps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- launcher/core/portable.mjs | 38 ++++++++++++++++++ launcher/main.mjs | 22 +++++++++- launcher/preload.cjs | 2 +- launcher/tests/library-actions-smoke.mjs | 20 ++++++++++ launcher/tests/portable.test.mjs | 51 +++++++++++++++++++++++- launcher/ui/game-details.jsx | 3 ++ 6 files changed, 132 insertions(+), 4 deletions(-) diff --git a/launcher/core/portable.mjs b/launcher/core/portable.mjs index 29bc5a2..6a80030 100644 --- a/launcher/core/portable.mjs +++ b/launcher/core/portable.mjs @@ -98,3 +98,41 @@ export async function carryPortableFiles(root, downloadDir, files, signal) { return files.map(file => copied.get(file) || file); } catch (error) { await fs.rm(directory, { recursive: true, force: true }); throw error; } } + +export async function removeManagedImportFiles(game, downloadDir) { + if (!path.isAbsolute(downloadDir)) return false; + const base = path.resolve(downloadDir); + const candidates = [game.sourceApk, game.apk, ...(game.files || []).map(file => file.path)]; + const roots = new Set(); + for (const candidate of candidates) { + if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) continue; + const relative = path.relative(base, path.resolve(candidate)); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue; + const parts = relative.split(path.sep); + let rootParts; + if (/^import-.+/.test(parts[0]) && /^\d+$/.test(parts[1] || '') && parts.length >= 3) { + rootParts = parts.slice(0, 1); + } else if (parts[0] === 'imports' && /^[0-9a-f-]{36}$/i.test(parts[1] || '') && parts.length >= 3) { + rootParts = parts.slice(0, 2); + } else if (parts[0] === 'quest' && parts[1] === game.package && + /^[0-9a-f-]{36}$/i.test(parts[2] || '') && parts.length >= 4) { + rootParts = parts.slice(0, 3); + } else continue; + roots.add(path.join(base, ...rootParts)); + } + const safeRoots = []; + for (const root of roots) { + let current = base; + let directory = true; + for (const part of path.relative(base, root).split(path.sep)) { + current = path.join(current, part); + let info; + try { info = await fs.lstat(current); } + catch (error) { if (error.code === 'ENOENT') { directory = false; break; } throw error; } + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Refusing to delete an imported folder outside AXRB-managed storage.'); + } + if (directory) safeRoots.push(root); + } + for (const root of safeRoots) await fs.rm(root, { recursive: true, force: true }); + return safeRoots.length > 0; +} diff --git a/launcher/main.mjs b/launcher/main.mjs index 8b18fcc..e3ddebb 100644 --- a/launcher/main.mjs +++ b/launcher/main.mjs @@ -21,7 +21,7 @@ import { migrateClockPolicy } from './core/clock-policy.mjs'; import { loadCompatibilityProfiles, resolveCompatibility, compatibilityPatchArgs, compatibilityRuntimeOptions } from './core/compatibility.mjs'; import { Ovrport, selectedPatchArgs } from './core/ovrport.mjs'; import { MetaSession } from './core/session.mjs'; -import { carryPortableFiles, configurePortable, portableOutput, sweepPortableTemp } from './core/portable.mjs'; +import { carryPortableFiles, configurePortable, portableOutput, removeManagedImportFiles, sweepPortableTemp } from './core/portable.mjs'; import { performanceScanArgs, performanceScanTimeout } from './core/performance.mjs'; import { ChatGPTAuth } from './core/chatgpt-auth.mjs'; import { AiDiagnostics, cleanEvidence, checkedText, MAX_EVIDENCE } from './core/ai-diagnostics.mjs'; @@ -158,7 +158,7 @@ function handler(name, callback) { const started = Date.now(), trace = !name.startsWith('ai') && !['state', 'diagnosticsRead', 'diagnostics', 'copyText'].includes(name); if (trace) liveDiagnostics?.append('launcher', `${name} started`, { tag: 'operation' }); try { - if (setup && setup.status.phase !== 'ready' && ['play', 'install', 'uninstall', 'import', 'patch', 'settings', 'questDevices', 'questGames', 'questImport', 'importZip', 'startAndroid', 'stopAndroid'].includes(name)) throw new Error('Complete runtime setup first.'); + if (setup && setup.status.phase !== 'ready' && ['play', 'install', 'uninstall', 'removeImported', 'import', 'patch', 'settings', 'questDevices', 'questGames', 'questImport', 'importZip', 'startAndroid', 'stopAndroid'].includes(name)) throw new Error('Complete runtime setup first.'); const value = await callback(...args); if (trace) liveDiagnostics?.append('launcher', `${name} completed (${Date.now() - started} ms)`, { tag: 'operation' }); return { ok: true, value }; @@ -503,6 +503,24 @@ handler('uninstall', id => exclusive(async () => { finally { await persist(); } return job.status === 'complete'; })); +handler('removeImported', id => exclusive(async () => { + const game = getGame(id); + if (game.source === 'meta' || !game.apk) throw new Error('Only locally imported apps can be deleted here.'); + if (runtime.child) throw new Error('Close the running game before deleting it.'); + if (state.data.jobs.some(j => j.gameId === id && ['queued', 'downloading', 'installing', 'patching', 'importing', 'uninstalling'].includes(j.status))) { + throw new Error('Finish this game’s transfer before deleting it.'); + } + const answer = await dialog.showMessageBox(window, { type: 'warning', title: 'Delete imported app', + message: `Delete ${game.name}?`, + detail: 'This uninstalls the app from AXRB’s Android emulator and deletes imported files copied into AXRB-managed storage. Android app data and saves will be lost. An original APK selected from outside AXRB will not be deleted.', + buttons: ['Cancel', 'Delete app'], defaultId: 0, cancelId: 0, noLink: true }); + if (answer.response !== 1) return false; + if (game.installed) await runtime.uninstall(game, () => {}); + await removeManagedImportFiles(game, state.data.settings.downloadDir); + state.data.games = state.data.games.filter(item => item !== game); + await persist(); + return true; +})); handler('patch', (id, selected) => exclusive(async () => { const game = getGame(id), cli = await configuredCli(); if (!game.apk) throw new Error('Download or import the APK first.'); diff --git a/launcher/preload.cjs b/launcher/preload.cjs index f93e9a1..8451254 100644 --- a/launcher/preload.cjs +++ b/launcher/preload.cjs @@ -1,6 +1,6 @@ const { contextBridge, ipcRenderer } = require('electron'); // Account usage is read in the main process; OAuth credentials never cross IPC. -const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storage', 'permissions', 'setPermission']; +const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'removeImported', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storage', 'permissions', 'setPermission']; contextBridge.exposeInMainWorld('axrb', Object.fromEntries([ ['onAiUpdate', callback => { const listener = (_event, value) => callback(value); ipcRenderer.on('axrb:ai-update', listener); return () => ipcRenderer.removeListener('axrb:ai-update', listener); }], ...channels.map(name => [name, (...args) => ipcRenderer.invoke(`axrb:${name}`, ...args)]), diff --git a/launcher/tests/library-actions-smoke.mjs b/launcher/tests/library-actions-smoke.mjs index 84c41a8..873c66e 100644 --- a/launcher/tests/library-actions-smoke.mjs +++ b/launcher/tests/library-actions-smoke.mjs @@ -70,6 +70,26 @@ export async function libraryActionsSmoke(window, { state, runtime, dialog, pers await js(`window.axrb.import()`); await check(`Array.from(document.querySelectorAll('[data-job-toast]')).some(e => e.textContent.includes('Reading APK'))`, 'APK progress toast missing'); await check(`Array.from(document.querySelectorAll('[data-job-toast]')).some(e => e.textContent.includes('APK toast test: imported'))`, 'APK completion toast missing'); + const importedId = 'local:com.axrbtest.imported'; + await check(`Boolean(document.querySelector('[data-game="${importedId}"]'))`, 'Imported APK missing from library'); + await js(`document.querySelector('[data-game="${importedId}"]').click()`); + await check(`Boolean(document.querySelector('[aria-label="Game actions"]'))`, 'Imported app actions missing'); + await js(`document.querySelector('[aria-label="Game actions"]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse', ctrlKey: false }))`); + await check(`Array.from(document.querySelectorAll('[role="menuitem"]')).some(e => e.textContent === 'Delete imported app')`, 'Delete imported app action missing'); + dialog.showMessageBox = async (_window, options) => { confirmation = options; return { response: 0 }; }; + await js(`Array.from(document.querySelectorAll('[role="menuitem"]')).find(e => e.textContent === 'Delete imported app').click()`); + await check(`!document.querySelector('[role="menu"]')`, 'Delete menu did not close'); + await wait(); + assert.match(confirmation.detail, /Android app data and saves will be lost/); + assert.ok(state.data.games.some(item => item.id === importedId), 'Cancel must keep the imported app'); + dialog.showMessageBox = async () => ({ response: 1 }); + await js(`document.querySelector('[aria-label="Game actions"]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse', ctrlKey: false }))`); + await check(`Array.from(document.querySelectorAll('[role="menuitem"]')).some(e => e.textContent === 'Delete imported app')`, 'Delete action missing after cancellation'); + await js(`Array.from(document.querySelectorAll('[role="menuitem"]')).find(e => e.textContent === 'Delete imported app').click()`); + assert.equal(state.data.games.some(item => item.id === importedId), false); + await check(`!document.querySelector('[data-game="${importedId}"]')`, 'Deleted imported app remains in library'); + await check(`document.body.textContent.includes('Imported app deleted')`, 'Delete completion notification missing'); + dialog.showMessageBox = original.confirm; runtime.inspect = async () => { throw Error('Invalid APK fixture'); }; await js(`window.axrb.import()`); await check(`Array.from(document.querySelectorAll('[data-job-toast]')).some(e => e.textContent.includes('Invalid APK fixture'))`, 'APK failure toast missing'); diff --git a/launcher/tests/portable.test.mjs b/launcher/tests/portable.test.mjs index 8c869c3..efaf110 100644 --- a/launcher/tests/portable.test.mjs +++ b/launcher/tests/portable.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { carryPortableFiles, portableOutput, sweepPortableTemp } from '../core/portable.mjs'; +import { carryPortableFiles, portableOutput, removeManagedImportFiles, sweepPortableTemp } from '../core/portable.mjs'; import { State } from '../core/state.mjs'; async function fixture(t) { @@ -64,6 +64,55 @@ test('cancelled portable imports remove their partial copy and keep the source', assert.deepEqual(await carryPortableFiles('', downloads, [source]), [source], 'nonportable imports remain user-managed'); }); +test('removing imports deletes only recognized AXRB-managed folders', async t => { + const { external, downloads } = await fixture(t); + const apkDirectory = path.join(downloads, 'import-abc123', '0'); + await fs.mkdir(apkDirectory, { recursive: true }); + const apk = path.join(apkDirectory, 'base.apk'); + await fs.writeFile(apk, 'copied APK'); + const assetDirectory = path.join(downloads, 'import-def456', '0'); + await fs.mkdir(assetDirectory, { recursive: true }); + const asset = path.join(assetDirectory, 'main.obb'); + await fs.writeFile(asset, 'copied asset'); + assert.equal(await removeManagedImportFiles({ apk, files: [{ path: asset }] }, downloads), true, 'legacy imports without provenance are cleaned up too'); + assert.equal(await fs.stat(path.join(downloads, 'import-abc123')).then(() => true, () => false), false); + assert.equal(await fs.stat(path.join(downloads, 'import-def456')).then(() => true, () => false), false); + + const zipDirectory = path.join(downloads, 'imports', '12345678-1234-1234-1234-123456789abc'); + await fs.mkdir(zipDirectory, { recursive: true }); + const zipApk = path.join(zipDirectory, 'base.apk'); + await fs.writeFile(zipApk, 'ZIP APK'); + assert.equal(await removeManagedImportFiles({ importedFrom: 'zip', apk: zipApk }, downloads), true); + assert.equal(await fs.stat(zipDirectory).then(() => true, () => false), false); + + const questDirectory = path.join(downloads, 'quest', 'com.example.game', '12345678-1234-1234-1234-123456789abc'); + await fs.mkdir(questDirectory, { recursive: true }); + const questApk = path.join(questDirectory, 'base.apk'); + await fs.writeFile(questApk, 'Quest APK'); + assert.equal(await removeManagedImportFiles({ importedFrom: 'quest', package: 'com.example.game', apk: questApk }, downloads), true); + assert.equal(await fs.stat(questDirectory).then(() => true, () => false), false); + + const original = path.join(external, 'user.apk'); + await fs.writeFile(original, 'user-owned APK'); + assert.equal(await removeManagedImportFiles({ importedFrom: 'apk', apk: original }, downloads), false); + assert.equal(await fs.readFile(original, 'utf8'), 'user-owned APK'); + assert.equal(await removeManagedImportFiles({ importedFrom: 'zip', apk: path.join(external, 'imports', '12345678-1234-1234-1234-123456789abc', 'base.apk') }, downloads), false); +}); + +test('removing imports refuses managed-looking paths redirected outside downloads', async t => { + const { external, downloads } = await fixture(t); + const importRoot = path.join(external, '12345678-1234-1234-1234-123456789abc'); + await fs.mkdir(importRoot, { recursive: true }); + const apk = path.join(importRoot, 'base.apk'); + await fs.writeFile(apk, 'do not remove'); + await fs.mkdir(downloads, { recursive: true }); + await fs.symlink(external, path.join(downloads, 'imports'), 'junction'); + await assert.rejects(removeManagedImportFiles({ + importedFrom: 'zip', apk: path.join(downloads, 'imports', '12345678-1234-1234-1234-123456789abc', 'base.apk') + }, downloads), /Refusing to delete/); + assert.equal(await fs.readFile(apk, 'utf8'), 'do not remove'); +}); + test('the portable temp sweep reclaims stale scratch and leaves a live run alone', async t => { const { root } = await fixture(t), temporary = path.join(root, 'temp'); const stale = path.join(temporary, 'emulator-crash'), fresh = path.join(temporary, 'this-run'); diff --git a/launcher/ui/game-details.jsx b/launcher/ui/game-details.jsx index 8bc21e5..7f26237 100644 --- a/launcher/ui/game-details.jsx +++ b/launcher/ui/game-details.jsx @@ -93,6 +93,9 @@ export function GameDetails({ game, state, local, onClose, run, pending, setPage {!game.apk && game.source !== 'meta' && run('import', () => call('import'))}>Import APK} {game.installed && loadExtra('permissions')}>Android permissions} {game.installed && <> operate(() => call('uninstall', game.id))}>Uninstall} + {game.apk && game.source !== 'meta' && <> operate(async () => { + if (await call('removeImported', game.id)) { notify('Imported app deleted'); onClose(); } + })}>Delete imported app} } } From bae059964d18e1f1760b5eff8171b25bfe1d3bf0 Mon Sep 17 00:00:00 2001 From: pr-io Date: Thu, 1 Oct 2026 10:40:13 -0400 Subject: [PATCH 02/15] Add Android storage usage bars Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- launcher/core/storage-info.mjs | 40 ++++++++++++++++++ launcher/main.mjs | 27 ++++++++++++ launcher/preload.cjs | 2 +- launcher/tests/storage.test.mjs | 21 ++++++++++ launcher/ui/settings.jsx | 73 ++++++++++++++++++++++++++++++--- 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 launcher/core/storage-info.mjs diff --git a/launcher/core/storage-info.mjs b/launcher/core/storage-info.mjs new file mode 100644 index 0000000..757d2a7 --- /dev/null +++ b/launcher/core/storage-info.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export function parseAndroidDataUsage(output) { + const row = String(output ?? '').split(/\r?\n/).map(line => line.trim().split(/\s+/)) + .find(fields => fields.at(-1) === '/data' && fields.length >= 5); + if (!row || !/^\d+$/.test(row[1]) || !/^\d+$/.test(row[2]) || !/^\d+$/.test(row[3])) { + throw new Error('Could not read Android /data storage usage.'); + } + const [total, used, free] = row.slice(1, 4).map(value => Number(value) * 1024); + if (![total, used, free].every(Number.isSafeInteger) || total <= 0 || used > total || free > total) { + throw new Error('Android returned invalid /data storage usage.'); + } + return { totalBytes: total, usedBytes: used, freeBytes: free }; +} + +export async function measureDirectoryBytes(directory) { + let total = 0; + async function walk(current) { + let entries; + try { entries = await fs.readdir(current, { withFileTypes: true }); } + catch (error) { + if (error.code === 'ENOENT' && current === directory) return; + throw error; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const file = path.join(current, entry.name); + if (entry.isDirectory()) await walk(file); + else if (entry.isFile()) { + let stat; + try { stat = await fs.stat(file); } + catch (error) { if (error.code === 'ENOENT') continue; throw error; } + total += Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size; + } + } + } + await walk(directory); + return total; +} diff --git a/launcher/main.mjs b/launcher/main.mjs index 8b18fcc..1bfa20e 100644 --- a/launcher/main.mjs +++ b/launcher/main.mjs @@ -28,6 +28,7 @@ import { AiDiagnostics, cleanEvidence, checkedText, MAX_EVIDENCE } from './core/ import { DiagnosticHarness } from './core/ai-tools.mjs'; import electronUpdater from 'electron-updater'; import { LauncherUpdates } from './core/updates.mjs'; +import { measureDirectoryBytes, parseAndroidDataUsage } from './core/storage-info.mjs'; // Keep the packaged app in Electron GUI mode even when launched from a shell // that uses ELECTRON_RUN_AS_NODE for other tooling. @@ -682,6 +683,32 @@ handler('storage', async storageGB => { await persist(); return { storageGB, previousGB: currentGB, changed: true }; }); +handler('storageInfo', async () => { + const hostDirectory = runtime.settings.managedDirectory || avdDirectory(runtime.settings); + let hostPath = hostDirectory; + while (true) { + try { await fs.stat(hostPath); break; } + catch (error) { + if (error.code !== 'ENOENT') throw error; + const parent = path.dirname(hostPath); + if (parent === hostPath) throw error; + hostPath = parent; + } + } + const disk = await fs.statfs(hostPath); + const totalBytes = Number(disk.blocks) * Number(disk.bsize); + const freeBytes = Number(disk.bavail) * Number(disk.bsize); + const host = { + drive: path.parse(hostDirectory).root || 'Host drive', + usedBytes: await measureDirectoryBytes(hostDirectory), + totalBytes, + freeBytes, + }; + const android = await runtime.online() + ? parseAndroidDataUsage(await runtime.adb(['shell', 'df', '-k', '/data'], { timeout: 10000 })) + : null; + return { host, android }; +}); handler('chooseFolder', async () => { const choice = await dialog.showOpenDialog(window, { defaultPath: portable || undefined, properties: ['openDirectory', 'createDirectory'] }); return choice.canceled ? null : portableOutput(portable, choice.filePaths[0]); diff --git a/launcher/preload.cjs b/launcher/preload.cjs index f93e9a1..ef55a50 100644 --- a/launcher/preload.cjs +++ b/launcher/preload.cjs @@ -1,6 +1,6 @@ const { contextBridge, ipcRenderer } = require('electron'); // Account usage is read in the main process; OAuth credentials never cross IPC. -const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storage', 'permissions', 'setPermission']; +const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storage', 'storageInfo', 'permissions', 'setPermission']; contextBridge.exposeInMainWorld('axrb', Object.fromEntries([ ['onAiUpdate', callback => { const listener = (_event, value) => callback(value); ipcRenderer.on('axrb:ai-update', listener); return () => ipcRenderer.removeListener('axrb:ai-update', listener); }], ...channels.map(name => [name, (...args) => ipcRenderer.invoke(`axrb:${name}`, ...args)]), diff --git a/launcher/tests/storage.test.mjs b/launcher/tests/storage.test.mjs index 47d1f32..f6c877a 100644 --- a/launcher/tests/storage.test.mjs +++ b/launcher/tests/storage.test.mjs @@ -1,7 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { avdConfig, avdDirectory, parseStorageGB, planStorageChange, withStorageGB, STORAGE_MAX_GB, STORAGE_MIN_GB } from '../core/setup.mjs'; +import { measureDirectoryBytes, parseAndroidDataUsage } from '../core/storage-info.mjs'; test('the configured partition size is read back from a real AVD config', () => { assert.equal(parseStorageGB('AvdId=axrb\ndisk.dataPartition.size=64G\nhw.ramSize=8192\n'), 64); @@ -60,3 +63,21 @@ test('a freshly provisioned AVD and a resized one agree on the size format', () assert.equal(parseStorageGB(created), 64, 'setup and resize must write a size the other can read'); assert.equal(parseStorageGB(withStorageGB(created, 96)), 96); }); + +test('Android data usage reads the mounted /data capacity and rejects incomplete output', () => { + assert.deepEqual(parseAndroidDataUsage('Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/block/vda 10485760 4194304 6291456 40% /data\n'), + { totalBytes: 10 * 1024 ** 3, usedBytes: 4 * 1024 ** 3, freeBytes: 6 * 1024 ** 3 }); + assert.throws(() => parseAndroidDataUsage('Filesystem 1K-blocks Used Available Use% Mounted on\n'), /Could not read/); +}); + +test('host footprint totals files in nested runtime directories', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'axrb-storage-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const avd = path.join(root, 'avd'); + await fs.mkdir(path.join(avd, 'nested'), { recursive: true }); + await fs.writeFile(path.join(avd, 'nested', 'userdata-qemu.img'), Buffer.alloc(2048)); + const usage = await measureDirectoryBytes(avd); + const stat = await fs.stat(path.join(avd, 'nested', 'userdata-qemu.img')); + assert.equal(usage, Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size); + assert.equal(await measureDirectoryBytes(path.join(root, 'missing')), 0); +}); diff --git a/launcher/ui/settings.jsx b/launcher/ui/settings.jsx index bdae505..8c6346d 100644 --- a/launcher/ui/settings.jsx +++ b/launcher/ui/settings.jsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { activeStatuses, call, EmulatorStatus } from './common'; +import { activeStatuses, bytes, call, EmulatorStatus } from './common'; export function Settings({ state, run, pending, notify }) { const [draft, setDraft] = useState({ ...state.settings }); @@ -10,7 +10,31 @@ export function Settings({ state, run, pending, notify }) { const [report, setReport] = useState(null); const [scan, setScan] = useState(null); const [storage, setStorage] = useState(state.settings.storageGB ?? 32); + const [storageInfo, setStorageInfo] = useState(null); + const [storageError, setStorageError] = useState(''); + const [loadingStorage, setLoadingStorage] = useState(false); + const storageRequest = useRef(false); + const hostCapacity = storageInfo?.host.totalBytes || 0; + const hostEnvironment = Math.min(storageInfo?.host.usedBytes || 0, hostCapacity); + const hostUsed = Math.max(0, Math.min(hostCapacity, hostCapacity - (storageInfo?.host.freeBytes || 0))); + const hostOther = Math.min(Math.max(0, hostUsed - hostEnvironment), hostCapacity - hostEnvironment); + const hostFree = Math.max(0, hostCapacity - hostEnvironment - hostOther); + const hostPercent = value => hostCapacity ? `${value / hostCapacity * 100}%` : '0%'; + const androidPercent = value => storageInfo?.android?.totalBytes + ? `${Math.min(100, value / storageInfo.android.totalBytes * 100)}%` : '0%'; const edit = (key, value) => { setDraft(d => ({ ...d, [key]: value })); setDirty(true); }; + const refreshStorageInfo = async () => { + if (storageRequest.current) return; + storageRequest.current = true; setLoadingStorage(true); setStorageError(''); + try { setStorageInfo(await call('storageInfo')); } + catch (error) { setStorageError(error.message); } + finally { storageRequest.current = false; setLoadingStorage(false); } + }; + useEffect(() => { + void refreshStorageInfo(); + const timer = setInterval(() => void refreshStorageInfo(), 30000); + return () => clearInterval(timer); + }, []); const browse = key => run(`choose-${key}`, async () => { const value = await call(key === 'ovrportCli' ? 'chooseCli' : 'chooseFolder'); if (value) edit(key, value); @@ -40,9 +64,9 @@ export function Settings({ state, run, pending, notify }) {
+ onClick={() => run('start-android', async () => { await call('startAndroid'); await refreshStorageInfo(); notify('Android started'); })}>Start + onClick={() => run('stop-android', async () => { await call('stopAndroid'); await refreshStorageInfo(); notify('Android stopped'); })}>Stop
{/* Escaped so the class stays valid under the stricter v-mode regex engine some Chromium builds use for pattern validation; an unescaped trailing hyphen used to pass unnoticed. */} @@ -56,11 +80,50 @@ export function Settings({ state, run, pending, notify }) {

Storage can only grow, and only while Android is stopped. The space is claimed as Android fills it, not up front.

+
+
+

Storage usage

+ +
+ {storageError &&

{storageError}

} + {storageInfo && <> +
+
+ + + +
+
+ Other drive usage · {bytes(hostOther)} + Android environment · {bytes(hostEnvironment)} + Free · {bytes(hostFree)} + {storageInfo.host.drive} · {bytes(storageInfo.host.totalBytes)} total +
+
+ {storageInfo.android + ?
+
+ Android internal storage + {bytes(storageInfo.android.usedBytes)} used · {bytes(storageInfo.android.freeBytes)} free of {bytes(storageInfo.android.totalBytes)} +
+
+
+
+
+ :

Start Android to see internal storage usage. Host usage is the allocated size of the managed runtime folder, or the AVD folder for a custom SDK setup. Values refresh every 30 seconds.

} + } + {!storageInfo && !storageError &&

Loading storage usage…

} +
{draft.cpuCores ?? 4}
edit('cpuCores', Number(e.target.value))} className="w-full accent-primary" aria-describedby="cpu-restart" /> From 165dc3ad212b2bdbbb4c01f3a618c5e4a2c960a7 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:41:21 +0200 Subject: [PATCH 03/15] Decouple controller input from grip activity and clarify tracking diagnostics --- host/src/openxr_session.h | 5 +++ host/src/tracking.cpp | 62 ++++++++++++++++++++++++++-------- runtime/src/spaces.cpp | 7 ++-- tests/native/runtime_smoke.cpp | 6 ++++ 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/host/src/openxr_session.h b/host/src/openxr_session.h index a80b95b..a8f3460 100644 --- a/host/src/openxr_session.h +++ b/host/src/openxr_session.h @@ -470,6 +470,11 @@ class OpenXrSession { bool sessionVisible_ = false; bool useFrameLoop_ = true; bool controllerActionsReady_ = false; + struct ControllerDiagnostics { + uint64_t samples = 0, aimInactive = 0, gripInactive = 0, aimInvalid = 0, gripInvalid = 0; + }; + std::array controllerDiagnostics_{}; + uint64_t controllerSyncSamples_ = 0, controllerUnfocusedSamples_ = 0, controllerSyncFailures_ = 0; MenuShortcut menuShortcut_; bool reportedSyncFailure_ = false; axrb::protocol::PoseFrame latest_{}; diff --git a/host/src/tracking.cpp b/host/src/tracking.cpp index 80ac5a3..ebc7f6d 100644 --- a/host/src/tracking.cpp +++ b/host/src/tracking.cpp @@ -462,7 +462,8 @@ void OpenXrSession::initialize_controller_actions() if (createActionSpace_(session_, &info, &aimSpaces_[i]) != XR_SUCCESS) return; } - controllerActionsReady_ = handSpaces_[0] != XR_NULL_HANDLE || handSpaces_[1] != XR_NULL_HANDLE; + controllerActionsReady_ = handSpaces_[0] != XR_NULL_HANDLE || handSpaces_[1] != XR_NULL_HANDLE || + aimSpaces_[0] != XR_NULL_HANDLE || aimSpaces_[1] != XR_NULL_HANDLE; if (controllerActionsReady_) { std::fprintf(stderr, "AXRB OpenXR: controller pose actions ready\n"); } @@ -550,6 +551,14 @@ void OpenXrSession::locate_controller_spaces(axrb::protocol::PoseFrame& frame, X syncInfo.countActiveActionSets = 1; syncInfo.activeActionSets = &activeActionSet; XrResult result = syncActions_(session_, &syncInfo); + ++controllerSyncSamples_; + controllerUnfocusedSamples_ += result == XR_SESSION_NOT_FOCUSED; + controllerSyncFailures_ += result != XR_SUCCESS && result != XR_SESSION_NOT_FOCUSED; + if (controllerSyncSamples_ == 1 || controllerSyncSamples_ % kPoseLogInterval == 0) { + std::fprintf(stderr, "AXRB Input: sync samples=%llu unfocused=%llu failures=%llu result=%d\n", + (unsigned long long)controllerSyncSamples_, (unsigned long long)controllerUnfocusedSamples_, + (unsigned long long)controllerSyncFailures_, result); + } if (result == XR_SESSION_NOT_FOCUSED) { menuShortcut_.reset(); return; @@ -565,20 +574,19 @@ void OpenXrSession::locate_controller_spaces(axrb::protocol::PoseFrame& frame, X bool locatedAny = false; for (size_t i = 0; i < handSpaces_.size(); ++i) { - if (handSpaces_[i] == XR_NULL_HANDLE) { - continue; - } - XrActionStateGetInfo stateInfo{XR_TYPE_ACTION_STATE_GET_INFO}; stateInfo.action = aimPoseAction_; stateInfo.subactionPath = handSubactionPaths_[i]; XrActionStatePose aimState{XR_TYPE_ACTION_STATE_POSE}; - if (getActionStatePose_(session_, &stateInfo, &aimState) == XR_SUCCESS && aimState.isActive) { + const XrResult aimResult = getActionStatePose_(session_, &stateInfo, &aimState); + XrResult aimLocateResult = XR_ERROR_HANDLE_INVALID; + if (aimResult == XR_SUCCESS && aimState.isActive && aimSpaces_[i] != XR_NULL_HANDLE) { frame.aim_active[i] = 1; XrSpaceLocation aimLocation{XR_TYPE_SPACE_LOCATION}; XrSpaceVelocity aimVelocity{XR_TYPE_SPACE_VELOCITY}; aimLocation.next = &aimVelocity; - if (locateSpace_(aimSpaces_[i], localSpace_, locateTime, &aimLocation) == XR_SUCCESS) { + aimLocateResult = locateSpace_(aimSpaces_[i], localSpace_, locateTime, &aimLocation); + if (aimLocateResult == XR_SUCCESS) { frame.aim_flags[i] = aimLocation.locationFlags; frame.aim[i] = to_protocol_pose(aimLocation.pose); frame.aim_velocity[i] = to_protocol_velocity(aimVelocity, aimLocation.locationFlags); @@ -588,12 +596,13 @@ void OpenXrSession::locate_controller_spaces(axrb::protocol::PoseFrame& frame, X stateInfo.subactionPath = handSubactionPaths_[i]; XrActionStatePose state{XR_TYPE_ACTION_STATE_POSE}; result = getActionStatePose_(session_, &stateInfo, &state); - if (result != XR_SUCCESS || state.isActive == XR_FALSE) { - continue; - } + const XrResult gripResult = result; + const bool gripActive = result == XR_SUCCESS && state.isActive; auto& input = frame.controllers[i]; - input.active = 1; + // Pose actions and scalar actions have independent activity. An + // unbound/inactive grip must not discard a live aim or button action. + input.active = gripActive || frame.aim_active[i]; constexpr uint32_t buttonBits[] = {axrb::protocol::PrimaryClick, axrb::protocol::SecondaryClick, axrb::protocol::MenuClick, axrb::protocol::StickClick, axrb::protocol::PrimaryTouch, axrb::protocol::SecondaryTouch, axrb::protocol::TriggerTouch, axrb::protocol::StickTouch, @@ -602,25 +611,48 @@ void OpenXrSession::locate_controller_spaces(axrb::protocol::PoseFrame& frame, X stateInfo.action = inputActions_[action]; if (action <= Squeeze) { XrActionStateFloat value{XR_TYPE_ACTION_STATE_FLOAT}; - if (getActionStateFloat_(session_, &stateInfo, &value) == XR_SUCCESS && value.isActive) + if (getActionStateFloat_(session_, &stateInfo, &value) == XR_SUCCESS && value.isActive) { + input.active = 1; (action == Trigger ? input.trigger : input.squeeze) = value.currentState; + } } else if (action == Stick) { XrActionStateVector2f value{XR_TYPE_ACTION_STATE_VECTOR2F}; if (getActionStateVector2f_(session_, &stateInfo, &value) == XR_SUCCESS && value.isActive) { + input.active = 1; input.stick_x = value.currentState.x; input.stick_y = value.currentState.y; } } else { XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; - if (getActionStateBoolean_(session_, &stateInfo, &value) == XR_SUCCESS && value.isActive && value.currentState) - input.buttons |= buttonBits[action - Primary]; + if (getActionStateBoolean_(session_, &stateInfo, &value) == XR_SUCCESS && value.isActive) { + input.active = 1; + if (value.currentState) input.buttons |= buttonBits[action - Primary]; + } } } XrSpaceLocation location{XR_TYPE_SPACE_LOCATION}; XrSpaceVelocity gripVelocity{XR_TYPE_SPACE_VELOCITY}; location.next = &gripVelocity; - result = locateSpace_(handSpaces_[i], localSpace_, locateTime, &location); + result = gripActive && handSpaces_[i] != XR_NULL_HANDLE + ? locateSpace_(handSpaces_[i], localSpace_, locateTime, &location) : XR_ERROR_HANDLE_INVALID; if (result == XR_SUCCESS) frame.grip_flags[i] = location.locationFlags; + auto& diagnostic = controllerDiagnostics_[i]; + ++diagnostic.samples; + diagnostic.aimInactive += aimResult != XR_SUCCESS || !aimState.isActive; + diagnostic.gripInactive += !gripActive; + diagnostic.aimInvalid += (frame.aim_flags[i] & 3) != 3; + diagnostic.gripInvalid += (frame.grip_flags[i] & 3) != 3; + if (diagnostic.samples == 1 || diagnostic.samples % kPoseLogInterval == 0) { + std::fprintf(stderr, + "AXRB Input: hand=%zu samples=%llu inactive(aim/grip)=%llu/%llu invalidPose(aim/grip)=%llu/%llu " + "stateResult=%d/%d locateResult=%d/%d active=%u/%u poseFlags=%llx/%llx velocityFlags=%u/%u\n", + i, (unsigned long long)diagnostic.samples, + (unsigned long long)diagnostic.aimInactive, (unsigned long long)diagnostic.gripInactive, + (unsigned long long)diagnostic.aimInvalid, (unsigned long long)diagnostic.gripInvalid, + aimResult, gripResult, aimLocateResult, result, frame.aim_active[i], unsigned(gripActive), + (unsigned long long)frame.aim_flags[i], (unsigned long long)frame.grip_flags[i], + unsigned(frame.aim_velocity[i].flags), unsigned(gripVelocity.velocityFlags)); + } if (result != XR_SUCCESS || (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) == 0 || (location.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) == 0) { diff --git a/runtime/src/spaces.cpp b/runtime/src/spaces.cpp index 483e21e..f6c61c6 100644 --- a/runtime/src/spaces.cpp +++ b/runtime/src/spaces.cpp @@ -203,9 +203,12 @@ XrResult XRAPI_CALL xrLocateSpace_impl( static std::atomic calls{0}; const auto sample=calls.fetch_add(1,std::memory_order_relaxed); if (sample<12 || sample%900==0) __android_log_print(ANDROID_LOG_INFO,"AXRB.Velocity", - "request=%llu kind=%d base=%d protocol=%u flags=%llu linear=(%.3f %.3f %.3f)", + "request=%llu kind=%d base=%d protocol=%u velocityFlags=%llu poseFlags=%llu seq=%llu aimActive=%u/%u controllerActive=%u/%u linear=(%.3f %.3f %.3f)", static_cast(sample),static_cast(spaceRecord->kind),static_cast(baseRecord->kind), - poseFrame.version,static_cast(v.flags),v.linear.x,v.linear.y,v.linear.z); + poseFrame.version,static_cast(v.flags), + static_cast(location->locationFlags),static_cast(poseFrame.sequence), + poseFrame.aim_active[0],poseFrame.aim_active[1],poseFrame.controllers[0].active,poseFrame.controllers[1].active, + v.linear.x,v.linear.y,v.linear.z); #endif } return XR_SUCCESS; diff --git a/tests/native/runtime_smoke.cpp b/tests/native/runtime_smoke.cpp index 7f46407..71f0242 100644 --- a/tests/native/runtime_smoke.cpp +++ b/tests/native/runtime_smoke.cpp @@ -567,6 +567,12 @@ int main() inputFrame.version=6; if (locateSpace(actionSpace,space,1,&location)!=XR_SUCCESS || velocity.velocityFlags!=0 || velocity.linearVelocity.x!=0) return EXIT_FAILURE; inputFrame.version=axrb::protocol::kPoseFrameVersion; + // Missing velocity is not missing tracking, including an active aim + // while the independent grip/controller action is inactive. + (aim ? inputFrame.aim_velocity[0] : inputFrame.grip_velocity[0]) = {}; + if (locateSpace(actionSpace, space, 1, &location) != XR_SUCCESS || + location.locationFlags != (aim ? 3 : 15) || velocity.velocityFlags != 0 || + location.pose.position.x != (aim ? 4.25f : 1.25f)) return EXIT_FAILURE; (aim ? inputFrame.aim_active[0] : inputFrame.controllers[0].active) = 0; if (locateSpace(actionSpace, space, 1, &location) != XR_SUCCESS || location.locationFlags || velocity.velocityFlags) return EXIT_FAILURE; } From 389f1dfe36dece9981e6e1e63993cde11cad9072 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:51:19 +0200 Subject: [PATCH 04/15] Preserve color encoding across OpenXR GPU frame transfers --- host/src/layer_color_renderer.h | 4 +++- host/src/presentation.cpp | 4 ++-- host/src/windows_gpu_receiver.h | 8 ++++++-- tests/native/layer_color_smoke.cpp | 26 ++++++++++++++++++++------ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/host/src/layer_color_renderer.h b/host/src/layer_color_renderer.h index 8324cdd..41c3ce8 100644 --- a/host/src/layer_color_renderer.h +++ b/host/src/layer_color_renderer.h @@ -59,7 +59,9 @@ float4 ps(float4 position : SV_Position) : SV_Target { rtv.Texture2DArray.FirstArraySlice=slice;rtv.Texture2DArray.ArraySize=1; Ptr input;Ptr output; if(FAILED(device->CreateShaderResourceView(source,&srv,&input)) || FAILED(device->CreateRenderTargetView(target,&rtv,&output))) return false; - const uint32_t conversion=(srgb && !isSrgb(src.Format) ? 1u:0u)|(srgb && !isSrgb(dst.Format) ? 2u:0u); + // Shader output is linear. An sRGB RTV encodes it automatically; + // a UNORM RTV must retain linear values, regardless of source encoding. + const uint32_t conversion=srgb && !isSrgb(src.Format) ? 1u:0u; struct Constants { protocol::LayerColor color; uint32_t flags,srgb,padding[2]; } values{color,flags,conversion,{}}; context->UpdateSubresource(constants_.Get(),0,nullptr,&values,0,0); D3D11_VIEWPORT viewport{0,0,float(src.Width),float(src.Height),0,1}; diff --git a/host/src/presentation.cpp b/host/src/presentation.cpp index c10e62a..da10c3a 100644 --- a/host/src/presentation.cpp +++ b/host/src/presentation.cpp @@ -187,10 +187,10 @@ bool OpenXrSession::create_projection_swapchain() int64_t selectedFormat = formats[0]; constexpr int64_t preferredFormats[] = { - DXGI_FORMAT_R8G8B8A8_UNORM, - DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_FORMAT_B8G8R8A8_UNORM, }; for (int64_t preferred : preferredFormats) { for (int64_t format : formats) { diff --git a/host/src/windows_gpu_receiver.h b/host/src/windows_gpu_receiver.h index 1c0fc8b..7a8f402 100644 --- a/host/src/windows_gpu_receiver.h +++ b/host/src/windows_gpu_receiver.h @@ -65,9 +65,13 @@ class WindowsGpuReceiver { } for (UINT eye = 0; eye < eyeCount; ++eye) { const UINT sourceEye = sourceEyeCount == 1 ? 0u : eye; - if (projection && !projection->colors[eye].identity()) { + const bool sourceSrgb = frame.formats[sourceEye] == 43 || frame.formats[sourceEye] == 50; + const bool targetSrgb = targetFormat == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB || + targetFormat == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + if (sourceSrgb != targetSrgb || (projection && !projection->colors[eye].identity())) { if (!colorRenderer || !colorRenderer->render(device,context,shared_[sourceEye].Get(),cached_.Get(),eye, - projection->colors[eye],protocol::image_layer_flags(*projection,eye),frame.formats[sourceEye]==43)) return false; + projection ? projection->colors[eye] : protocol::LayerColor{}, + projection ? protocol::image_layer_flags(*projection,eye) : 6u,sourceSrgb)) return false; } else context->CopySubresourceRegion(cached_.Get(), eye, 0, 0, 0, shared_[sourceEye].Get(), 0, nullptr); } return true; diff --git a/tests/native/layer_color_smoke.cpp b/tests/native/layer_color_smoke.cpp index 078a7fc..11a3b5d 100644 --- a/tests/native/layer_color_smoke.cpp +++ b/tests/native/layer_color_smoke.cpp @@ -6,11 +6,12 @@ #include using Microsoft::WRL::ComPtr; using namespace axrb::protocol; -static bool test_shared_mono_quads() { +static bool test_shared_mono_quads(bool sourceSrgb, bool targetSrgb) { ComPtr device;ComPtr context; if(FAILED(D3D11CreateDevice(nullptr,D3D_DRIVER_TYPE_HARDWARE,nullptr,0,nullptr,0,D3D11_SDK_VERSION,&device,nullptr,&context)))return false; D3D11_TEXTURE2D_DESC d{};d.Width=d.Height=4;d.MipLevels=d.ArraySize=d.SampleDesc.Count=1; - d.Format=DXGI_FORMAT_R8G8B8A8_UNORM;d.BindFlags=D3D11_BIND_SHADER_RESOURCE|D3D11_BIND_RENDER_TARGET; + d.Format=sourceSrgb ? DXGI_FORMAT_R8G8B8A8_UNORM_SRGB : DXGI_FORMAT_R8G8B8A8_UNORM; + d.BindFlags=D3D11_BIND_SHADER_RESOURCE|D3D11_BIND_RENDER_TARGET; d.MiscFlags=D3D11_RESOURCE_MISC_SHARED_NTHANDLE|D3D11_RESOURCE_MISC_SHARED; uint8_t raw[64];for(int i=0;i<64;i+=4){raw[i]=64;raw[i+1]=128;raw[i+2]=192;raw[i+3]=128;} D3D11_SUBRESOURCE_DATA init{raw,16,64};ComPtr source; @@ -19,13 +20,14 @@ static bool test_shared_mono_quads() { // matching the producer/receiver synchronization in the real transport. axrb::host::GpuCompletion sourceReady; if(!sourceReady.wait(device.Get(),context.Get()))return false; - WindowsGpuFrame frame{0xc010000000000000ull+GetCurrentProcessId(),{37,0}}; + WindowsGpuFrame frame{0xc010000000000000ull+GetCurrentProcessId(),{sourceSrgb ? 43u : 37u,0}}; wchar_t name[96];swprintf_s(name,L"Local\\AXRB_GPU_%016llx_0",frame.session); ComPtr resource;HANDLE handle=nullptr; if(FAILED(source.As(&resource)) || FAILED(resource->CreateSharedHandle(nullptr,DXGI_SHARED_RESOURCE_READ|DXGI_SHARED_RESOURCE_WRITE,name,&handle)))return false; struct Close {HANDLE h;~Close(){CloseHandle(h);}} close{handle}; axrb::host::WindowsGpuReceiver receiver;axrb::host::LayerColorRenderer renderer; d.ArraySize=2;d.MiscFlags=0;d.BindFlags=0; + d.Format=targetSrgb ? DXGI_FORMAT_R8G8B8A8_UNORM_SRGB : DXGI_FORMAT_R8G8B8A8_UNORM; ComPtr target,readback; if(FAILED(device->CreateTexture2D(&d,nullptr,&target)))return false; d.Usage=D3D11_USAGE_STAGING;d.CPUAccessFlags=D3D11_CPU_ACCESS_READ; @@ -40,7 +42,10 @@ static bool test_shared_mono_quads() { for(UINT eye=0;eye<2;++eye) { D3D11_MAPPED_SUBRESOURCE m{};if(FAILED(context->Map(readback.Get(),eye,D3D11_MAP_READ,0,&m)))return false; float expected[4];for(int i=0;i<4;++i)expected[i]=raw[i]/255.f; - apply_layer_color(expected,p.colors[eye],6);bool ok=true; + if(sourceSrgb)for(int i=0;i<3;++i)expected[i]=expected[i]<=.04045f ? expected[i]/12.92f : std::pow((expected[i]+.055f)/1.055f,2.4f); + apply_layer_color(expected,p.colors[eye],6); + if(targetSrgb)for(int i=0;i<3;++i)expected[i]=expected[i]<=.0031308f ? expected[i]*12.92f : 1.055f*std::pow(expected[i],1.f/2.4f)-.055f; + bool ok=true; for(int y=0;y<4;++y)for(int x=0;x<4;++x)for(int ch=0;ch<4;++ch) if(std::abs(int(static_cast(m.pData)[y*m.RowPitch+x*4+ch])-int(expected[ch]*255+.5f))>1) { if(x==0 && y==0)std::printf("mono mismatch mode=%d eye=%u ch=%d got=%u want=%d\n",mode,eye,ch,static_cast(m.pData)[ch],int(expected[ch]*255+.5f));ok=false; @@ -67,6 +72,13 @@ int main() { if(FAILED(device->CreateTexture2D(&desc,nullptr,&target))) return 5; desc.BindFlags=0;desc.Usage=D3D11_USAGE_STAGING;desc.CPUAccessFlags=D3D11_CPU_ACCESS_READ; if(FAILED(device->CreateTexture2D(&desc,nullptr,&readback))) return 6; + for(bool targetSrgb : {false,true}) { + desc.Format=targetSrgb ? DXGI_FORMAT_R8G8B8A8_UNORM_SRGB : DXGI_FORMAT_R8G8B8A8_UNORM; + desc.BindFlags=D3D11_BIND_RENDER_TARGET;desc.Usage=D3D11_USAGE_DEFAULT;desc.CPUAccessFlags=0; + target.Reset();readback.Reset(); + if(FAILED(device->CreateTexture2D(&desc,nullptr,&target)))return 12; + desc.BindFlags=0;desc.Usage=D3D11_USAGE_STAGING;desc.CPUAccessFlags=D3D11_CPU_ACCESS_READ; + if(FAILED(device->CreateTexture2D(&desc,nullptr,&readback)))return 13; for(uint32_t flags : {0u,2u,6u}) for(bool srgb : {false,true}) for(int mode=0;mode<3;++mode) { LayerColor c; if(mode==1) {c.scale[0]=c.scale[1]=c.scale[2]=0;} @@ -80,7 +92,7 @@ int main() { auto encode=[](float v){v=(std::max)(0.f,v);return v<=.0031308f?v*12.92f:1.055f*std::pow(v,1.f/2.4f)-.055f;}; if(srgb)for(int i=0;i<3;++i)expected[i]=decode(expected[i]); apply_layer_color(expected,c,flags); - if(srgb)for(int i=0;i<3;++i)expected[i]=encode(expected[i]); + if(targetSrgb)for(int i=0;i<3;++i)expected[i]=encode(expected[i]); bool ok=true; for(int y=0;y<4;++y)for(int x=0;x<4;++x)for(int ch=0;ch<4;++ch) { int want=int((std::max)(0.f,(std::min)(1.f,expected[ch]))*255+.5f); @@ -89,6 +101,7 @@ int main() { } context->Unmap(readback.Get(),1);if(!ok)return 9; } + } // Legacy packet decoding must supply identity instead of reading GPU bytes as color. uint8_t legacy[kLegacyGpuBatchPartBytes]{}; ImageFrameHeader h;h.header_size=64+96; @@ -97,7 +110,8 @@ int main() { std::memcpy(legacy,&h,64);std::memcpy(legacy+64,&p,96);std::memcpy(legacy+160,&g,16); auto decoded=decode_gpu_batch_part(legacy,sizeof(legacy),0); if(decoded.gpu.session!=123 || !decoded.projection.colors[0].identity() || !decoded.projection.colors[1].identity())return 10; - if(!test_shared_mono_quads())return 11; + for(bool sourceSrgb : {false,true})for(bool targetSrgb : {false,true}) + if(!test_shared_mono_quads(sourceSrgb,targetSrgb))return 11; std::puts("PASS: GPU fade/identity/bias, stereo target slice, alpha modes, linear/sRGB, finite validation, legacy metadata, distinct colors on mono packed quads and identity transitions"); return 0; } From c952c8fc34c87dd3a08f5cfd4a4acca3190661d3 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:54:17 +0200 Subject: [PATCH 05/15] Bump launcher version to 1.0.3 --- launcher/package-lock.json | 4 ++-- launcher/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/launcher/package-lock.json b/launcher/package-lock.json index 44d879d..ddb9faf 100644 --- a/launcher/package-lock.json +++ b/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "axrb-launcher", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "axrb-launcher", - "version": "1.0.2", + "version": "1.0.3", "license": "GPL-3.0-or-later", "dependencies": { "@assistant-ui/react": "0.15.22", diff --git a/launcher/package.json b/launcher/package.json index 3306142..8ec3455 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "axrb-launcher", - "version": "1.0.2", + "version": "1.0.3", "private": true, "description": "Quest library and native Windows AXRB launcher", "license": "GPL-3.0-or-later", From 6b8635c9afa90678c826d3e9269c35d171841d4d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 4 Oct 2026 14:36:39 -0400 Subject: [PATCH 06/15] Document Iron Man legacy loader compatibility requirements --- docs/iron_man_vr_compatibility.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/iron_man_vr_compatibility.md diff --git a/docs/iron_man_vr_compatibility.md b/docs/iron_man_vr_compatibility.md new file mode 100644 index 0000000..ee7ab98 --- /dev/null +++ b/docs/iron_man_vr_compatibility.md @@ -0,0 +1,22 @@ +# Iron Man VR compatibility + +This work addresses startup of the unmodified Quest Android edition of Marvel's Iron Man VR, package `com.camouflaj.salmon`. It does not apply to the PSVR edition. + +## Observed working prerequisites + +- A hardware-accelerated Android guest with ARM64 native translation and hardware Vulkan rendering. +- Quest-compatible Android identity: Unity's older Oculus hardware check requires the manufacturer to contain `oculus`. Changing only the PC OpenXR runtime does not satisfy this check. +- A legacy `com.oculus.systemdriver.DriverLoader` entry point forwarding to the matching project's ARM64 OpenXR runtime. Function resolution must fall back to `xrGetInstanceProcAddr`, including loader initialization functions that are not ELF exports. +- The Meta Platform initialization interface expected by the game's bundled loader. Refract already provides a compatibility package and its asynchronous internal Unity initialization entry point. AXRB users need that platform-service prerequisite separately. This change does not grant game ownership or provide authenticated Meta services. +- Controller-only extension exposure for the affected older Oculus plugin. During our test, controller movement and buttons worked after the workaround and a restart with controllers connected. Isolated causal proof of the extension filter versus controller startup order remains incomplete. +- Working direct GPU image transport. Hardware rendering alone does not guarantee fast frame transfer. A failed first image connection must not permanently disable sharing before any consumer has connected; failed completion after connection must retain the safety fallback. + +## Evidence and limits + +The installed Quest build 0.2.1-443305 reached stereo rendering, both controller movement and buttons, and saved progress on September 30, 2026. The test used Windows 11, a hardware-accelerated Android guest and an RTX 3070. VDXR and SteamVR both reached Iron Man rendering, but loading, latency and gameplay performance remained inconsistent. This is startup compatibility evidence, not a claim of full or performant game support. + +The optional 80 percent internal render target, sRGB color experiment, and WHPX clock-helper experiment are not startup requirements. They are outside this compatibility change. + +## Contribution status + +Draft: portable packaging, regression checks and project-specific integration are being prepared from the tested local compatibility components. Existing installations and user data are not part of the contribution. A fresh headset validation of the final portable builds is still required. From ff22eeeec0eb18730483b1bea04a8bd1908b445e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 4 Oct 2026 14:59:54 -0400 Subject: [PATCH 07/15] Add portable legacy Oculus driver and Iron Man setup helpers --- docs/iron_man_vr_compatibility.md | 19 ++- licenses/Refract-device-identity-MIT.txt | 22 +++ runtime/apk/build_apk.ps1 | 23 +++ runtime/apk/systemdriver/AndroidManifest.xml | 4 + runtime/apk/systemdriver/DriverLoader.java | 13 ++ runtime/apk/systemdriver/legacy_driver.cpp | 136 ++++++++++++++++++ .../apk/systemdriver/legacy_function_lookup.h | 11 ++ runtime/src/frame_transport.cpp | 7 + scripts/emulator/device_identity.ps1 | 36 +++++ scripts/run/install_legacy_quest_compat.ps1 | 44 ++++++ tests/CMakeLists.txt | 8 ++ tests/native/legacy_function_lookup_smoke.cpp | 22 +++ 12 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 licenses/Refract-device-identity-MIT.txt create mode 100644 runtime/apk/systemdriver/AndroidManifest.xml create mode 100644 runtime/apk/systemdriver/DriverLoader.java create mode 100644 runtime/apk/systemdriver/legacy_driver.cpp create mode 100644 runtime/apk/systemdriver/legacy_function_lookup.h create mode 100644 scripts/emulator/device_identity.ps1 create mode 100644 scripts/run/install_legacy_quest_compat.ps1 create mode 100644 tests/native/legacy_function_lookup_smoke.cpp diff --git a/docs/iron_man_vr_compatibility.md b/docs/iron_man_vr_compatibility.md index ee7ab98..46ac7ba 100644 --- a/docs/iron_man_vr_compatibility.md +++ b/docs/iron_man_vr_compatibility.md @@ -4,7 +4,8 @@ This work addresses startup of the unmodified Quest Android edition of Marvel's ## Observed working prerequisites -- A hardware-accelerated Android guest with ARM64 native translation and hardware Vulkan rendering. +- The complete Quest game APK and matching expansion assets installed in Android, not just the APK. The tested 0.2.1-443305 download contained an APK plus 33 content files. Saved progress is optional for startup. +- A hardware-accelerated Android guest with ARM64 native translation and hardware Vulkan rendering. On Windows, CPU virtualization and the Windows Hypervisor Platform must be available. ARM64 translation must support the game's Vulkan path; use the translator documented for your project rather than assuming the same translator is required by both forks. - Quest-compatible Android identity: Unity's older Oculus hardware check requires the manufacturer to contain `oculus`. Changing only the PC OpenXR runtime does not satisfy this check. - A legacy `com.oculus.systemdriver.DriverLoader` entry point forwarding to the matching project's ARM64 OpenXR runtime. Function resolution must fall back to `xrGetInstanceProcAddr`, including loader initialization functions that are not ELF exports. - The Meta Platform initialization interface expected by the game's bundled loader. Refract already provides a compatibility package and its asynchronous internal Unity initialization entry point. AXRB users need that platform-service prerequisite separately. This change does not grant game ownership or provide authenticated Meta services. @@ -19,4 +20,18 @@ The optional 80 percent internal render target, sRGB color experiment, and WHPX ## Contribution status -Draft: portable packaging, regression checks and project-specific integration are being prepared from the tested local compatibility components. Existing installations and user data are not part of the contribution. A fresh headset validation of the final portable builds is still required. +Draft: the portable ARM64 runtime/driver packages build and pass signature verification. Missing-export function-lookup regression tests pass, and runtime payload equality is verified. Existing installations and user data are not part of the contribution. A fresh headset validation of the final portable builds is still required. + +## Build and install the legacy adapter + +Build the ARM64 runtime using the project's normal runtime APK builder (`runtime/apk/build_apk.ps1 -Abi arm64-v8a -Sdk -Jdk `). The build also produces `out/android/runtime-arm64-v8a/axrb-systemdriver-debug.apk` from the same runtime library. Do not mix a driver APK with an older runtime build. + +With the emulator running, use `scripts/run/install_legacy_quest_compat.ps1 -Sdk -Port -ControllersOnly`. It installs the matching runtime and driver APKs and persists a controller-only policy scoped to Iron Man. Omit `-ControllersOnly` and rerun to clear that policy. Stop and restart the game after changing it. This affects extension discovery, not physical tracking scale. + +The installer accepts `-PlatformApk ` for an existing compatible platform initialization package, such as Refract's separately built `platform-sdk` package. It does not modify ownership state. Use that package's existing documented lawful-content configuration; this PR does not authenticate Meta cloud services or provide downloads. + +Quest identity must be configured separately before game startup. This project provides `scripts/emulator/device_identity.ps1 -Identity quest -Sdk -Serial emulator-`; it reboots the guest. After a reboot, confirm the selected hardware renderer and matching runtime packages again. Connect the PC OpenXR headset session and wake both controllers before launching Iron Man. + +The controller flag is opt-in and scoped to `persist.sys.axrb.legacy.controllers_only.com.camouflaj.salmon`; other titles keep their advertised hand tracking extensions. This portable packaging needs a fresh headset confirmation before promoting this draft as a general compatibility claim. + +The helper selects the project's normal local ADB server (AXRB 5038, Refract 5037). Pass `-AdbServerPort` if your guest uses another local server. The identity helper backs up guest build properties before changing them and addresses emulator serials only. It requires a writable, root-capable Android guest. Stop the game before installing packages or changing policy. diff --git a/licenses/Refract-device-identity-MIT.txt b/licenses/Refract-device-identity-MIT.txt new file mode 100644 index 0000000..ddbb451 --- /dev/null +++ b/licenses/Refract-device-identity-MIT.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Refract contributors +Copyright (c) 2026 Feline Reintgen (AXRB) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/runtime/apk/build_apk.ps1 b/runtime/apk/build_apk.ps1 index 21aa788..eb23aee 100644 --- a/runtime/apk/build_apk.ps1 +++ b/runtime/apk/build_apk.ps1 @@ -66,4 +66,27 @@ try { Run "$bt\apksigner.bat" ($signArgs + @('--out', "$build\axrb-openxr-runtime-debug.apk", "$build\aligned.apk")) Run "$bt\apksigner.bat" @('verify', "$build\axrb-openxr-runtime-debug.apk") Write-Host "Built $build\axrb-openxr-runtime-debug.apk" + + # Older Oculus loaders discover this package instead of the OpenXR broker. + # Bundle the same ABI/runtime and signing identity, not a separately built copy. + $driver = Join-Path $build 'systemdriver' + foreach ($dir in @('classes', 'dex', "package\lib\$Abi")) { + New-Item -ItemType Directory -Force "$driver\$dir" | Out-Null + } + $llvm = Join-Path $Sdk "ndk\$NdkVersion\toolchains\llvm\prebuilt\windows-x86_64" + $target = @{ 'x86_64' = 'x86_64-linux-android29'; 'arm64-v8a' = 'aarch64-linux-android29' }[$Abi] + Run "$llvm\bin\clang++.exe" @("--target=$target", "--sysroot=$llvm\sysroot", '-shared', '-fPIC', '-O2', '-std=c++17', '-static-libstdc++', '-Wall', '-Wextra', '-Wl,--no-undefined', "$PSScriptRoot\systemdriver\legacy_driver.cpp", '-llog', '-ldl', '-o', "$driver\package\lib\$Abi\libaxrb_legacy_driver.so") + Run "$bt\aapt2.exe" @('link', '-I', $androidJar, '--manifest', "$PSScriptRoot\systemdriver\AndroidManifest.xml", '-o', "$driver\base.apk") + Run "$Jdk\bin\javac.exe" @('-source', '8', '-target', '8', '-Xlint:-options', '-bootclasspath', $androidJar, '-d', "$driver\classes", "$PSScriptRoot\systemdriver\DriverLoader.java") + $driverClasses = @(Get-ChildItem "$driver\classes" -Recurse -Filter '*.class' | ForEach-Object FullName) + Run "$bt\d8.bat" (@('--min-api', '29', '--output', "$driver\dex") + $driverClasses) + Copy-Item "$build\package\lib\$Abi\libopenxr_runtime.so" "$driver\package\lib\$Abi" -Force + Copy-Item "$driver\dex\classes.dex" "$driver\package" -Force + Copy-Item "$driver\base.apk" "$driver\unsigned.apk" -Force + Run "$Jdk\bin\jar.exe" @('uf', "$driver\unsigned.apk", '-C', "$driver\package", 'classes.dex', '-C', "$driver\package", 'lib') + Run "$bt\zipalign.exe" @('-f', '-p', '4', "$driver\unsigned.apk", "$driver\aligned.apk") + Run "$bt\apksigner.bat" ($signArgs + @('--out', "$build\axrb-systemdriver-debug.apk", "$driver\aligned.apk")) + Run "$bt\apksigner.bat" @('verify', "$build\axrb-systemdriver-debug.apk") + Write-Host "Built $build\axrb-systemdriver-debug.apk" + } finally { $env:JAVA_HOME = $oldJavaHome } diff --git a/runtime/apk/systemdriver/AndroidManifest.xml b/runtime/apk/systemdriver/AndroidManifest.xml new file mode 100644 index 0000000..eca6647 --- /dev/null +++ b/runtime/apk/systemdriver/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/runtime/apk/systemdriver/DriverLoader.java b/runtime/apk/systemdriver/DriverLoader.java new file mode 100644 index 0000000..5b34a25 --- /dev/null +++ b/runtime/apk/systemdriver/DriverLoader.java @@ -0,0 +1,13 @@ +package com.oculus.systemdriver; + +/** Compatibility entry point for older Oculus OpenXR loaders. */ +public final class DriverLoader { + private DriverLoader() {} + public static synchronized void loadXrRuntime() { + System.loadLibrary("axrb_legacy_driver"); + nativeLoad(); + } + public static long getProcAddr(String name) { return nativeGetProcAddr(name); } + private static native void nativeLoad(); + private static native long nativeGetProcAddr(String name); +} diff --git a/runtime/apk/systemdriver/legacy_driver.cpp b/runtime/apk/systemdriver/legacy_driver.cpp new file mode 100644 index 0000000..195807a --- /dev/null +++ b/runtime/apk/systemdriver/legacy_driver.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../src/openxr_dispatch/openxr_minimal.h" +#include "legacy_function_lookup.h" + +static void* runtime = nullptr; +static PFN_xrGetInstanceProcAddr gipa = nullptr; +static PFN_xrNegotiateLoaderRuntimeInterface negotiateRuntime = nullptr; +static bool controllersOnly = false; +using EnumerateExtensions = XrResult (*)(const char*, uint32_t, uint32_t*, XrExtensionProperties*); +using GetPoseState = XrResult (*)(XrSession, const XrActionStateGetInfo*, XrActionStatePose*); +using GetFloatState = XrResult (*)(XrSession, const XrActionStateGetInfo*, XrActionStateFloat*); +static EnumerateExtensions enumerateExtensions = nullptr; +static GetPoseState poseState = nullptr; +static GetFloatState floatState = nullptr; + +static XrResult filteredExtensions(const char* layer, uint32_t capacity, uint32_t* count, XrExtensionProperties* properties) { + if (!controllersOnly || (layer && layer[0])) return enumerateExtensions(layer, capacity, count, properties); + if (!count) return XR_ERROR_VALIDATION_FAILURE; + uint32_t originalCount = 0; + XrResult result = enumerateExtensions(nullptr, 0, &originalCount, nullptr); + if (result != XR_SUCCESS) return result; + std::vector original(originalCount); + for (auto& extension : original) extension.type = XR_TYPE_EXTENSION_PROPERTIES; + result = enumerateExtensions(nullptr, originalCount, &originalCount, original.data()); + if (result != XR_SUCCESS) return result; + std::vector filtered; + for (const auto& extension : original) { + if (std::strcmp(extension.extensionName, "XR_EXT_hand_tracking") && + std::strcmp(extension.extensionName, "XR_EXT_hand_tracking_data_source")) filtered.push_back(extension); + } + *count = static_cast(filtered.size()); + if (!capacity) return XR_SUCCESS; + if (capacity < filtered.size()) return XR_ERROR_SIZE_INSUFFICIENT; + if (!properties) return XR_ERROR_VALIDATION_FAILURE; + for (size_t i = 0; i < filtered.size(); ++i) { + if (properties[i].type != XR_TYPE_EXTENSION_PROPERTIES) return XR_ERROR_VALIDATION_FAILURE; + properties[i].extensionVersion = filtered[i].extensionVersion; + std::strcpy(properties[i].extensionName, filtered[i].extensionName); + } + return XR_SUCCESS; +} +static XrResult loggedPose(XrSession session, const XrActionStateGetInfo* info, XrActionStatePose* state) { + const XrResult result = poseState(session, info, state); + static unsigned calls = 0; + if (++calls % 600 == 0 && state) __android_log_print(ANDROID_LOG_INFO, "AXRB-LegacyInput", "controller pose active=%u result=%d", state->isActive, result); + return result; +} +static XrResult loggedFloat(XrSession session, const XrActionStateGetInfo* info, XrActionStateFloat* state) { + const XrResult result = floatState(session, info, state); + static unsigned calls = 0; + if (++calls % 600 == 0 && state) __android_log_print(ANDROID_LOG_INFO, "AXRB-LegacyInput", "controller analog active=%u value=%.3f result=%d", state->isActive, state->currentState, result); + return result; +} +static XrResult compatibleGipa(XrInstance instance, const char* name, PFN_xrVoidFunction* function) { + const XrResult result = gipa(instance, name, function); + if (result != XR_SUCCESS || !name || !function || !*function) return result; + if (!std::strcmp(name, "xrEnumerateInstanceExtensionProperties")) { + enumerateExtensions = reinterpret_cast(*function); + *function = reinterpret_cast(filteredExtensions); + } else if (!std::strcmp(name, "xrGetActionStatePose")) { + poseState = reinterpret_cast(*function); + *function = reinterpret_cast(loggedPose); + } else if (!std::strcmp(name, "xrGetActionStateFloat")) { + floatState = reinterpret_cast(*function); + *function = reinterpret_cast(loggedFloat); + } + return result; +} +static XrResult compatibleNegotiate(const XrNegotiateLoaderInfo* info, XrNegotiateRuntimeRequest* request) { + const XrResult result = negotiateRuntime(info, request); + if (result == XR_SUCCESS && request) request->getInstanceProcAddr = compatibleGipa; + return result; +} +static void fail(JNIEnv* env, const char* message) { + __android_log_print(ANDROID_LOG_ERROR, "AXRB-LegacyDriver", "%s", message); + env->ThrowNew(env->FindClass("java/lang/UnsatisfiedLinkError"), message); +} +extern "C" JNIEXPORT void JNICALL +Java_com_oculus_systemdriver_DriverLoader_nativeLoad(JNIEnv* env, jclass) { + if (runtime) return; + void* handle = dlopen("libopenxr_runtime.so", RTLD_NOW | RTLD_LOCAL); + if (!handle) { fail(env, dlerror()); return; } + auto negotiate = reinterpret_cast(dlsym(handle, "xrNegotiateLoaderRuntimeInterface")); + if (!negotiate) { fail(env, "AXRB runtime negotiation symbol missing"); dlclose(handle); return; } + XrNegotiateLoaderInfo info{}; + info.structType = XR_LOADER_INTERFACE_STRUCT_LOADER_INFO; + info.structVersion = XR_LOADER_INFO_STRUCT_VERSION; + info.structSize = sizeof(info); + info.minInterfaceVersion = info.maxInterfaceVersion = XR_CURRENT_LOADER_RUNTIME_VERSION; + info.minApiVersion = XR_MAKE_VERSION(1, 0, 0); + info.maxApiVersion = XR_MAKE_VERSION(1, 1023, 4095); + XrNegotiateRuntimeRequest request{}; + request.structType = XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST; + request.structVersion = XR_RUNTIME_INFO_STRUCT_VERSION; + request.structSize = sizeof(request); + if (negotiate(&info, &request) != XR_SUCCESS || !request.getInstanceProcAddr) { + fail(env, "AXRB runtime negotiation failed"); dlclose(handle); return; + } + gipa = request.getInstanceProcAddr; + negotiateRuntime = negotiate; + runtime = handle; + char process[128]{}; + if (FILE* file = std::fopen("/proc/self/cmdline", "rb")) { + std::fread(process, 1, sizeof(process)-1, file); std::fclose(file); + } + char key[256]{}; + char flag[PROP_VALUE_MAX]{}; + std::snprintf(key, sizeof(key), "persist.sys.axrb.legacy.controllers_only.%s", process); + __system_property_get(key, flag); + controllersOnly = !std::strcmp(flag, "1"); + __android_log_print(ANDROID_LOG_INFO, "AXRB-LegacyDriver", "controller-only compatibility=%d", controllersOnly); + __android_log_print(ANDROID_LOG_INFO, "AXRB-LegacyDriver", "AXRB ARM64 runtime loaded; legacy interface ready"); +} +extern "C" JNIEXPORT jlong JNICALL +Java_com_oculus_systemdriver_DriverLoader_nativeGetProcAddr(JNIEnv* env, jclass clazz, jstring name) { + if (!runtime) Java_com_oculus_systemdriver_DriverLoader_nativeLoad(env, clazz); + if (!runtime || env->ExceptionCheck() || !name) return 0; + const char* text = env->GetStringUTFChars(name, nullptr); + if (!text) return 0; + void* result = nullptr; + if (!std::strcmp(text, "xrGetInstanceProcAddr")) result = reinterpret_cast(compatibleGipa); + else if (!std::strcmp(text, "xrNegotiateLoaderRuntimeInterface")) result = reinterpret_cast(compatibleNegotiate); + else result = reinterpret_cast(resolve_legacy_function(text, + reinterpret_cast(dlsym(runtime, text)), compatibleGipa)); + __android_log_print(ANDROID_LOG_INFO, "AXRB-LegacyDriver", "Requested %s: %s", text, result ? "available" : "unavailable"); + env->ReleaseStringUTFChars(name, text); + return reinterpret_cast(result); +} diff --git a/runtime/apk/systemdriver/legacy_function_lookup.h b/runtime/apk/systemdriver/legacy_function_lookup.h new file mode 100644 index 0000000..2a6de7c --- /dev/null +++ b/runtime/apk/systemdriver/legacy_function_lookup.h @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +#pragma once +// Include the project's OpenXR types before this header. +inline PFN_xrVoidFunction resolve_legacy_function( + const char* name, PFN_xrVoidFunction exported, PFN_xrGetInstanceProcAddr lookup) { + if (!name) return nullptr; + if (exported) return exported; + PFN_xrVoidFunction function = nullptr; + if (lookup && lookup(nullptr, name, &function) == XR_SUCCESS) return function; + return nullptr; +} diff --git a/runtime/src/frame_transport.cpp b/runtime/src/frame_transport.cpp index 18cf2cf..57822ae 100644 --- a/runtime/src/frame_transport.cpp +++ b/runtime/src/frame_transport.cpp @@ -11,6 +11,8 @@ class ImageTransportClient { close_socket(); } + bool has_connected_once() const { return everConnected_; } + // In the emulator GPU-sharing path, the host copies each exported image // into host-owned storage before acknowledging it. Defer that round trip // until immediately before the guest could overwrite the shared image, so @@ -281,6 +283,7 @@ class ImageTransportClient { void note_connected(const char* route) { + everConnected_ = true; connectedAtNs_ = monotonic_time_ns(); established_ = false; route_ = route; @@ -304,6 +307,7 @@ class ImageTransportClient { int socket_ = -1; bool directWindows_ = false; + bool everConnected_ = false; int lastError_ = 0; XrTime lastConnectAttemptNs_ = 0; XrTime connectedAtNs_ = 0; @@ -861,6 +865,9 @@ XrResult submit_projection_frame(const XrFrameEndInfo& info, uint32_t batchPart, return XR_SUCCESS; } if (batchPart) return XR_ERROR_RUNTIME_FAILURE; + // No consumer has seen the initial image if the first connection is not ready. + // Retry export next frame; retain the safety fallback after any connection. + if (!image_transport_client().has_connected_once()) return XR_SUCCESS; // Never reuse shared images after an uncertain consumer completion. g_vulkan.disable_gpu_export(); __android_log_print(ANDROID_LOG_WARN, "AXRB.GPU", "GPU consumer unavailable; disabling shared export for this session"); diff --git a/scripts/emulator/device_identity.ps1 b/scripts/emulator/device_identity.ps1 new file mode 100644 index 0000000..79261b7 --- /dev/null +++ b/scripts/emulator/device_identity.ps1 @@ -0,0 +1,36 @@ +# Derived from Refract scripts/device_identity.ps1 (MIT). +# Copyright (c) 2026 Refract contributors; Copyright (c) 2026 Florian Reintgen (AXRB). +# See licenses/Refract-device-identity-MIT.txt for the full license notice. +param([Parameter(Mandatory)][ValidateSet('quest', 'google')][string]$Identity, [ValidatePattern('^emulator-[0-9]+$')][string]$Serial = 'emulator-5580', + [string]$Sdk = "$env:LOCALAPPDATA\Android\Sdk", + [ValidateRange(1024,65535)][int]$AdbServerPort = 5038) +# Switches the emulator's brand/manufacturer between a Quest ('oculus'/'Oculus') and 'google'/'Google'. +# The model stays "Quest 3" either way. Reboots the guest; recheck hardware rendering afterwards. +# quest - unmodified Quest APKs: Unity's OculusUnity.getIsOnOculusHardware() needs "oculus" in +# Build.MANUFACTURER, and the Platform SDK then comes from the Refract platform package. +# google - OVRPort-patched APKs that must pick OVRPort's generic OpenXR loader. +# The first switch saves each build.prop as .before-identity next to it. +$ErrorActionPreference = 'Stop' +$env:ANDROID_ADB_SERVER_PORT = [string]$AdbServerPort +$env:ADB_SERVER_SOCKET = $null +$adb = Join-Path $Sdk 'platform-tools\adb.exe' +if (!(Test-Path -LiteralPath $adb)) { throw 'SDK platform-tools/adb.exe is missing.' } +& $adb -s $Serial get-state | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Start the selected emulator before changing its identity.' } +$brand, $manufacturer = if ($Identity -eq 'quest') { 'oculus', 'Oculus' } else { 'google', 'Google' } +$files = '/system/build.prop /vendor/build.prop /product/etc/build.prop /system_ext/etc/build.prop /odm/etc/build.prop' + +& $adb -s $Serial root | Out-Null +if ($LASTEXITCODE -ne 0) { throw "ADB root failed." } +& $adb -s $Serial wait-for-device +& $adb -s $Serial remount | Out-Null +if ($LASTEXITCODE -ne 0) { throw "ADB remount failed." } +$script = "set -e; for f in $files; do [ -f `$f ] || continue; [ -f `$f.before-identity ] || cp -p `$f `$f.before-identity; " + + "sed -i -E 's/^(ro\.product\.[a-z_]+\.brand)=.*/\1=$brand/; s/^(ro\.product\.[a-z_]+\.manufacturer)=.*/\1=$manufacturer/' `$f; done; " + + "grep -hE '^ro\.product\.[a-z_]+\.(brand|manufacturer)=' $files" +& $adb -s $Serial shell $script +if ($LASTEXITCODE -ne 0) { throw "Guest identity update failed; inspect the preserved build-property backups." } +& $adb -s $Serial reboot +if ($LASTEXITCODE -ne 0) { throw "Guest reboot failed." } +& $adb -s $Serial wait-for-device +& $adb -s $Serial shell 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done; getprop ro.product.brand; getprop ro.product.manufacturer' diff --git a/scripts/run/install_legacy_quest_compat.ps1 b/scripts/run/install_legacy_quest_compat.ps1 new file mode 100644 index 0000000..59df1ac --- /dev/null +++ b/scripts/run/install_legacy_quest_compat.ps1 @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MIT +param( + [string]$Sdk = "$env:LOCALAPPDATA\Android\Sdk", + [ValidateRange(5554,5682)][int]$Port = 5580, + [ValidatePattern('^[a-zA-Z0-9_.]+$')][string]$Package = 'com.camouflaj.salmon', + [string]$DriverApk, + [string]$RuntimeApk, + [ValidateRange(1024,65535)][int]$AdbServerPort = 5038, + [string]$PlatformApk, + [switch]$ControllersOnly +) +$ErrorActionPreference = 'Stop' +$env:ANDROID_ADB_SERVER_PORT = [string]$AdbServerPort +$env:ADB_SERVER_SOCKET = $null +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +if (!$DriverApk) { $DriverApk = Join-Path $root 'out/android/runtime-arm64-v8a/axrb-systemdriver-debug.apk' } +if (!$RuntimeApk) { $RuntimeApk = Join-Path $root 'out/android/runtime-arm64-v8a/axrb-openxr-runtime-debug.apk' } +$adb = Join-Path $Sdk 'platform-tools\adb.exe' +$serial = "emulator-$Port" +function Run-Adb([string[]]$Arguments) { + & $adb -s $serial @Arguments + if ($LASTEXITCODE -ne 0) { throw "ADB operation failed on $serial." } +} +if (!(Test-Path -LiteralPath $adb)) { throw 'SDK platform-tools/adb.exe is missing.' } +if (!(Test-Path -LiteralPath $RuntimeApk)) { throw 'The matching ARM64 runtime APK is missing; build it or pass -RuntimeApk.' } +if (!(Test-Path -LiteralPath $DriverApk)) { throw 'Build the ARM64 runtime and legacy driver first, or pass -DriverApk.' } +$state = & $adb -s $serial get-state +if ($LASTEXITCODE -ne 0 -or ($state -join '').Trim() -ne 'device') { throw 'Start the selected emulator and retry.' } +$running = & $adb -s $serial shell pidof $Package +if (($running -join '').Trim()) { throw 'Stop the game before installing runtime packages or changing its input policy.' } +# Only emulator guests are addressed. No platform ownership state is modified. +Run-Adb @('install', '--no-incremental', '--force-queryable', '-r', (Resolve-Path -LiteralPath $RuntimeApk).Path) +Run-Adb @('install', '--no-incremental', '--force-queryable', '-r', (Resolve-Path -LiteralPath $DriverApk).Path) +if ($PlatformApk) { + Run-Adb @('install', '--no-incremental', '--force-queryable', '-r', (Resolve-Path -LiteralPath $PlatformApk).Path) +} +$property = "persist.sys.axrb.legacy.controllers_only.$Package" +$value = if ($ControllersOnly) { '1' } else { '0' } +Run-Adb @('shell', 'setprop', $property, $value) +$actual = & $adb -s $serial shell getprop $property +if ($LASTEXITCODE -ne 0 -or ($actual -join '').Trim() -ne $value) { + throw 'Controller compatibility was not applied. A root-capable emulator is required; run adb root and retry.' +} +Write-Host "Legacy driver installed. Controller-only policy for $Package is $value; restart the game." diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7f37341..2880400 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -99,3 +99,11 @@ if(AXRB_ENABLE_PERFORMANCE_OVERLAY) axrb_native_test(axrb_fresh_frame_stats_smoke fresh_frame_stats_smoke.cpp axrb_fresh_frame_stats) target_include_directories(axrb_fresh_frame_stats_smoke PRIVATE ${PROJECT_SOURCE_DIR}/modules/performance_overlay) endif() + +add_executable(axrb_legacy_function_lookup_smoke native/legacy_function_lookup_smoke.cpp) +if(MSVC) + target_compile_options(axrb_legacy_function_lookup_smoke PRIVATE /UNDEBUG) +else() + target_compile_options(axrb_legacy_function_lookup_smoke PRIVATE -UNDEBUG) +endif() +add_test(NAME axrb_legacy_function_lookup COMMAND axrb_legacy_function_lookup_smoke) diff --git a/tests/native/legacy_function_lookup_smoke.cpp b/tests/native/legacy_function_lookup_smoke.cpp new file mode 100644 index 0000000..c973946 --- /dev/null +++ b/tests/native/legacy_function_lookup_smoke.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +#include "../../runtime/src/openxr_dispatch/openxr_minimal.h" +#include "../../runtime/apk/systemdriver/legacy_function_lookup.h" +#include +#include +static unsigned calls = 0; +static void XRAPI_CALL initialized() {} +static void XRAPI_CALL exported() {} +static XrResult XRAPI_CALL lookup(XrInstance instance, const char* name, PFN_xrVoidFunction* result) { + assert(!instance); ++calls; + *result = initialized; + return std::strcmp(name, "xrInitializeLoaderKHR") ? XR_ERROR_FUNCTION_UNSUPPORTED : XR_SUCCESS; +} +int main() { + assert(resolve_legacy_function("xrInitializeLoaderKHR", nullptr, lookup) == initialized); + assert(calls == 1); + assert(resolve_legacy_function("xrInitializeLoaderKHR", exported, lookup) == exported); + assert(calls == 1); + assert(!resolve_legacy_function("missing", nullptr, lookup)); + assert(!resolve_legacy_function("missing", nullptr, nullptr)); + assert(!resolve_legacy_function(nullptr, exported, lookup)); +} From bf2c040740b2f4b42d68c83625d4ff7ee0e9905e Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:09:28 +0200 Subject: [PATCH 08/15] Prepare AXRB 1.0.4 with performance improvements and game ZIP export Port Refract frame, texture and emulator performance improvements while retaining AXRB features. Default to GPU BC3 with BC7 selectable, and restore the compatible QEMU APIC default. Add multi-game ZIP export/import, safe export filenames, storage IPC repair, launcher debloat controls, regression coverage and license notices. Include experimental Digitalis build tooling and patches; live emulator validation remains pending. Validation: 276 launcher tests, production UI and setup build passed; native and Android runtime checks are documented in the performance port audit. --- docs/cpu_pressure.md | 17 +- docs/refract-performance-port.md | 95 + host/src/adaptive_pickup_budget.h | 36 + host/src/openxr_host.cpp | 9 +- host/src/openxr_session.cpp | 8 +- host/src/openxr_session.h | 6 + host/src/panel_swapchain.cpp | 10 +- host/src/presentation.cpp | 5 +- host/src/tracking.cpp | 31 +- launcher/THIRD_PARTY_NOTICES.md | 4 + launcher/core/debloat.mjs | 82 + launcher/core/game-export.mjs | 98 + launcher/core/game-files.mjs | 24 +- launcher/core/runtime.mjs | 2 +- launcher/core/state.mjs | 2 +- launcher/main.mjs | 79 +- launcher/package-lock.json | 25 +- launcher/package.json | 5 +- launcher/preload.cjs | 4 +- launcher/prepare-release.mjs | 13 + launcher/tests/debloat.test.mjs | 52 + launcher/tests/emulator-startup.test.mjs | 35 + launcher/tests/game-export-smoke.mjs | 61 + launcher/tests/game-export.test.mjs | 102 + launcher/tests/preload.test.mjs | 37 + launcher/tests/runtime-launch.test.mjs | 11 +- launcher/tests/ui-smoke.mjs | 17 + launcher/ui/common.jsx | 2 +- launcher/ui/downloads.jsx | 4 +- launcher/ui/game-export.jsx | 25 + launcher/ui/main.jsx | 5 +- launcher/ui/settings.jsx | 34 + launcher/ui/toasts.jsx | 2 +- licenses/Refract-MIT.txt | 27 + protocol/transport_tcp.cpp | 5 +- protocol/transport_tcp.h | 3 +- runtime/src/frame_loop.cpp | 41 +- runtime/src/frame_publisher.h | 56 + runtime/src/frame_transport.cpp | 66 +- runtime/src/session.cpp | 3 + runtime/src/spaces.cpp | 23 + runtime/src/vulkan_backend.cpp | 47 +- runtime/src/vulkan_backend.h | 23 +- runtime/vulkan/android_vulkan_layer.cpp | 63 +- runtime/vulkan/texture/astc_bc3_spirv.h | 3280 +++++++++++++++++ runtime/vulkan/texture/astc_substitution.h | 166 +- runtime/vulkan/texture/astc_to_bc7.h | 8 +- runtime/vulkan/texture/etc2_to_bc.h | 410 +++ runtime/vulkan/texture/gen_astc_bc3_shader.py | 60 + runtime/vulkan/texture/gpu_astc.h | 152 + runtime/vulkan/texture/shaders/astc_bc3.comp | 536 +++ scripts/build/source_archive.py | 2 +- scripts/emulator/android_runtime_policy.py | 8 +- scripts/emulator/p_core_affinity.ps1 | 37 + scripts/emulator/performance_policy.py | 39 + scripts/emulator/translator.py | 185 + scripts/emulator/windows_android_emulator.ps1 | 42 +- scripts/run/run_windows_game.ps1 | 7 +- tests/CMakeLists.txt | 8 + tests/android/texture_gpu_smoke.cpp | 85 + tests/android/vulkan_android_smoke.cpp | 9 +- tests/android/vulkan_astc_bc7_e2e.cpp | 1 + tests/integration/test_distribution.py | 2 +- tests/integration/test_performance_policy.py | 51 + tests/integration/test_translator_bundle.py | 46 + tests/integration/test_vulkan_descriptors.py | 4 +- tests/integration/texture_transcode_check.py | 143 + tests/integration/vulkan_texture_gpu_check.py | 36 + tests/native/adaptive_pickup_budget_smoke.cpp | 17 + tests/native/astc_gpu_check.cpp | 204 + tests/native/frame_publisher_smoke.cpp | 29 + tests/native/reference_astc_decode.h | 397 ++ tests/native/runtime_smoke.cpp | 35 + tests/native/texture_transcode_check.cpp | 66 + tools/translator/README.md | 61 + tools/translator/bionic-api36-compat.patch | 477 +++ tools/translator/build_digitalis.sh | 68 + tools/translator/collect_notices.py | 43 + .../digitalis-axrb-performance.patch | 1840 +++++++++ .../translator/digitalis-block-chaining.patch | 840 +++++ .../digitalis-heavy-readonly-tbi.patch | 135 + .../digitalis-lite-fp-pairwise-minmax.patch | 420 +++ .../digitalis-performance-fix.patch | 63 + .../digitalis-return-prediction.patch | 872 +++++ .../digitalis-simd-address-candidate.patch | 28 + .../digitalis-syslib-redirect.patch | 63 + tools/translator/export_axrb_patch.py | 11 + .../translator/native-bridge-tls-compat.patch | 54 + 88 files changed, 12213 insertions(+), 126 deletions(-) create mode 100644 docs/refract-performance-port.md create mode 100644 host/src/adaptive_pickup_budget.h create mode 100644 launcher/core/debloat.mjs create mode 100644 launcher/core/game-export.mjs create mode 100644 launcher/tests/debloat.test.mjs create mode 100644 launcher/tests/game-export-smoke.mjs create mode 100644 launcher/tests/game-export.test.mjs create mode 100644 launcher/tests/preload.test.mjs create mode 100644 launcher/ui/game-export.jsx create mode 100644 licenses/Refract-MIT.txt create mode 100644 runtime/src/frame_publisher.h create mode 100644 runtime/vulkan/texture/astc_bc3_spirv.h create mode 100644 runtime/vulkan/texture/etc2_to_bc.h create mode 100644 runtime/vulkan/texture/gen_astc_bc3_shader.py create mode 100644 runtime/vulkan/texture/gpu_astc.h create mode 100644 runtime/vulkan/texture/shaders/astc_bc3.comp create mode 100644 scripts/emulator/p_core_affinity.ps1 create mode 100644 scripts/emulator/performance_policy.py create mode 100644 scripts/emulator/translator.py create mode 100644 tests/android/texture_gpu_smoke.cpp create mode 100644 tests/integration/test_performance_policy.py create mode 100644 tests/integration/test_translator_bundle.py create mode 100644 tests/integration/texture_transcode_check.py create mode 100644 tests/integration/vulkan_texture_gpu_check.py create mode 100644 tests/native/adaptive_pickup_budget_smoke.cpp create mode 100644 tests/native/astc_gpu_check.cpp create mode 100644 tests/native/frame_publisher_smoke.cpp create mode 100644 tests/native/reference_astc_decode.h create mode 100644 tests/native/texture_transcode_check.cpp create mode 100644 tools/translator/README.md create mode 100644 tools/translator/bionic-api36-compat.patch create mode 100644 tools/translator/build_digitalis.sh create mode 100644 tools/translator/collect_notices.py create mode 100644 tools/translator/digitalis-axrb-performance.patch create mode 100644 tools/translator/digitalis-block-chaining.patch create mode 100644 tools/translator/digitalis-heavy-readonly-tbi.patch create mode 100644 tools/translator/digitalis-lite-fp-pairwise-minmax.patch create mode 100644 tools/translator/digitalis-performance-fix.patch create mode 100644 tools/translator/digitalis-return-prediction.patch create mode 100644 tools/translator/digitalis-simd-address-candidate.patch create mode 100644 tools/translator/digitalis-syslib-redirect.patch create mode 100644 tools/translator/export_axrb_patch.py create mode 100644 tools/translator/native-bridge-tls-compat.patch diff --git a/docs/cpu_pressure.md b/docs/cpu_pressure.md index 4b660b8..770e735 100644 --- a/docs/cpu_pressure.md +++ b/docs/cpu_pressure.md @@ -15,10 +15,10 @@ game. Interleaved matrix, synthetic head pose, medians of six 20 s windows | --- | --- | --- | --- | --- | | Nexus | baseline | 1.84 | 21.3 | 86.7 | | Nexus | cached buffers + native skinning | 1.25 | 14.0 (-34%) | 89.6 | -| Nexus | + hypervisor APIC + asg (default now) | 0.99 | 11.0 (-48%) | 89.8 | +| Nexus | + hypervisor APIC + asg (measured configuration) | 0.99 | 11.0 (-48%) | 89.8 | | North Star | baseline | 2.11 | 25.3 | 83.6 | | North Star | cached buffers + native skinning | 1.82 | 20.5 (-19%) | 88.8 | -| North Star | + hypervisor APIC + asg (default now) | 1.61 | 18.9 (-25%) | 85.1 | +| North Star | + hypervisor APIC + asg (measured configuration) | 1.61 | 18.9 (-25%) | 85.1 | The baseline is the state before this work: uncached host-visible buffers, Unity's own skinning code, QEMU's local APIC and the pipe graphics transport. @@ -201,7 +201,7 @@ boots, so differences below about 10% need more rounds. `session.py` refuses to measure when the hypervisor APIC was requested but is not active; the guest's reschedule-interrupt (`RES`) and `LOC` rates also tell the two apart. -It is the default with the clock helper (Finding 7); +It is opt-in after reports of boot failures on other hosts; `scripts/emulator/windows_android_emulator.ps1 -LocalApic` (`Qemu`, `Hypervisor`, `HypervisorX2Apic`), `run_windows_game.ps1 -LocalApic` and the launcher setting `localApic` override it. x2APIC measured the same as xAPIC @@ -240,11 +240,14 @@ APIC in every row, medians of four 20 s windows: Only the transport is adopted: the translator options are within noise, and precise SIGSEGV reporting is what games that catch their own faults need. -Both are defaults now: `windows_android_emulator.ps1 -LocalApic Auto` selects -the hypervisor APIC whenever the clock helper runs (TscCorrected), and +`windows_android_emulator.ps1 -LocalApic Auto` selects QEMU's APIC for boot +compatibility, including with TscCorrected. Hypervisor APIC modes remain opt-in: +WHPX accepting their setup does not prove that the guest will boot. Meanwhile, `-GraphicsTransport Asg` sets `hw.gltransport=asg` in the AVD before starting -it. `-LocalApic Qemu` and `-GraphicsTransport Pipe` restore the previous -behaviour; the launcher setting `localApic` passes through. +it. `-LocalApic Hypervisor` enables the measured APIC optimization; +`-GraphicsTransport Pipe` restores the previous transport. The launcher +setting `localApic` passes through. Explicit modes override inherited APIC +environment flags for the emulator child. ## Generic options that did not help diff --git a/docs/refract-performance-port.md b/docs/refract-performance-port.md new file mode 100644 index 0000000..ea8fc73 --- /dev/null +++ b/docs/refract-performance-port.md @@ -0,0 +1,95 @@ +# Refract performance port + +Compared against [MIXIDtheSilly/Refract](https://github.com/MIXIDtheSilly/Refract) +at `8a03ae4`. Scope: performance and latency, preserving AXRB's launcher, Meta +integration, entitlement behavior, APK handling, and supported composition layers. +Imported code is covered by `licenses/Refract-MIT.txt`; Digitalis/Berberis keeps +its Apache-2.0 notices. + +| Upstream optimization | AXRB disposition | +| --- | --- | +| Digitalis lite load/store shortcut | Excluded after it crashed `Arm64MemoryDifferentialFuzz.TaggedBaseAddressIsIgnored`. Unconditional top-byte masking is retained for correctness. Other patches target pinned translator `9e7f4407`. | +| Direct translated-block chaining (`687fceb`) | Included in the combined Digitalis patch. | +| Host call/return prediction (`487d2f9`) | Included, without references to unpublished diagnostic counters. | +| Gear up small hot regions (`7e77b20`) | `berberis.gearup_min_insns=0` when Digitalis is active. | +| Syslib redirection | Included for Digitalis guest-library compatibility. | +| SIMD-address, heavy-readonly-TBI, pairwise-minmax candidate patches | Retained for review, not enabled: absent from the shipped Refract bundle's active patch list. | +| GPU ASTC-to-BC3 (`35c4d23`) | Default on capable devices. AXRB staging ownership and submit-time source revalidation retained. BC7 remains selectable. | +| ETC2/EAC-to-BC1/3/4/5 (`35c4d23`) | Uses AXRB's existing worker pool, staging reuse and upload lifetime tracking. | +| Asynchronous Vulkan GPU export and pixel readback | Fence waiting and delivery run on a persistent worker. One outstanding publication bounds latency and preserves AXRB's whole-frame ACK ownership. | +| Frame-paced pose publication and guest pacing (`0358bb7`) | Host wakes the pose sender on new tracking samples; Android waits for a fresh pose with bounded timer fallback. | +| Adaptive fresh-frame pickup budget | Tracks host frame misses, reduces waiting after misses and recovers conservatively. `AXRB_FRESH_FRAME_WAIT_US` still overrides it. | +| Pose-tag history / adaptive display-frame lag | Retains AXRB's latest-frame selection and existing optional history. Refract repurposes metadata AXRB uses for whole-frame multi-layer transport; copying that wire format would break AXRB composition. | +| Direct emulator image TCP | Uses `10.0.2.2:38491`, avoiding the adb reverse relay. Non-emulator transport remains available. | +| Growing panel swapchains (`5ca8c44`) | Grow in 256-pixel steps without shrinking while the swapchain is in use. | +| Recoverable `xrEndFrame` errors | Continue the host frame loop; stop it for session/instance loss. | +| Dynamic multi-layer atlas and export retirement | AXRB already has whole-frame batches, dynamic layer counts, mono/stereo transitions, ACK gating and export retirement; retained. | +| D3D11 fence-event waits (`fa5d64d`, `d1e1ab3`) | Already present in AXRB, including fallback coverage. | +| Descriptor layout caching (`006341a`) | Already present; retained cached entry points, scratch reuse and concurrent lifetime handling. | +| Cached Vulkan buffers and Unity skinning acceleration | Existing AXRB implementations retained. | +| Address-space graphics (ASG) | Already the AXRB default. | +| P-core scheduling | Emulator inherits the highest efficiency-class core mask on single-group hybrid systems. Parent affinity is restored. Homogeneous/multi-group systems retain Windows scheduling. | +| Corrected TSC and vCPU handling | Existing AXRB corrected-clock and hypervisor-APIC paths retained. Refract's busy idle polling is not made a default. | +| `NO_TTWU_QUEUE` and missing zram restoration | Applied to the emulator; missing kernel facilities are reported without preventing game launch. Active swap is never reset. | +| DirectSound audio latency (`ee07a0c`) | 20 ms latency and 40 ms output buffer by default, configurable. | +| Unity texture mip reduction | Not forced: this reduces game texture resolution rather than accelerating equivalent rendering. GPU texture transcoding provides the performance path without changing mip selection. | +| Refract launcher, Store removal, IDs, account/Meta behavior, HUD redesign | Excluded from this performance-only port. AXRB features remain in place. | + +## Controls + +The launcher's **Texture conversion** setting defaults to **Performance (GPU / BC3)**; +**Higher quality (BC7)** applies on the next game launch. +`scripts/run/run_windows_game.ps1 -TextureMode gpu` is the command-line default. Use +`-TextureMode bc7` for higher texture quality. GPU mode uses the compute path +for primary, one-time command buffers on compute-capable queues, and CPU BC3 +for other uploads to those images. A device that cannot initialize the compute +pipeline retains BC7. ETC2/EAC conversion is available in both modes. + +For direct runtime testing, set `debug.axrb.astc_gpu` to `1` (GPU/BC3) or `0` +(BC7) before starting the process. `debug.axrb.astc_to_bc7=0` disables compressed +texture substitution entirely. `debug.axrb.async_export=0` and +`debug.axrb.frame_sync=0` independently disable asynchronous delivery and pose pacing. +`-AllHostCores` on the emulator script disables P-core selection. +`debug.axrb.image_transport=adb` retains the previous adb-reverse image connection; +the default is direct emulator TCP. A 64-byte echo probe on this machine measured +roughly 0.59–0.62 ms median direct RTT versus 0.59–0.71 ms via adb reverse across +two runs each; this does not establish a universal throughput or game FPS gain. + +Digitalis can be selected with `-Translator digitalis`, restored with +`-Translator google`, or installed automatically when a verified bundle is +available. `AXRB_DIGITALIS_BUNDLE` can point to the bundle on another drive. +See [the translator build instructions](../tools/translator/README.md). + +## Verification + +The port has Windows native tests, Android descriptor-layer tests, real Vulkan +pixel readback and deferred GPU-batch tests, ETC2/EAC reference decoding checks, +and a desktop GPU ASTC reference comparison. `tests/android/texture_gpu_smoke.cpp` +checks real GPU conversion, source writes after recording, and repeated resource +retirement. These checks establish correctness; they are not game FPS benchmarks. + +Completed checks on the development machine: + +- Windows Release build and 25 native tests passed. +- All 20 launcher runtime-launch tests passed, including default GPU/BC3 and + explicit BC7 argument forwarding. The production launcher UI build passed. +- Performance-policy, translator-bundle integrity and distribution tests passed + (4, 4 and 7 tests respectively). +- Both Android runtime APKs (`x86_64` and `arm64-v8a`) and the Vulkan layer built. +- Real Android Vulkan readback, deferred publication, six GPU-batch frames, + descriptor handling and GPU texture lifetime/source-rewrite checks passed. +- Both texture modes passed through the actual loaded Android Vulkan layer. + On the solid-block gradient fixture, GPU/BC3 produced 51.37 dB PSNR with a + maximum channel error of 2; BC7 reproduced the reference exactly. This fixture + verifies the path and is not a general image-quality or speed benchmark. +- ETC2/EAC reference checks and the desktop GPU ASTC reference comparison passed. + +The patched Digitalis ARM64 host suite passed 3,736 tests; 2 hardware-dependent +tests were skipped and 3 upstream tests remain disabled. Both lite and heavy +tagged-pointer differential fuzz tests passed after retaining safe TBI masking. +The complete Digitalis distribution built successfully. All 74 canonical +artifacts passed presence, ELF architecture, native-bridge export and checksum +verification. The AXRB archive includes the pinned source manifest, combined +patch and license notices. The local bundle is on `D:\AXRB\digitalis-prebuilts`, +exposed to this checkout through `out/digitalis/prebuilts`; the archive is +`D:\AXRB\digitalis-prebuilts-axrb.tar.gz`. diff --git a/host/src/adaptive_pickup_budget.h b/host/src/adaptive_pickup_budget.h new file mode 100644 index 0000000..1ef2d12 --- /dev/null +++ b/host/src/adaptive_pickup_budget.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +namespace axrb::host::detail { +// Refract's adaptive pickup budget: half a frame initially, back off after +// compositor misses and recover gradually after ten seconds without misses. +class AdaptivePickupBudget { +public: + void observe(int64_t displayTime, int64_t period, uint64_t now) { + if (period < 4'000'000 || period > 25'000'000) { lastDisplay_ = 0; return; } + if (period != period_) { + period_ = period; budget_ = period / 2; lastDisplay_ = 0; + misses_.clear(); changed_ = now; + } + if (lastDisplay_ && displayTime > lastDisplay_) { + const int64_t missed = (displayTime - lastDisplay_ + period / 2) / period - 1; + for (int64_t i = 0; i < (std::min)(missed, int64_t{8}); ++i) misses_.push_back(now); + } + lastDisplay_ = displayTime; + while (!misses_.empty() && now - misses_.front() > 10'000'000'000ULL) misses_.pop_front(); + const auto recent = std::count_if(misses_.begin(), misses_.end(), [now](auto t) { return now - t <= 2'000'000'000ULL; }); + if (recent >= 2 && now - changed_ >= 1'000'000'000ULL) { + budget_ = (std::max)(period / 4, budget_ - 500'000); changed_ = now; + } else if (misses_.empty() && now - changed_ >= 10'000'000'000ULL) { + budget_ = (std::min)(period * 65 / 100, budget_ + 500'000); changed_ = now; + } + } + uint32_t microseconds() const { return static_cast((std::min)(int64_t{8000}, budget_ / 1000)); } +private: + int64_t lastDisplay_ = 0, period_ = 0, budget_ = 0; + uint64_t changed_ = 0; + std::deque misses_; +}; +} diff --git a/host/src/openxr_host.cpp b/host/src/openxr_host.cpp index 5ad01be..0961a7d 100644 --- a/host/src/openxr_host.cpp +++ b/host/src/openxr_host.cpp @@ -283,9 +283,10 @@ int OpenXrHost::run(int argc, char** argv) std::thread poseThread([poseSource, imageFrame, port, frames] { axrb::protocol::TcpPoseServer poseServer; - poseServer.serve_with_producer(port, frames, [&](uint64_t sequence) { - return poseSource->latest_frame(sequence); - }); + uint64_t generation = 0; + poseServer.serve_with_producer(port, frames, [&](uint64_t) { + return poseSource->wait_for_pose(generation); + }, true); }); poseThread.detach(); @@ -295,7 +296,7 @@ int OpenXrHost::run(int argc, char** argv) // OpenXR paces an active frame loop. An extra Windows sleep can // consume a timer tick and lower the compositor submission rate. if (!poseSource->drives_frame_loop()) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + std::this_thread::sleep_for(std::chrono::milliseconds(11)); } } return 0; diff --git a/host/src/openxr_session.cpp b/host/src/openxr_session.cpp index 9ef7e5a..c1727fb 100644 --- a/host/src/openxr_session.cpp +++ b/host/src/openxr_session.cpp @@ -147,9 +147,13 @@ bool OpenXrSession::initialize(const std::string& gameName) uint32_t parsed = 0; const auto end = value + std::strlen(value); const auto result = std::from_chars(value, end, parsed); - if (result.ec == std::errc{} && result.ptr == end && parsed <= 8000) freshFrameWaitUs_ = parsed; + if (result.ec == std::errc{} && result.ptr == end && parsed <= 8000) { + freshFrameWaitUs_ = parsed; fixedFreshFrameWait_ = true; + } } - std::fprintf(stderr, "AXRB timing experiment: max_fresh_wait_us=%u clock_extension=%s\n", freshFrameWaitUs_, performanceCounterTimeEnabled_ ? "available" : "unavailable"); + std::fprintf(stderr, "AXRB frame pickup: mode=%s max_fresh_wait_us=%u clock_extension=%s\n", + fixedFreshFrameWait_ ? "fixed" : "adaptive", fixedFreshFrameWait_ ? freshFrameWaitUs_ : 8000, + performanceCounterTimeEnabled_ ? "available" : "unavailable"); #endif std::fprintf(stderr, "AXRB compositor: equirect2=%s\n", equirectEnabled_ ? "enabled" : "unavailable"); if (handTrackingEnabled_) { diff --git a/host/src/openxr_session.h b/host/src/openxr_session.h index a8f3460..c9315c0 100644 --- a/host/src/openxr_session.h +++ b/host/src/openxr_session.h @@ -25,6 +25,7 @@ #include #include #include +#include "adaptive_pickup_budget.h" #include #include #include @@ -360,6 +361,7 @@ class OpenXrSession { axrb::protocol::PoseFrame make_frame(uint64_t sequence); axrb::protocol::PoseFrame latest_frame(uint64_t sequence); + axrb::protocol::PoseFrame wait_for_pose(uint64_t& generation); // Receive on the image thread, independently of the OpenXR compositor clock. // The same lock protects cross-device cache ownership and its render poses. @@ -479,6 +481,8 @@ class OpenXrSession { bool reportedSyncFailure_ = false; axrb::protocol::PoseFrame latest_{}; std::mutex frameMutex_; + std::condition_variable poseUpdated_; + uint64_t poseGeneration_ = 0; PFN_xrDestroyInstance destroyInstance_ = nullptr; PFN_xrGetSystem getSystem_ = nullptr; @@ -583,6 +587,8 @@ class OpenXrSession { PFN_xrConvertWin32PerformanceCounterToTimeKHR convertPerformanceCounterTime_ = nullptr; XrTime windows_xr_time(); uint32_t freshFrameWaitUs_ = 0; + bool fixedFreshFrameWait_ = false; + AdaptivePickupBudget pickupBudget_; std::array overflowViews_{}; bool should_precompose(const GpuFrameBatch& frame) const; bool compose_overflow(ID3D11Texture2D*, const HostImageSnapshot&, const XrPosef* worldFromView); diff --git a/host/src/panel_swapchain.cpp b/host/src/panel_swapchain.cpp index b4d8856..85fbdab 100644 --- a/host/src/panel_swapchain.cpp +++ b/host/src/panel_swapchain.cpp @@ -14,16 +14,18 @@ bool OpenXrSession::acquire_panel_swapchain(const HostImageSnapshot &frame) { height = (std::max)(height, part.header.height); slices += part.projection.view_count == 2 ? 2u : 1u; } - if (!panelSwapchain_ || width > panelWidth_ || height > panelHeight_ || slices != panelLayers_) { + if (!panelSwapchain_ || width > panelWidth_ || height > panelHeight_ || slices > panelLayers_) { if (panelSwapchain_) { if (destroySwapchain_(panelSwapchain_) != XR_SUCCESS) return false; panelSwapchain_ = XR_NULL_HANDLE; panelImages_.clear(); } - panelWidth_ = width; - panelHeight_ = height; - panelLayers_ = slices; + // Refract: reserve in 256-pixel steps and never shrink during a session. + // Animated panels otherwise repeatedly stall the compositor on allocation. + panelWidth_ = (std::max)(panelWidth_, (width + 255u) & ~255u); + panelHeight_ = (std::max)(panelHeight_, (height + 255u) & ~255u); + panelLayers_ = (std::max)(panelLayers_, slices); XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; info.format = projectionFormat_; info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT | diff --git a/host/src/presentation.cpp b/host/src/presentation.cpp index da10c3a..557913e 100644 --- a/host/src/presentation.cpp +++ b/host/src/presentation.cpp @@ -306,7 +306,7 @@ bool OpenXrSession::update_projection_layers(XrTime displayTime, XrDuration disp if (result != XR_SUCCESS) return false; // Never hold the receive/publication lock while waiting for the receiver. - // This opt-in experiment budgets against both predicted display lead and + // Budget against both predicted display lead and // the current frame period, reserving time for upload/submission. These // are best-effort waits, not a hard deadline guarantee on Windows. // Missing time conversion, invalid prediction or late frames skip the wait. @@ -315,7 +315,8 @@ bool OpenXrSession::update_projection_layers(XrTime displayTime, XrDuration disp const int64_t lead = displayTime - hostNow; static axrb::protocol::PerfStats leadStats("host-display-lead"); leadStats.record(static_cast(lead) / 1000000.0); - const auto budget = fresh_wait_budget_ns(freshFrameWaitUs_, lead, displayPeriod); + const auto requestedUs = fixedFreshFrameWait_ ? freshFrameWaitUs_ : pickupBudget_.microseconds(); + const auto budget = fresh_wait_budget_ns(requestedUs, lead, displayPeriod); if (budget > 0 && imageFrame_ && submittedGameFrames_.initialized) { if (imageFrame_->needs_newer(submittedGameFrames_.lastSequence)) { static axrb::protocol::PerfStats freshWaitStats("host-fresh-image-wait"); diff --git a/host/src/tracking.cpp b/host/src/tracking.cpp index ebc7f6d..e0cc93c 100644 --- a/host/src/tracking.cpp +++ b/host/src/tracking.cpp @@ -235,6 +235,9 @@ axrb::protocol::PoseFrame OpenXrSession::make_frame(uint64_t sequence) publish_pose(frame); if (beganFrame) { +#if defined(_WIN32) + pickupBudget_.observe(frameDisplayTime, frameDisplayPeriod, monotonic_time_ns()); +#endif XrCompositionLayerQuad fpsHud{XR_TYPE_COMPOSITION_LAYER_QUAD}; #if defined(_WIN32) auto& layers = nativeSubmission_.layers; @@ -262,7 +265,10 @@ axrb::protocol::PoseFrame OpenXrSession::make_frame(uint64_t sequence) } if (result != XR_SUCCESS) { std::fprintf(stderr, "AXRB OpenXR: xrEndFrame failed: %s (%d)\n", xr_result_name(result), result); - useFrameLoop_ = false; + // A rejected layer is recoverable on the next frame. Disabling + // pacing here leaves the headset frozen after one bad submission. + if (result == XR_ERROR_SESSION_LOST || result == XR_ERROR_INSTANCE_LOST) + useFrameLoop_ = false; } #if defined(_WIN32) #if defined(AXRB_ENABLE_PERFORMANCE_OVERLAY) @@ -307,8 +313,24 @@ axrb::protocol::PoseFrame OpenXrSession::latest_frame(uint64_t sequence) void OpenXrSession::publish_pose(const axrb::protocol::PoseFrame& frame) { - std::lock_guard lock(frameMutex_); - latest_ = frame; + { + std::lock_guard lock(frameMutex_); + latest_ = frame; + ++poseGeneration_; + } + poseUpdated_.notify_all(); +} + +axrb::protocol::PoseFrame OpenXrSession::wait_for_pose(uint64_t& generation) +{ + std::unique_lock lock(frameMutex_); + // A bounded heartbeat keeps reconnects/session transitions responsive. + // Preserve the capture sequence and timestamp rather than relabeling stale poses. + poseUpdated_.wait_for(lock, std::chrono::milliseconds(100), [&] { + return poseGeneration_ != generation; + }); + generation = poseGeneration_; + return latest_; } bool OpenXrSession::create_reference_space(XrReferenceSpaceType type, XrSpace* space) @@ -643,6 +665,9 @@ void OpenXrSession::locate_controller_spaces(axrb::protocol::PoseFrame& frame, X diagnostic.aimInvalid += (frame.aim_flags[i] & 3) != 3; diagnostic.gripInvalid += (frame.grip_flags[i] & 3) != 3; if (diagnostic.samples == 1 || diagnostic.samples % kPoseLogInterval == 0) { + const auto& aim = frame.aim[i]; + std::fprintf(stderr, "AXRB Input: pose seq=%llu hand=%zu aim=(%.4f %.4f %.4f) q=(%.4f %.4f %.4f %.4f)\n", + (unsigned long long)frame.sequence, i, aim.x, aim.y, aim.z, aim.qx, aim.qy, aim.qz, aim.qw); std::fprintf(stderr, "AXRB Input: hand=%zu samples=%llu inactive(aim/grip)=%llu/%llu invalidPose(aim/grip)=%llu/%llu " "stateResult=%d/%d locateResult=%d/%d active=%u/%u poseFlags=%llx/%llx velocityFlags=%u/%u\n", diff --git a/launcher/THIRD_PARTY_NOTICES.md b/launcher/THIRD_PARTY_NOTICES.md index 296743b..c6a7eaa 100644 --- a/launcher/THIRD_PARTY_NOTICES.md +++ b/launcher/THIRD_PARTY_NOTICES.md @@ -122,6 +122,10 @@ they are not embedded in the installer. Their archives retain their notices. The launcher uses jose 6.2.12 (MIT, copyright 2018 Filip Skokan) for OpenID Connect token validation. Its license is included in licenses/jose.txt. https://github.com/panva/jose +Game ZIP export uses yazl 3.3.1 (MIT, copyright 2014 Josh Wolfe). +Its license and dependency licenses are included under licenses/npm. +https://github.com/thejoshwolfe/yazl + The AI chat interface uses @assistant-ui/react 0.15.22 and @assistant-ui/react-markdown 0.14.17 (MIT, copyright 2025 AgentbaseAI Inc.), with remark-gfm for GitHub-flavored Markdown. Upstream: diff --git a/launcher/core/debloat.mjs b/launcher/core/debloat.mjs new file mode 100644 index 0000000..b9871f7 --- /dev/null +++ b/launcher/core/debloat.mjs @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Deliberately exact package names: never derive removal targets from a prefix, +// process name or renderer input. Framework/APEX, WebView, input, permissions, +// networking, media, launcher and AXRB/game packages remain installed. +export const OPTIONAL_PACKAGES = Object.freeze([ + 'com.google.android.gms', 'com.google.android.gsf', 'com.android.vending', + 'com.google.android.gms.supervision', 'com.google.android.googlequicksearchbox', + 'com.google.android.apps.wellbeing', 'com.google.android.as', 'com.google.android.as.oss', + 'com.google.android.settings.intelligence', 'com.google.android.apps.safetyhub', + 'com.google.android.calendar', 'com.google.android.contacts', 'com.google.android.gm', + 'com.google.android.deskclock', 'com.google.android.apps.messaging', 'com.google.android.dialer', + 'com.google.android.apps.maps', 'com.google.android.apps.photos', 'com.google.android.apps.docs', + 'com.google.android.youtube', 'com.google.android.apps.youtube.music', 'com.android.chrome', + 'com.google.android.apps.wallpaper', 'com.google.android.apps.customization.pixel', + 'com.android.dreams.basic', 'com.android.wallpaper.livepicker', 'com.android.wallpaperbackup', + 'com.android.camera2', 'com.google.android.markup', 'com.google.android.avatarpicker', + 'com.android.printspooler', 'com.android.bips', 'com.google.android.printservice.recommendation', + 'com.google.android.projection.gearhead', 'com.google.android.feedback', + 'com.google.android.apps.restore', 'com.google.android.partnersetup', + 'com.google.android.onetimeinitializer', 'com.android.egg', 'com.android.bookmarkprovider', + 'com.android.providers.partnerbookmarks', +]); +const allowed = new Set(OPTIONAL_PACKAGES); +export function packages(output) { + return new Set(String(output).split(/\r?\n/).filter(s => s.startsWith('package:')).map(s => s.slice(8).trim())); +} +export class Debloat { + constructor(runtime, directory, progress = () => {}) { + this.runtime = runtime; this.file = path.join(directory, 'axrb-debloat.json'); this.progress = progress; + } + async read() { + try { + const value = JSON.parse(await fs.readFile(this.file, 'utf8')); + if (value.version !== 1 || !Array.isArray(value.packages) || value.packages.some(p => !allowed.has(p))) throw new Error('Invalid debloat recovery record.'); + return value; + } catch (error) { if (error.code === 'ENOENT') return { version: 1, packages: [] }; throw error; } + } + async save(value) { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.writeFile(this.file + '.tmp', JSON.stringify(value, null, 2)); + await fs.rename(this.file + '.tmp', this.file); + } + async check() { + if (this.runtime.child) throw new Error('Close the game before changing Android apps.'); + const hardware = (await this.runtime.adb(['shell', 'getprop', 'ro.hardware'])).trim(); + const api = (await this.runtime.adb(['shell', 'getprop', 'ro.build.version.sdk'])).trim(); + if (hardware !== 'ranchu' || api !== '36') throw new Error('Debloat is supported only on the Android 16 emulator.'); + if ((await this.runtime.adb(['shell', 'getprop', 'sys.boot_completed'])).trim() !== '1') throw new Error('Wait for Android to finish starting.'); + const thirdParty = packages(await this.runtime.adb(['shell', 'pm', 'list', 'packages', '-3', '--user', '0'])); + const processes = await this.runtime.adb(['shell', 'ps', '-A', '-o', 'NAME']); + if (processes.split(/\r?\n/).some(n => thirdParty.has(n.trim().split(':')[0]) && !n.trim().startsWith('com.axrb.'))) throw new Error('Close running Android games before changing Android apps.'); + } + async preview() { + await this.check(); + const installed = packages(await this.runtime.adb(['shell', 'pm', 'list', 'packages', '-s', '--user', '0'])); + return { removable: OPTIONAL_PACKAGES.filter(p => installed.has(p)), restore: (await this.read()).packages }; + } + async apply(restore = false) { + const preview = await this.preview(), journal = await this.read(); + const targets = restore ? [...journal.packages] : preview.removable; + const failures = []; let completed = 0; + for (const name of targets) { + this.progress({ completed, total: targets.length, package: name, restore }); + // Write intent before mutation, so interruption cannot lose restoration. + if (!restore && !journal.packages.includes(name)) { journal.packages.push(name); await this.save(journal); } + try { + const output = await this.runtime.adb(restore + ? ['shell', 'cmd', 'package', 'install-existing', '--user', '0', name] + : ['shell', 'pm', 'uninstall', '-k', '--user', '0', name], { timeout: 60000 }); + if (/Failure|Error:/i.test(output) || (!restore && !/Success/.test(output))) throw new Error(output.trim()); + const installed = packages(await this.runtime.adb(['shell', 'pm', 'list', 'packages', '--user', '0', name])); + if (installed.has(name) !== restore) throw new Error('Android did not confirm the package change.'); + if (restore) { journal.packages = journal.packages.filter(p => p !== name); await this.save(journal); } + } catch (error) { failures.push({ package: name, error: error.message }); } + completed++; + } + const result = { completed, total: targets.length, failures, restore }; + this.progress(result); return result; + } +} diff --git a/launcher/core/game-export.mjs b/launcher/core/game-export.mjs new file mode 100644 index 0000000..4dc17df --- /dev/null +++ b/launcher/core/game-export.mjs @@ -0,0 +1,98 @@ +import fs from 'node:fs/promises'; +import { createReadStream, createWriteStream } from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { pipeline } from 'node:stream/promises'; +import { finished } from 'node:stream/promises'; +import yazl from 'yazl'; +import { archivePath } from './archive.mjs'; +import { assetDestination } from './game-files.mjs'; + +export const COLLECTION_MANIFEST = 'axrb-games.json'; + +export function gameExportFilename(title) { + // Display titles can contain punctuation that Windows filenames cannot. + // This is a suggested name, not an untrusted asset path to validate. + let name = Array.from(String(title ?? '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '-').trim()).slice(0, 100).join('').replace(/[. ]+$/, ''); + if (!name) name = 'AXRB-game'; + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) name = `Game-${name}`; + return `${name}.zip`; +} + +export async function exportGamesZip(games, output, inspect, { signal, update = () => {}, progress = () => {} } = {}) { + if (!Array.isArray(games) || !games.length || games.length > 1000) throw new Error('Select between 1 and 1000 games.'); + const entries = [], packages = new Set(), folders = []; + for (const [index, game] of games.entries()) { + signal?.throwIfAborted(); update(`Checking ${game.name}`); + if (!game.apk) throw new Error(`${game.name}: download or import the APK before exporting.`); + const base = await inspect(game.apk, { allowSplit: true }); + if (base.split || base.package !== game.package || packages.has(base.package)) throw new Error('Select one complete build per game.'); + assetDestination(base.package, `Android/data/${base.package}/check`); + packages.add(base.package); + const folder = `games/${String(index + 1).padStart(4, '0')}`; + folders.push(folder); + const prefix = games.length > 1 ? `${folder}/` : ''; + const files = [{ path: game.apk, name: 'base.apk' }]; + const splits = new Set(); + for (const file of game.files || []) { + if (file.kind === 'apk') continue; // Export the currently selected APK, including patches. + if (file.kind === 'split') { + const split = await inspect(file.path, { allowSplit: true }); + if (!split.split || split.package !== base.package || split.versionCode !== base.versionCode || splits.has(split.split)) throw new Error(`${game.name}: split APKs do not match the selected APK.`); + splits.add(split.split); + files.push({ path: file.path, name: `split_${splits.size}.apk` }); + } else { + const name = file.destination || `Android/obb/${base.package}/${path.basename(file.name)}`; + assetDestination(base.package, name); + files.push({ path: file.path, name }); + } + } + const names = new Set(); + for (const file of files) { + signal?.throwIfAborted(); + const name = prefix + file.name; + archivePath(path.dirname(output), name); + if (names.has(name.toLowerCase())) throw new Error('Multiple files target the same archive entry.'); + names.add(name.toLowerCase()); + if (path.resolve(file.path).toLowerCase() === path.resolve(output).toLowerCase()) throw new Error('Export cannot replace a game file.'); + const stat = await fs.lstat(file.path); + if (!stat.isFile()) throw new Error(`Missing or linked game file: ${file.name}`); + entries.push({ ...file, name, size: stat.size, mtime: stat.mtime }); + } + } + const total = entries.reduce((sum, entry) => sum + entry.size, 0); + if (entries.length > 199999 || !Number.isSafeInteger(total) || total > 256 * 1024 ** 3 - 1024 ** 2) throw new Error('Export exceeds the ZIP import limit.'); + const space = await fs.statfs(path.dirname(output)); + if (Number(space.bavail) * Number(space.bsize) < total + entries.length * 1024 + 1024 ** 2) throw new Error('Not enough free space for the game ZIP.'); + const temporary = `${output}.${randomUUID()}.partial`, zip = new yazl.ZipFile(); + const streams = new Set(); + let completed = 0; + zip.on('error', error => zip.outputStream.destroy(error)); + const writing = pipeline(zip.outputStream, createWriteStream(temporary, { flags: 'wx' }), { signal }); + // Attach rejection handling immediately while entries are being queued. + writing.catch(() => {}); + try { + if (games.length > 1) zip.addBuffer(Buffer.from(JSON.stringify({ format: 'axrb-games', version: 1, games: folders })), COLLECTION_MANIFEST); + for (const entry of entries) zip.addReadStreamLazy(entry.name, { size: entry.size, mtime: entry.mtime, compress: false }, callback => { + try { + signal?.throwIfAborted(); update(`Exporting ${entry.name}`); + const stream = createReadStream(entry.path, { signal }); + streams.add(stream); + stream.on('close', () => streams.delete(stream)); + stream.on('data', chunk => { completed += chunk.length; progress(completed, total); }); + callback(null, stream); + } catch (error) { callback(error); } + }); + zip.end(); + await writing; + signal?.throwIfAborted(); + await fs.rename(temporary, output); + return { file: output, games: games.length, bytes: total }; + } finally { + zip.outputStream.destroy(); + const closing = [...streams].map(stream => { stream.destroy(); return finished(stream).catch(() => {}); }); + await Promise.all(closing); + await writing.catch(() => {}); + await fs.rm(temporary, { force: true }); + } +} diff --git a/launcher/core/game-files.mjs b/launcher/core/game-files.mjs index 2040645..6662237 100644 --- a/launcher/core/game-files.mjs +++ b/launcher/core/game-files.mjs @@ -139,6 +139,11 @@ export async function inspectGameFolder(directory, inspect, { signal, update = ( } export async function importGameZip(file, downloadDir, inspect, { signal, update = () => {}, progress = () => {}, reserve } = {}) { + const games = await importGameArchive(file, downloadDir, inspect, { signal, update, progress, reserve, collection: false }); + return games[0]; +} + +export async function importGameArchive(file, downloadDir, inspect, { signal, update = () => {}, progress = () => {}, reserve, collection = true } = {}) { const directory = path.join(downloadDir, 'imports', randomUUID()); try { update('Checking ZIP'); @@ -146,7 +151,24 @@ export async function importGameZip(file, downloadDir, inspect, { signal, update await checkSpace(directory, size, reserve === undefined ? {} : { reserve }); update('Extracting ZIP'); await extractZip(file, directory, { signal, limit: 256 * 1024 ** 3, progress }); - return await inspectGameFolder(directory, inspect, { signal, update }); + const manifestPath = path.join(directory, 'axrb-games.json'); + const manifestStat = await fs.stat(manifestPath).catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + if (!manifestStat) return [await inspectGameFolder(directory, inspect, { signal, update })]; + if (!collection || manifestStat.size > 1024 ** 2) throw new Error('Invalid game collection manifest.'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + if (manifest?.format !== 'axrb-games' || manifest.version !== 1 || !Array.isArray(manifest.games) || !manifest.games.length || manifest.games.length > 1000 || + manifest.games.some(folder => typeof folder !== 'string' || !/^games\/[0-9]{4}$/.test(folder)) || new Set(manifest.games).size !== manifest.games.length) throw new Error('Invalid game collection manifest.'); + for (const name of await walkFiles(directory)) { + if (name !== 'axrb-games.json' && !manifest.games.some(folder => name.startsWith(folder + '/'))) throw new Error('Collection contains files outside its game folders.'); + } + const games = [], packages = new Set(); + for (const folder of manifest.games) { + signal?.throwIfAborted(); + const game = await inspectGameFolder(path.join(directory, folder), inspect, { signal, update }); + if (packages.has(game.package)) throw new Error('Collection contains multiple builds of the same game.'); + packages.add(game.package); games.push(game); + } + return games; } catch (error) { await fs.rm(directory, { recursive: true, force: true }); throw error; } } diff --git a/launcher/core/runtime.mjs b/launcher/core/runtime.mjs index 01ee242..f399775 100644 --- a/launcher/core/runtime.mjs +++ b/launcher/core/runtime.mjs @@ -311,7 +311,7 @@ export class Runtime { if (sessionId !== undefined && !/^[a-f0-9]{8}$/.test(sessionId)) throw new Error('Invalid session identifier.'); this.fpsHudEvent = `Local\\AXRB.FpsHud.${randomUUID().replaceAll('-', '')}`; const args = bridge ? [] : powershellArgs(path.join(this.root, 'scripts/run/run_windows_game.ps1'), { Avd: this.settings.avd, Port: this.settings.port, - Sdk: this.settings.sdk, MemoryMB: this.settings.memoryMB, CpuCores: this.settings.cpuCores ?? 4, Package: game.package, Activity: game.activity, GameName: game.name, FpsHud: this.settings.fpsHud === true, FpsHudEventName: this.fpsHudEvent, + Sdk: this.settings.sdk, MemoryMB: this.settings.memoryMB, CpuCores: this.settings.cpuCores ?? 4, TextureMode: this.settings.textureMode ?? 'gpu', Package: game.package, Activity: game.activity, GameName: game.name, FpsHud: this.settings.fpsHud === true, FpsHudEventName: this.fpsHudEvent, // Recovery inside the game script must use the same CPU policy as ensure(). GuestClock: this.settings.guestClock || 'Auto', ...(this.settings.localApic ? { LocalApic: this.settings.localApic } : {}), diff --git a/launcher/core/state.mjs b/launcher/core/state.mjs index f1aac6c..f6f30da 100644 --- a/launcher/core/state.mjs +++ b/launcher/core/state.mjs @@ -6,7 +6,7 @@ export class State { await fs.mkdir(this.directory, { recursive: true }); try { this.data = { ...this.data, ...JSON.parse(await fs.readFile(path.join(this.directory, 'library.json'), 'utf8')) }; } catch (error) { if (error.code !== 'ENOENT') throw new Error('Launcher library could not be read. The original file has been preserved.'); } - this.data.jobs = this.data.jobs.map(j => ['queued', 'downloading', 'installing', 'patching', 'importing', 'uninstalling'].includes(j.status) ? { ...j, status: 'interrupted', error: 'Interrupted when the launcher closed. Retry to continue.' } : j); + this.data.jobs = this.data.jobs.map(j => ['queued', 'downloading', 'installing', 'patching', 'importing', 'exporting', 'uninstalling'].includes(j.status) ? { ...j, status: 'interrupted', error: 'Interrupted when the launcher closed. Retry to continue.' } : j); if (portableRoot) { const current = path.resolve(portableRoot), previous = this.data.portableRoot; if (previous !== current) { diff --git a/launcher/main.mjs b/launcher/main.mjs index d5e4498..dfc09ac 100644 --- a/launcher/main.mjs +++ b/launcher/main.mjs @@ -12,7 +12,8 @@ import { prepareBridge } from './core/quest-bridge.mjs'; import { Setup, identifyArchives, avdDirectory, parseStorageGB, planStorageChange, withStorageGB } from './core/setup.mjs'; import { loadLibraryArtwork } from './core/artwork.mjs'; import { Quest } from './core/quest.mjs'; -import { findOriginalApk, importGameZip } from './core/game-files.mjs'; +import { findOriginalApk, importGameArchive } from './core/game-files.mjs'; +import { exportGamesZip, gameExportFilename } from './core/game-export.mjs'; import { collectDiagnostics, describeSessionEnd, parseSessionRecord, redact, uploadDiagnostics } from './core/diagnostics.mjs'; import { LiveDiagnostics } from './core/live-diagnostics.mjs'; import { EmulatorWatchdog } from './core/watchdog.mjs'; @@ -29,6 +30,7 @@ import { DiagnosticHarness } from './core/ai-tools.mjs'; import electronUpdater from 'electron-updater'; import { LauncherUpdates } from './core/updates.mjs'; import { measureDirectoryBytes, parseAndroidDataUsage } from './core/storage-info.mjs'; +import { Debloat } from './core/debloat.mjs'; // Keep the packaged app in Electron GUI mode even when launched from a shell // that uses ELECTRON_RUN_AS_NODE for other tooling. @@ -309,19 +311,26 @@ async function importTransfer(kind, input) { const imported = kind === 'quest' ? await new Quest(runtime.settings).pullGame(input.serial, input.package, state.data.settings.downloadDir, inspect, options) : kind === 'apk' ? { ...await inspect(input.file), apk: (await carryPortableFiles(portable, state.data.settings.downloadDir, [input.file], controller.signal))[0], downloaded: true } - : await importGameZip(input.file, state.data.settings.downloadDir, inspect, options); + : await importGameArchive(input.file, state.data.settings.downloadDir, inspect, options); controller.signal.throwIfAborted(); - const existing = state.data.games.find(g => g.package === imported.package); - const game = state.put({ ...imported, id: existing?.id || imported.id, source: existing?.source || imported.source, + const imports = Array.isArray(imported) ? imported : [imported]; + // Validate the whole archive before persisting or installing any game. + const games = imports.map(imported => { + const existing = state.data.games.find(g => g.package === imported.package); + return state.put({ ...imported, id: existing?.id || imported.id, source: existing?.source || imported.source, importedFrom: kind, installed: existing?.installed || false }); - job.gameId = game.id; job.name = game.name; job.completed = job.total; + }); + job.gameId = games[0].id; job.name = games.length > 1 ? `${games.length} games` : games[0].name; job.completed = job.total; await persist(); if (kind === 'zip') { Object.assign(job, { status: 'installing', stage: 'Starting Android', completed: 0, total: 0, progressUnit: 'bytes' }); changed(); // A cancelled extraction never reaches installation. After this point the // job remains an install, so its APK/asset transaction is not interrupted. - await runtime.install(game, (stage, progress) => { Object.assign(job, { stage, completed: 0, total: 0, progressUnit: 'bytes' }, progress); changed(); }); - game.installed = true; + for (const [index, game] of games.entries()) { + await runtime.install(game, (stage, progress) => { Object.assign(job, { stage: `${index + 1}/${games.length} ${game.name}: ${stage}`, completed: 0, total: 0, progressUnit: 'bytes' }, progress); changed(); }); + game.installed = true; + await persist(); + } } job.status = 'complete'; job.stage = kind === 'zip' ? 'Installed' : 'Ready to install'; } catch (error) { job.status = controller.signal.aborted ? 'cancelled' : 'failed'; job.error = message(error); } @@ -337,7 +346,7 @@ metaSession = new MetaSession(state.directory, { portable: Boolean(portable), sa const pendingRuntime = await state.pendingRuntime(); const components = JSON.parse(await fs.readFile(path.join(directory, 'core/components.json'), 'utf8')); state.data.settings = { sdk: path.join(process.env.LOCALAPPDATA || '', 'Android/Sdk'), avd: 'axrb-games-api34', port: 5580, - memoryMB: 8192, cpuCores: 4, downloadDir: path.join(portable || app.getPath('downloads'), portable ? 'downloads' : 'AXRB'), ovrportCli: '', + memoryMB: 8192, cpuCores: 4, textureMode: 'gpu', downloadDir: path.join(portable || app.getPath('downloads'), portable ? 'downloads' : 'AXRB'), ovrportCli: '', precomposeProjectionLayers: false, guestClock: 'Auto', ...state.data.settings }; migrateClockPolicy(state.data.settings); @@ -436,7 +445,7 @@ handler('builds', id => store().builds(appId(id)).then(items => items.map(b => ( handler('download', (id, binaryId) => downloadGame(appId(id), binaryId)); handler('dlc', id => store().dlc(appId(id)).then(items => items.map(({ files, ...item }) => ({ ...item, fileCount: files.length, bytes: files.reduce((n, f) => n + f.size, 0) })))); handler('downloadDlc', (id, dlcId) => downloadGame(appId(id), null, appId(dlcId))); -handler('cancel', id => { if (['queued', 'downloading', 'importing'].includes(state.data.jobs.find(j => j.id === id)?.status)) controllers.get(id)?.abort(); }); +handler('cancel', id => { if (['queued', 'downloading', 'importing', 'exporting'].includes(state.data.jobs.find(j => j.id === id)?.status)) controllers.get(id)?.abort(); }); handler('retry', id => { const job = state.data.jobs.find(j => j.id === id); if (!job || !['failed', 'interrupted', 'cancelled'].includes(job.status)) throw new Error('This task cannot be retried.'); return downloadGame(job.gameId, job.binaryId, job.dlcId); }); handler('import', async () => { if (busy) throw new Error('Wait for the current task to finish.'); @@ -455,6 +464,33 @@ handler('importZip', async () => { const result = await dialog.showOpenDialog(window, { title: 'Install game ZIP', filters: [{ name: 'Game ZIP', extensions: ['zip'] }], properties: ['openFile'] }); if (!result.canceled) return importTransfer('zip', { file: result.filePaths[0] }); }); +handler('exportGames', async ids => { + if (busy || controllers.size) throw new Error('Wait for current transfers and installations to finish.'); + if (!Array.isArray(ids) || !ids.length || ids.length > 1000 || ids.some(id => typeof id !== 'string') || new Set(ids).size !== ids.length) throw new Error('Select between 1 and 1000 games.'); + const games = ids.map(getGame); + busy = true; changed(); + let job, controller; + try { + const result = await dialog.showSaveDialog(window, { title: `Export ${games.length} game${games.length === 1 ? '' : 's'}`, defaultPath: path.join(app.getPath('downloads'), games.length === 1 ? gameExportFilename(games[0].name) : 'AXRB-games.zip'), filters: [{ name: 'Game ZIP', extensions: ['zip'] }] }); + if (result.canceled || !result.filePath) { busy = false; changed(); return; } + job = { id: randomUUID(), kind: 'export', name: games.length === 1 ? games[0].name : `${games.length} games`, status: 'exporting', stage: 'Checking game files', completed: 0, total: 0 }; + controller = new AbortController(); controllers.set(job.id, controller); + state.data.jobs.unshift(job); await persist(); + void (async () => { + let last = 0; + const notify = () => { if (Date.now() - last > 150) { last = Date.now(); changed(); } }; + try { + await exportGamesZip(games, result.filePath, (file, options) => runtime.inspect(file, options), { + signal: controller.signal, update: stage => { job.stage = stage; notify(); }, + progress: (completed, total) => { Object.assign(job, { completed, total }); notify(); } + }); + job.status = 'complete'; job.stage = `Saved to ${result.filePath}`; + } catch (error) { job.status = controller.signal.aborted ? 'cancelled' : 'failed'; job.error = message(error); } + finally { controllers.delete(job.id); busy = false; await persist(); } + })().catch(error => console.error(message(error))); + return job.id; + } catch (error) { if (job) controllers.delete(job.id); busy = false; changed(); throw error; } +}); handler('importAssets', id => exclusive(async () => { const game = getGame(id); const result = await dialog.showOpenDialog(window, { title: 'Add expansion files / DLC assets', properties: ['openFile', 'multiSelections'] }); @@ -634,6 +670,26 @@ handler('stop', () => runtime.stop()); // around play, install and uninstall. Reuses the same exclusive() gate those // share, since a manual boot or shutdown is exactly as disruptive to them. handler('startAndroid', () => exclusive(() => runtime.ensure())); +handler('debloat', (restore = false) => exclusive(async () => { + if (typeof restore !== 'boolean') throw new Error('Invalid debloat operation.'); + if (setup && setup.status.phase !== 'ready') throw new Error('Complete runtime setup first.'); + if (runtime.child) throw new Error('Close the game before changing Android apps.'); + await runtime.ensure(); + const debloat = new Debloat(runtime, avdDirectory(runtime.settings), update => { + if (window && !window.isDestroyed()) window.webContents.send('axrb:debloat-progress', update); + }); + const preview = await debloat.preview(); + const names = restore ? preview.restore : preview.removable; + if (!names.length) return { completed: 0, total: 0, failures: [], restore }; + const answer = await dialog.showMessageBox(window, { + type: 'question', title: restore ? 'Restore Android apps' : 'Debloat Android', + message: `${restore ? 'Restore' : 'Remove'} ${names.length} optional Android apps?`, + detail: (restore ? '' : 'Removes apps for the emulator user, including Google Play services. Apps requiring Google services may stop working. App data is retained for restoration; the base system image will not shrink.\n\n') + names.join('\n'), + buttons: ['Cancel', restore ? 'Restore' : 'Remove'], defaultId: 0, cancelId: 0, + }); + if (answer.response !== 1) return null; + return debloat.apply(restore); +})); handler('stopAndroid', () => exclusive(async () => { if (runtime.child) throw new Error('Close the running game before stopping Android.'); if (!await runtime.online()) return; @@ -645,7 +701,7 @@ handler('fpsHud', async enabled => { await persist(); }); handler('settings', async values => { - const allowed = ['sdk', 'avd', 'port', 'memoryMB', 'cpuCores', 'downloadDir', 'ovrportCli', 'diagnosticsEndpoint', 'precomposeProjectionLayers']; + const allowed = ['sdk', 'avd', 'port', 'memoryMB', 'cpuCores', 'textureMode', 'downloadDir', 'ovrportCli', 'diagnosticsEndpoint', 'precomposeProjectionLayers']; if (!values || typeof values !== 'object') throw new Error('Invalid settings.'); if (busy || controllers.size || runtime.child) throw new Error('Finish current tasks before changing runtime settings.'); const settings = { ...state.data.settings }; @@ -658,6 +714,7 @@ handler('settings', async values => { // mismatch and refuse to launch. Never offer a number it will not honour. if (!Number.isInteger(settings.cpuCores) || settings.cpuCores < 2 || settings.cpuCores > 6) throw new Error('Choose between 2 and 6 vCPUs.'); if (typeof settings.precomposeProjectionLayers !== 'boolean') throw new Error('Choose whether to precompose projection layers.'); + if (!['gpu', 'bc7'].includes(settings.textureMode)) throw new Error('Choose a supported texture conversion mode.'); for (const key of ['sdk', 'downloadDir']) if (typeof settings[key] !== 'string' || !path.isAbsolute(settings[key])) throw new Error('Select absolute Windows paths.'); portableOutput(portable, settings.downloadDir); if (settings.ovrportCli) portableOutput(portable, settings.ovrportCli); @@ -904,6 +961,8 @@ if (smoke) { await aiChatSmoke(window, aiDiagnostics, chatgpt, path.join(root, 'out/launcher/smoke')); const { libraryActionsSmoke } = await import('./tests/library-actions-smoke.mjs'); await libraryActionsSmoke(window, { state, runtime, dialog, persist, publicState, ovrport }); + const { gameExportSmoke } = await import('./tests/game-export-smoke.mjs'); + await gameExportSmoke(window, { state, runtime, dialog, persist }); app.quit(); } else { if (!setup || setup.status.phase === 'ready') syncInstalled().catch(() => {}); refreshArtwork().catch(() => {}); } app.on('window-all-closed', () => { for (const controller of controllers.values()) controller.abort(); setup?.cancel(); if (setup?.task) setup.task.finally(() => app.quit()); else app.quit(); }); diff --git a/launcher/package-lock.json b/launcher/package-lock.json index ddb9faf..cf2b8da 100644 --- a/launcher/package-lock.json +++ b/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "axrb-launcher", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "axrb-launcher", - "version": "1.0.3", + "version": "1.0.4", "license": "GPL-3.0-or-later", "dependencies": { "@assistant-ui/react": "0.15.22", @@ -21,7 +21,8 @@ "react-dom": "^19.3.0", "remark-gfm": "4.0.1", "tailwind-merge": "^3.7.0", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "yazl": "3.3.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -3463,6 +3464,15 @@ "node": "20 || >=22" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8738,6 +8748,15 @@ "node": ">=12" } }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/launcher/package.json b/launcher/package.json index 8ec3455..d2b5a7b 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "axrb-launcher", - "version": "1.0.3", + "version": "1.0.4", "private": true, "description": "Quest library and native Windows AXRB launcher", "license": "GPL-3.0-or-later", @@ -37,7 +37,8 @@ "react-dom": "^19.3.0", "remark-gfm": "4.0.1", "tailwind-merge": "^3.7.0", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "yazl": "3.3.1" }, "productName": "AXRB", "author": "AXRB contributors", diff --git a/launcher/preload.cjs b/launcher/preload.cjs index 8451254..6e5fa61 100644 --- a/launcher/preload.cjs +++ b/launcher/preload.cjs @@ -1,7 +1,9 @@ const { contextBridge, ipcRenderer } = require('electron'); // Account usage is read in the main process; OAuth credentials never cross IPC. -const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'removeImported', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storage', 'permissions', 'setPermission']; +const channels = ['aiStatus', 'aiAccount', 'aiModels', 'aiCollect', 'aiAnalyze', 'aiCancel', 'aiReset', 'aiReport', 'questDevices', 'questGames', 'questImport', 'importZip', 'exportGames', 'setupCheck', 'setupStart', 'setupCancel', 'chooseSetupArchives', 'setupLicense', 'state', 'sync', 'login', 'logout', 'search', 'add', 'lookup', 'builds', 'download', 'dlc', 'downloadDlc', 'cancel', 'retry', 'import', 'importAssets', 'install', 'uninstall', 'removeImported', 'patch', 'play', 'stop', 'startAndroid', 'stopAndroid', 'fpsHud', 'settings', 'chooseFolder', 'chooseCli', 'openFolder', 'openStore', 'diagnostics', 'diagnosticsRead', 'performanceScan', 'copyText', 'storageInfo', 'storage', 'permissions', 'setPermission']; contextBridge.exposeInMainWorld('axrb', Object.fromEntries([ + ['debloat', restore => ipcRenderer.invoke('axrb:debloat', restore)], + ['onDebloatProgress', callback => { const listener = (_event, value) => callback(value); ipcRenderer.on('axrb:debloat-progress', listener); return () => ipcRenderer.removeListener('axrb:debloat-progress', listener); }], ['onAiUpdate', callback => { const listener = (_event, value) => callback(value); ipcRenderer.on('axrb:ai-update', listener); return () => ipcRenderer.removeListener('axrb:ai-update', listener); }], ...channels.map(name => [name, (...args) => ipcRenderer.invoke(`axrb:${name}`, ...args)]), ['onChange', callback => { const listener = (_event, state) => callback(state); ipcRenderer.on('axrb:changed', listener); return () => ipcRenderer.removeListener('axrb:changed', listener); }], diff --git a/launcher/prepare-release.mjs b/launcher/prepare-release.mjs index 5c714b1..ec015e4 100644 --- a/launcher/prepare-release.mjs +++ b/launcher/prepare-release.mjs @@ -40,11 +40,24 @@ const files = [ 'scripts/emulator/gpu_validation.ps1', 'scripts/emulator/windows_gpu.py', 'scripts/emulator/android_runtime_policy.py', 'scripts/emulator/audio_policy.py', 'scripts/emulator/distribution.py', 'scripts/emulator/storage_policy.py', 'scripts/emulator/unreal_memory_policy.py', 'scripts/emulator/qemu_multicore.py', + 'scripts/emulator/performance_policy.py', 'scripts/emulator/translator.py', 'scripts/emulator/p_core_affinity.ps1', 'scripts/run/run_windows_game.ps1', 'scripts/run/fps_hud.ps1', 'scripts/run/stop_game.ps1', 'scripts/run/performance_scan.ps1', 'modules/performance_overlay/collect_guest_cpu.py', 'modules/performance_overlay/resource_history.py', 'scripts/run/open_windows_features.ps1', 'scripts/run/android_app_label.py', 'scripts/run/steamvr_app_identity.py'].map(p => [p, p]), ]; +// Include a locally built Digitalis bundle, with its notices and provenance. +// A source checkout without one still supports the existing Google translator. +async function addDigitalis(directory, relative = 'out/digitalis/prebuilts') { + for (const item of await fs.readdir(directory, { withFileTypes: true })) { + const next = `${relative}/${item.name}`; + if (item.isDirectory()) await addDigitalis(path.join(directory, item.name), next); + else files.push([next, next]); + } +} +const digitalis = path.join(root, 'out/digitalis/prebuilts'); +try { if (process.env.AXRB_SKIP_DIGITALIS !== '1') { await fs.access(digitalis); await addDigitalis(digitalis); } } +catch (error) { if (error.code !== 'ENOENT') throw error; } const sha256 = {}; for (const [from, to] of files) { const bytes = await fs.readFile(path.join(root, from)); diff --git a/launcher/tests/debloat.test.mjs b/launcher/tests/debloat.test.mjs new file mode 100644 index 0000000..de0bfd1 --- /dev/null +++ b/launcher/tests/debloat.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Debloat, OPTIONAL_PACKAGES } from '../core/debloat.mjs'; + +async function fixture(t) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'axrb-debloat-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const installed = new Set(['com.google.android.gms', 'com.google.android.gm', 'com.android.systemui', 'com.axrb.openxrruntime', 'com.test.game']); + const calls = []; let hardware = 'ranchu', running = '', failure = ''; + const runtime = { adb: async args => { + calls.push(args); + if (args[1] === 'getprop') return ({'ro.hardware':hardware, 'ro.build.version.sdk':'36', 'sys.boot_completed':'1'})[args[2]]; + if (args[1] === 'ps') return running; + if (args.includes('list')) return [...(args.includes('-3') ? ['com.test.game'] : installed)].map(p => 'package:' + p).join('\n'); + const p = args.at(-1); + if (p === failure) return 'Failure [DELETE_FAILED_INTERNAL_ERROR]'; + if (args.includes('uninstall')) { installed.delete(p); return 'Success'; } + if (args.includes('install-existing')) { installed.add(p); return 'Package installed for user: 0'; } + throw new Error('Unexpected ADB request'); + }}; + return { debloat:new Debloat(runtime,dir), installed,calls,runtime, + hardware:v=>hardware=v,running:v=>running=v,fail:v=>failure=v,dir }; +} +test('removes only listed apps; repeated removal and restore preserve games and core', async t => { + const f=await fixture(t); + const result=await f.debloat.apply(); assert.equal(result.completed,2); assert.deepEqual(result.failures,[]); + assert(f.installed.has('com.test.game')); assert(f.installed.has('com.android.systemui')); + assert.equal((await f.debloat.apply()).completed,0); + assert.equal((await f.debloat.apply(true)).completed,2); + assert(f.installed.has('com.google.android.gms')); + assert.equal((await f.debloat.apply(true)).completed,0); + assert(f.calls.filter(a=>a.includes('uninstall')).every(a=>a.includes('-k') && a.includes('--user') && a.includes('0'))); +}); +test('rejects physical devices and externally running games before mutation', async t => { + const f=await fixture(t); f.hardware('qcom'); await assert.rejects(f.debloat.apply(),/emulator/); + f.hardware('ranchu'); f.running('com.test.game:worker'); await assert.rejects(f.debloat.apply(),/Close running/); + assert(!f.calls.some(a=>a.includes('uninstall'))); +}); +test('failed removal remains recoverable; restoration never trusts arbitrary journal packages', async t => { + const f=await fixture(t); f.fail('com.google.android.gms'); + assert.equal((await f.debloat.apply()).failures.length,1); + assert((await f.debloat.read()).packages.includes('com.google.android.gms')); + f.fail(''); await f.debloat.apply(true); + await fs.writeFile(path.join(f.dir,'axrb-debloat.json'),JSON.stringify({version:1,packages:['com.test.game']})); + await assert.rejects(f.debloat.apply(true),/Invalid debloat/); +}); +test('policy preserves Android and VR dependencies', () => { + for (const p of ['android','com.android.systemui','com.android.settings','com.google.android.permissioncontroller','com.google.android.webview','com.google.android.networkstack','com.google.android.inputmethod.latin','com.google.android.providers.media.module','com.axrb.openxrruntime']) assert(!OPTIONAL_PACKAGES.includes(p)); +}); diff --git a/launcher/tests/emulator-startup.test.mjs b/launcher/tests/emulator-startup.test.mjs index f2d6a9e..6ff1650 100644 --- a/launcher/tests/emulator-startup.test.mjs +++ b/launcher/tests/emulator-startup.test.mjs @@ -6,6 +6,41 @@ import { run } from '../core/runtime.mjs'; const script = fileURLToPath(new URL('../../scripts/emulator/windows_android_emulator.ps1', import.meta.url)).replaceAll("'", "''"); const windows = { skip: process.platform !== 'win32', timeout: 15000 }; +test('APIC auto stays on QEMU and explicit modes override inherited flags', windows, async () => { + const result = await probe(` +$auto = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text -eq "$" + "LocalApic -eq 'Auto'" +}, $true) +$assignments = $ast.FindAll({ param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -in @('$env:AXRB_WHPX_HV_APIC', '$env:AXRB_WHPX_HV_X2APIC') -and + $node.Right.Extent.Text.Contains('$LocalApic') +}, $true) +if (!$auto -or $assignments.Count -ne 2) { throw 'Missing APIC selection block' } +$rows = @() +foreach ($clock in @('Default', 'Tsc', 'TscCorrected')) { + foreach ($mode in @('Auto', 'Qemu', 'Hypervisor', 'HypervisorX2Apic')) { + $GuestClock = $clock + $LocalApic = $mode + $env:AXRB_WHPX_HV_APIC = '1' + $env:AXRB_WHPX_HV_X2APIC = '1' + . ([scriptblock]::Create($auto.Extent.Text)) + foreach ($statement in $assignments) { . ([scriptblock]::Create($statement.Extent.Text)) } + $rows += @{ mode = $LocalApic; hv = $env:AXRB_WHPX_HV_APIC; x2 = $env:AXRB_WHPX_HV_X2APIC } + } +} +Write-Output ('RESULT:' + (ConvertTo-Json -InputObject $rows -Compress)) +`); + const modes = [ + { mode: 'Qemu', hv: '0', x2: '0' }, + { mode: 'Qemu', hv: '0', x2: '0' }, + { mode: 'Hypervisor', hv: '1', x2: '0' }, + { mode: 'HypervisorX2Apic', hv: '1', x2: '1' }, + ]; + assert.deepEqual(result, [...modes, ...modes, ...modes]); +}); + // Execute the production functions/diagnostic block without starting an emulator. // Only the external ADB boundary and retry delay are replaced. async function probe(body) { diff --git a/launcher/tests/game-export-smoke.mjs b/launcher/tests/game-export-smoke.mjs new file mode 100644 index 0000000..635b7e2 --- /dev/null +++ b/launcher/tests/game-export-smoke.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Exercise real IPC, ZIP writing, batch import and state updates. Only native +// dialogs, APK inspection and the Android install boundary use fixtures. +export async function gameExportSmoke(window, { state, runtime, dialog, persist }) { + const directory = await fs.mkdtemp(path.join(state.directory, 'export-smoke-')); + const original = { save: dialog.showSaveDialog, open: dialog.showOpenDialog, inspect: runtime.inspect, install: runtime.install, + games: structuredClone(state.data.games), jobs: structuredClone(state.data.jobs), downloads: state.data.settings.downloadDir }; + const invoke = async (name, ...args) => { + const result = await window.webContents.executeJavaScript(`window.axrb[${JSON.stringify(name)}](...${JSON.stringify(args)})`); + assert.equal(result.ok, true, result.error); + return result.value; + }; + const waitJob = async id => { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const job = state.data.jobs.find(job => job.id === id); + if (job?.status === 'failed') throw Error(job.error); + if (job?.status === 'complete') return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw Error('Export/import job timed out'); + }; + try { + const games = []; + for (const name of ['one', 'two']) { + const game = { id: `local:com.export.${name}`, package: `com.export.${name}`, name: `Export ${name}`, versionCode: '1', split: '', source: 'local', apk: path.join(directory, `${name}.apk`) }; + await fs.writeFile(game.apk, JSON.stringify(game)); + state.put(game); games.push(game); + } + runtime.inspect = async file => JSON.parse(await fs.readFile(file, 'utf8')); + const output = path.join(directory, 'games.zip'); + games[0].name = 'Batman: Arkham Shadow'; + state.put(games[0]); + dialog.showSaveDialog = async (_window, options) => { + assert.equal(path.basename(options.defaultPath), 'Batman- Arkham Shadow.zip'); + return { canceled: false, filePath: path.join(directory, 'single.zip') }; + }; + await waitJob(await invoke('exportGames', [games[0].id])); + dialog.showSaveDialog = async () => ({ canceled: false, filePath: output }); + await waitJob(await invoke('exportGames', games.map(game => game.id))); + assert.ok((await fs.stat(output)).size > 0); + state.data.games = state.data.games.filter(game => !games.some(item => item.id === game.id)); + state.data.settings.downloadDir = path.join(directory, 'imported'); + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [output] }); + const installed = []; + runtime.install = async game => { installed.push(game.package); assert.ok((await fs.stat(game.apk)).isFile()); }; + await waitJob(await invoke('importZip')); + assert.deepEqual(installed, games.map(game => game.package)); + for (const game of games) assert.equal(state.data.games.find(item => item.package === game.package)?.installed, true); + console.log('AXRB game export smoke passed: two games exported and reimported through production IPC.'); + } finally { + dialog.showSaveDialog = original.save; dialog.showOpenDialog = original.open; + runtime.inspect = original.inspect; runtime.install = original.install; + state.data.games = original.games; state.data.jobs = original.jobs; state.data.settings.downloadDir = original.downloads; + await persist(); + await fs.rm(directory, { recursive: true, force: true }); + } +} diff --git a/launcher/tests/game-export.test.mjs b/launcher/tests/game-export.test.mjs new file mode 100644 index 0000000..950643a --- /dev/null +++ b/launcher/tests/game-export.test.mjs @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { exportGamesZip, gameExportFilename } from '../core/game-export.mjs'; +import { safeName } from '../core/download.mjs'; +import { importGameArchive, importGameZip } from '../core/game-files.mjs'; + +async function workspace(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'axrb-export-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} +const inspect = async file => { + const identity = JSON.parse(await fs.readFile(file, 'utf8')); + return { id: `local:${identity.package}`, source: 'local', name: identity.package, apk: file, ...identity }; +}; + +test('ZIP suggestions accept display titles without weakening asset validation', () => { + assert.equal(gameExportFilename('Batman: Arkham Shadow'), 'Batman- Arkham Shadow.zip'); + assert.equal(gameExportFilename('CON'), 'Game-CON.zip'); + assert.equal(gameExportFilename(' ... '), 'AXRB-game.zip'); + assert.equal(gameExportFilename('日本語'), '日本語.zip'); + for (const title of ['a/b\\c:*?"<>|', 'NUL.txt', 'Game. ', '\x00test', '😀'.repeat(300), undefined]) { + assert.doesNotThrow(() => safeName(gameExportFilename(title))); + } + assert.throws(() => safeName('Batman: Arkham Shadow')); +}); +async function game(root, pkg) { + const dir = path.join(root, pkg); await fs.mkdir(dir); + const apk = path.join(dir, 'patched.apk'), split = path.join(dir, 'split.apk'), asset = path.join(dir, 'asset'); + await fs.writeFile(apk, JSON.stringify({ package: pkg, versionCode: '7', split: '', patched: true })); + await fs.writeFile(split, JSON.stringify({ package: pkg, versionCode: '7', split: 'arm64' })); + await fs.writeFile(asset, 'asset payload'); + return { ...await inspect(apk), files: [ + { kind: 'apk', path: path.join(dir, 'unavailable-original.apk') }, + { kind: 'split', path: split }, + { kind: 'asset', path: asset, destination: `Android/data/${pkg}/files/asset.bundle` } + ] }; +} + +test('single export round-trips through the existing ZIP format using the active patched APK', async t => { + const root = await workspace(t), source = await game(root, 'com.example.one'), output = path.join(root, 'one.zip'); + await fs.writeFile(output, 'previous export'); + const progress = []; + await exportGamesZip([source], output, inspect, { progress: (...args) => progress.push(args) }); + const imported = await importGameZip(output, root, inspect, { reserve: 0 }); + assert.equal(imported.package, source.package); + assert.equal(imported.patched, true); + assert.deepEqual(imported.files.map(f => f.kind).sort(), ['apk', 'asset', 'split']); + const asset = imported.files.find(f => f.kind === 'asset'); + assert.equal(asset.destination, source.files[2].destination); + assert.equal(await fs.readFile(asset.path, 'utf8'), 'asset payload'); + assert.equal(progress.at(-1)[0], progress.at(-1)[1]); +}); + +test('multiple games round-trip with distinct splits and asset destinations', async t => { + const root = await workspace(t), sources = await Promise.all(['com.example.one', 'com.example.two'].map(pkg => game(root, pkg))); + const output = path.join(root, 'collection.zip'); + await exportGamesZip(sources, output, inspect); + const imported = await importGameArchive(output, root, inspect, { reserve: 0 }); + assert.deepEqual(imported.map(g => g.package), sources.map(g => g.package)); + for (const entry of imported) { + assert.equal(entry.files.length, 3); + assert.equal(entry.files.find(f => f.kind === 'asset').destination, `Android/data/${entry.package}/files/asset.bundle`); + } +}); + +test('mismatched splits and missing assets fail without replacing an existing output', async t => { + const root = await workspace(t), source = await game(root, 'com.example.one'), output = path.join(root, 'existing.zip'); + await fs.writeFile(output, 'keep'); + await fs.writeFile(source.files[1].path, JSON.stringify({ package: source.package, versionCode: '8', split: 'arm64' })); + await assert.rejects(exportGamesZip([source], output, inspect), /split APKs/); + source.files = [source.files[2]]; + await fs.rm(source.files[0].path); + await assert.rejects(exportGamesZip([source], output, inspect), /ENOENT/); + assert.equal(await fs.readFile(output, 'utf8'), 'keep'); +}); + +test('cancellation removes partial files and preserves the existing ZIP', async t => { + const root = await workspace(t), source = await game(root, 'com.example.one'), output = path.join(root, 'existing.zip'); + await fs.writeFile(output, 'keep'); + await fs.writeFile(source.files[2].path, Buffer.alloc(1024 * 1024)); + const controller = new AbortController(); + await assert.rejects(exportGamesZip([source], output, inspect, { signal: controller.signal, progress: () => controller.abort() }), /abort/i); + assert.equal(await fs.readFile(output, 'utf8'), 'keep'); + assert.equal((await fs.readdir(root)).some(name => name.endsWith('.partial')), false); +}); + +test('collections validate every game before returning and clean up invalid imports', async t => { + const root = await workspace(t), sources = await Promise.all(['com.example.one', 'com.example.two'].map(pkg => game(root, pkg))); + const output = path.join(root, 'collection.zip'); + await exportGamesZip(sources, output, inspect); + const downloads = path.join(root, 'downloads'); + await assert.rejects(importGameArchive(output, downloads, async file => { + const identity = await inspect(file); + if (identity.package === 'com.example.two') throw new Error('Invalid APK'); + return identity; + }, { reserve: 0 }), /Invalid APK/); + assert.deepEqual(await fs.readdir(path.join(downloads, 'imports')), []); +}); diff --git a/launcher/tests/preload.test.mjs b/launcher/tests/preload.test.mjs new file mode 100644 index 0000000..9a740c9 --- /dev/null +++ b/launcher/tests/preload.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import vm from 'node:vm'; + +async function bridge() { + let api; + const calls = []; + const electron = { + contextBridge: { exposeInMainWorld: (name, value) => { assert.equal(name, 'axrb'); api = value; } }, + ipcRenderer: { invoke: async (...args) => { calls.push(args); return { ok: true, value: { host: {}, android: null } }; } } + }; + vm.runInNewContext(await fs.readFile(new URL('../preload.cjs', import.meta.url), 'utf8'), { + require: name => { assert.equal(name, 'electron'); return electron; } + }); + return { api, calls }; +} + +test('storage usage is exposed and forwarded to its main-process handler', async () => { + const { api, calls } = await bridge(); + assert.equal(typeof api.storageInfo, 'function'); + assert.equal((await api.storageInfo()).ok, true); + assert.deepEqual(calls, [['axrb:storageInfo']]); + assert.equal(typeof api.storage, 'function'); +}); + +test('every static renderer API call is exposed by the real preload', async () => { + const { api } = await bridge(); + const root = new URL('../ui/', import.meta.url); + for (const file of await fs.readdir(root, { recursive: true })) { + if (!/\.(jsx|js)$/.test(file)) continue; + const text = await fs.readFile(new URL(file.replaceAll('\\', '/'), root), 'utf8'); + for (const match of text.matchAll(/\bcall\(['"]([A-Za-z][A-Za-z0-9]*)['"]/g)) { + assert.equal(typeof api[match[1]], 'function', `${file}: ${match[1]} is missing from preload`); + } + } +}); diff --git a/launcher/tests/runtime-launch.test.mjs b/launcher/tests/runtime-launch.test.mjs index c5b3587..a06ab96 100644 --- a/launcher/tests/runtime-launch.test.mjs +++ b/launcher/tests/runtime-launch.test.mjs @@ -92,8 +92,8 @@ test('Windows game launch actually executes PowerShell and reports its exit', { t.after(() => fs.rm(root, { recursive: true, force: true })); await fs.mkdir(path.join(root, 'scripts/run'), { recursive: true }); await fs.writeFile(path.join(root, 'scripts/run/run_windows_game.ps1'), ` -param($Avd, $Port, $Sdk, $MemoryMB, $CpuCores, $Package, $Activity, $GameName, [switch]$FpsHud, $FpsHudEventName) -Write-Output "EXECUTED:$Package CORES:$CpuCores" +param($Avd, $Port, $Sdk, $MemoryMB, $CpuCores, $TextureMode, $Package, $Activity, $GameName, [switch]$FpsHud, $FpsHudEventName) +Write-Output "EXECUTED:$Package CORES:$CpuCores TEXTURE:$TextureMode" exit 7 `); const runtime = new Runtime(root, { avd:'test',port:5580,sdk:root,memoryMB:8192,cpuCores:6 }); @@ -104,7 +104,14 @@ exit 7 assert.equal(result.code, 7); // -File propagates the script's own exit code. assert.match(result.output, /EXECUTED:com.example.game/); assert.match(result.output, /CORES:6/); + assert.match(result.output, /TEXTURE:gpu/); assert.equal(runtime.game, null); + runtime.settings.textureMode = 'bc7'; + const quality = await new Promise(resolve => { + runtime.launch({ id:'local:com.example.game',package:'com.example.game',activity:'com.example.game/.Main',name:'Test' }, (code, output) => resolve({code,output})); + }); + assert.equal(quality.code, 7); + assert.match(quality.output, /TEXTURE:bc7/); }); test('projection precomposition follows matched profiles or the global override', { skip: process.platform !== 'win32', timeout: 20000 }, async t => { diff --git a/launcher/tests/ui-smoke.mjs b/launcher/tests/ui-smoke.mjs index d19dd7b..e682166 100644 --- a/launcher/tests/ui-smoke.mjs +++ b/launcher/tests/ui-smoke.mjs @@ -60,6 +60,23 @@ export async function uiSmoke(window, directory, snapshot, errors) { await check(`Boolean(document.querySelector('#library-search'))`, 'Library did not load'); await diagnosticsSmoke(window); await check(`Array.from(document.querySelectorAll('button')).some(b => b.textContent === 'Install ZIP')`, 'ZIP install action missing'); + wc.send('axrb:changed', { ...snapshot(), busy: false, games: [ + { id: 'local:com.export.one', name: 'Export fixture one', apk: 'fixture-one.apk' }, + { id: 'local:com.export.two', name: 'Export fixture two', apk: 'fixture-two.apk' }, + { id: 'local:com.export.remote', name: 'Not downloaded' } + ] }); + await tick(); + await js(`Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Export games').click()`); + await check(`document.querySelectorAll('[role="dialog"] input[type="checkbox"]').length === 2`, 'Export must list only games with local APKs'); + await check(`Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => b.textContent.startsWith('Export '))?.disabled`, 'Empty export selection must be disabled'); + await js(`Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => b.textContent === 'Select all').click()`); + await check(`Array.from(document.querySelectorAll('[role="dialog"] button')).some(b => b.textContent === 'Export 2 games' && !b.disabled)`, 'Multi-game selection did not enable export'); + await capture('export-games'); + await js(`Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => b.textContent === 'Clear selection').click()`); + await check(`!document.querySelector('[role="dialog"] input:checked')`, 'Export clear selection failed'); + await escape(); + wc.send('axrb:changed', snapshot()); + await tick(); if (await js(`Boolean(document.querySelector('h1, footer'))`)) throw new Error('Unexpected decorative heading/footer'); await click('[data-nav="quest"]'); await check(`Boolean(document.querySelector('[data-quest]'))`, 'Quest view did not render'); diff --git a/launcher/ui/common.jsx b/launcher/ui/common.jsx index 67132c9..cbe3916 100644 --- a/launcher/ui/common.jsx +++ b/launcher/ui/common.jsx @@ -3,7 +3,7 @@ import { Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -export const activeStatuses = ['queued', 'downloading', 'installing', 'patching', 'importing', 'uninstalling']; +export const activeStatuses = ['queued', 'downloading', 'installing', 'patching', 'importing', 'exporting', 'uninstalling']; export const bytes = n => n ? `${(n / 1024 ** 3).toFixed(n < 1024 ** 3 ? 2 : 1)} GB` : '0 GB'; export function InstallProgress({ job }) { const total = job.progressUnit === 'bytes' ? job.total : 0, completed = job.completed || 0; diff --git a/launcher/ui/downloads.jsx b/launcher/ui/downloads.jsx index 3504777..51b0b85 100644 --- a/launcher/ui/downloads.jsx +++ b/launcher/ui/downloads.jsx @@ -5,9 +5,9 @@ export function Downloads({ jobs, run, pending }) { return
{jobs.length ? jobs.map(job =>
{job.name}{job.status}
{job.stage}{Boolean(job.total) && job.status !== 'installing' && {job.progressUnit === 'files' ? `${job.completed} / ${job.total} files` : `${bytes(job.completed)} / ${bytes(job.total)}`}}
- {job.status === 'installing' ? : ['downloading', 'importing', 'uninstalling'].includes(job.status) && } + {job.status === 'installing' ? : ['downloading', 'importing', 'exporting', 'uninstalling'].includes(job.status) && }
- {['queued', 'downloading', 'importing'].includes(job.status) ? : !job.kind && ['failed', 'interrupted', 'cancelled'].includes(job.status) && /^\d+$/.test(job.gameId) ? : null} + {['queued', 'downloading', 'importing', 'exporting'].includes(job.status) ? : !job.kind && ['failed', 'interrupted', 'cancelled'].includes(job.status) && /^\d+$/.test(job.gameId) ? : null}
{job.error &&

{job.error}

}
) : No downloads}
; } diff --git a/launcher/ui/game-export.jsx b/launcher/ui/game-export.jsx new file mode 100644 index 0000000..ec86e9e --- /dev/null +++ b/launcher/ui/game-export.jsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { call } from './common'; + +export function GameExport({ games, busy, onClose, run, pending, setPage }) { + const available = games.filter(game => game.apk); + const [selected, setSelected] = useState(new Set()); + const ids = available.filter(game => selected.has(game.id)).map(game => game.id); + return { if (!open) onClose(); }}> + + Export gamesChoose games to save in one ZIP. Use Install ZIP to import it again. Includes local APKs and assets, without pulling saves from Android. + {available.length ? <> +
{ids.length} selected
+
{available.map(game => )}
+ :

Download or import a game APK first.

} +
+
+
; +} diff --git a/launcher/ui/main.jsx b/launcher/ui/main.jsx index 8b4a9b6..11f3965 100644 --- a/launcher/ui/main.jsx +++ b/launcher/ui/main.jsx @@ -11,6 +11,7 @@ import { AiHelp } from './ai-help'; import { Settings } from './settings'; import { Downloads } from './downloads'; import { GameDetails } from './game-details'; +import { GameExport } from './game-export'; import { Quest } from './quest'; import { Toasts } from './toasts'; import { DebugPanel } from './diagnostics'; @@ -25,6 +26,7 @@ function App() { const [results, setResults] = useState([]); const [searched, setSearched] = useState(false); const [selected, setSelected] = useState(null); + const [exportOpen, setExportOpen] = useState(false); const [pending, setPending] = useState(new Set()); const pendingRef = useRef(new Set()); const [notice, setNotice] = useState(null); @@ -68,7 +70,7 @@ function App() { {!state ? : <> {page === 'library' && <>
setLibraryQuery(e.target.value)} />
-
+
{games.length ? : {state.games.length ? 'No matching games' : 'No games yet'}}} {page === 'store' && <>
{/* Escaped so the class stays valid under the stricter v-mode regex engine some Chromium builds use for pattern validation; an unescaped trailing hyphen used to pass unnoticed. */} +
+
+ Debloat Android +
+ + +
+
+

Removes optional apps and Google Play services from Android. Games requiring Google services may need Restore. Keeps app data; does not shrink the system image.

+ {pending.has('debloat') &&
+ {debloatProgress ? <>{debloatProgress.completed}/{debloatProgress.total} {debloatProgress.package} : 'Preparing Android…'} +
} + {debloatResult?.failures.length > 0 &&
{debloatResult.failures.map(f =>

{f.package}: {f.error}

)}
} +
{!state.settings.managedDirectory && <>{field('sdk', 'Android SDK', { required: true })}{field('avd', 'Virtual device', { required: true, pattern: '[a-zA-Z0-9_\\-]+' })}}
{field('port', 'Port', { type: 'number', min: 5554, max: 5682, step: 2, required: true })}{field('memoryMB', 'Memory (MB)', { type: 'number', min: 2048, max: 16384, step: 1024, required: true })}
@@ -140,6 +165,15 @@ export function Settings({ state, run, pending, notify }) { className="size-4 shrink-0 accent-primary" aria-describedby="precompose-projection-layers-help" onChange={e => edit('precomposeProjectionLayers', e.target.checked)} />
+
+ + +

Performance is the default. BC7 preserves more texture detail. Applies on the next game launch.

+