Fix RetroArch WebUI QR action registration

Bind WebUI in the common action initializer instead of the custom keyboard
branch. Preserve patch markers so existing configured trees update too.
Production callback/dispatch regression fails before the fix and passes
after it; picker and ES-DE binaries remain byte-identical.

Resolve the integration fixture race exposed by this run: browser covers
now use valid PNG data and assertions require decoded images.

Validation: PS5_FRONTENDS='es-de picker' PS5_VULKAN_TEMPLATE="$PWD/build/picker-template" tools/verify.sh
Format, unit (177 tests), and build passed. After correcting the fixture,
tools/verify.sh format integration evidence passed (177 tests).
Evidence: evidence/webui-qr-actions/capture.json and expectation.json;
reproduce with python3 tools/evidence.py compare evidence/

Deployed build 0bdc8dd8: 6 changed files verified; exact stored-byte eboot
digest matches. Owner confirmed picker/ES-DE; XMB/RGUI live recheck pending.
This commit is contained in:
Mihawk committed 2026-10-07 18:33:46 -04:00
1 parent bf859b0d77
commit 3de11ab36e
7 files changed
+221 -6

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
{
"step": "webui-qr-actions",
"recorded": "2026-10-07T22:32:02.262734+00:00",
"revision": "working tree on bf859b0",
"command": "PS5_FRONTENDS='es-de picker' PS5_VULKAN_TEMPLATE=\"$PWD/build/picker-template\" tools/verify.sh",
"raw_capture": "klog/webui-qr-actions/gates-recheck.log",
"raw_lines": 1360,
"raw_bytes": 89236,
"lines": [
"test_current_ip_changes_offline_and_modal_lifecycle (tests.test_webui_qr.WebUIQR.test_current_ip_changes_offline_and_modal_lifecycle) ... ok",
"test_encoder_decodes_and_preserves_quiet_zone (tests.test_webui_qr.WebUIQR.test_encoder_decodes_and_preserves_quiet_zone) ... ok",
"test_menu_hooks_apply_and_are_idempotent (tests.test_webui_qr.WebUIQR.test_menu_hooks_apply_and_are_idempotent) ... ok",
"test_picker_buttons_consume_input (tests.test_webui_qr.WebUIQR.test_picker_buttons_consume_input) ... ok",
"test_retroarch_entry_opens_modal_and_cancel_closes (tests.test_webui_qr.WebUIQR.test_retroarch_entry_opens_modal_and_cancel_closes) ... ok",
"test_rgui_rendered_frame_decodes (tests.test_webui_qr.WebUIQR.test_rgui_rendered_frame_decodes) ... ok",
"test_upstream_encoder_is_unmodified (tests.test_webui_qr.WebUIQR.test_upstream_encoder_is_unmodified) ... ok",
"test_xmb_rendered_frame_decodes (tests.test_webui_qr.WebUIQR.test_xmb_rendered_frame_decodes) ... ok",
"",
"----------------------------------------------------------------------",
"Ran 8 tests in 1.049s",
"",
"OK",
"== format",
" PASS (9s)",
"== unit",
"Ran 177 tests in 266.658s",
" PASS (267s)",
"== build",
" PASS (106s)",
"== format",
" PASS (9s)",
"== integration",
"Ran 177 tests in 244.004s",
" PASS (251s)",
"== evidence",
"101 capture(s) replayed, 0 failed",
" PASS (1s)",
"verify: PASS (format integration evidence)",
"Regression reproduced on bf859b0: WebUI entry action was not bound.",
"Working picker and ES-DE binaries remain byte-identical: PASS",
"Deployment: 6 changed files verified; 13617 unchanged files retained.",
"Deployed eboot.bin stored-byte SHA-256 matches corrected staged build: PASS"
],
"owner_report": {
"picker": "Works great",
"xmb": "WebUI tab doesn't work",
"rgui": "WebUI doesn't work",
"es-de": "Works great"
},
"root_cause": "0115 inserted the WebUI action binding at the first lookup-setting binding, inside the custom keyboard branch. Common menu entries could not reach it. 0116 retires that insertion and binds WebUI in menu_cbs_init_bind_ok.",
"regression_before": {
"raw_capture": "klog/webui-qr-actions/before.log",
"result": "Expected failure: selecting WebUI failed the action initializer assertion."
},
"regression_after": "Compile production action initializer, WebUI callback and modal dispatcher against the real QR library. Select opens QR with expected URL, navigation is consumed, Cancel closes, normal actions resume, other entries retain fallback.",
"staged_artifacts": {
"eboot.bin": {
"bytes": 38771116,
"sha256": "dbb60b013648c51ac7058f7dbb7107997a08920a1ace6adc6e446e2b7773d4ff"
},
"picker/picker.bin": {
"bytes": 29608493,
"sha256": "411e2b6f604878bd00b8d2b67e797b2ae91f1682ad046719464d04c57fa9fe04"
},
"es-de/es-de.bin": {
"bytes": 48154397,
"sha256": "ac4dde1b3f41f06b633d24efbdbe5f6978e26ea91a15b1779a6f79c40478c9a5"
},
"manifest.sha256": {
"bytes": 1785399,
"sha256": "4fd06c8a3a3a9a926b84f4519c37275bd264be3af9adf20f337599447b2441a3"
}
},
"console_run": "Corrected build 0bdc8dd8 deployed to base PS5; exact eboot.bin stored-byte SHA-256 matches. XMB/RGUI live recheck pending.",
"verification_commands": [
"PS5_FRONTENDS='es-de picker' PS5_VULKAN_TEMPLATE=\"$PWD/build/picker-template\" tools/verify.sh",
"tools/verify.sh format integration evidence"
],
"initial_integration_failure": {
"raw_capture": "klog/webui-qr-actions/gates.log",
"test": "test_games_page_in_a_browser",
"error": "Timeout waiting for four cover images. The fixture served text as PNG, racing the page image-error fallback.",
"resolution": "Browser-only source fixture now supplies a valid PNG; assertion waits for decoded images. Isolated strict test and full integration pass.",
"strict_test_log": "klog/webui-qr-actions/browser-fixed.log"
},
"deployment": {
"command": "python3 tools/deploy-title.py",
"raw_capture": "klog/webui-qr-actions/deploy.log",
"changed_files_verified": 6,
"unchanged_files_retained": 13617,
"eboot_readback": {
"bytes": 38771116,
"sha256": "dbb60b013648c51ac7058f7dbb7107997a08920a1ace6adc6e446e2b7773d4ff",
"matches_staged": true
}
}
}
@@ -0,0 +1,17 @@
{
"step": "webui-qr-actions",
"note": "Fix the common RetroArch action registration, preserving picker/ES-DE binaries confirmed working by owner. Production action flow regression reproduced before the fix and passes after.",
"must_contain": [
"test_retroarch_entry_opens_modal_and_cancel_closes",
"Regression reproduced on bf859b0: WebUI entry action was not bound.",
"Working picker and ES-DE binaries remain byte-identical: PASS",
"Ran 177 tests",
"verify: PASS (format integration evidence)",
"Deployed eboot.bin stored-byte SHA-256 matches corrected staged build: PASS"
],
"must_not_contain": [
"FAILED",
"ERROR"
],
"human_check": "After restarting the title, select WebUI in XMB and RGUI; confirm the QR opens and Circle closes it. Picker and ES-DE were confirmed working by the owner."
}
+2
View File
@@ -265,3 +265,5 @@
0114 The shared media library: RetroArch's thumbnail lookup asks /app0/library (src/ps5_library.c, ps5_library_thumbnail) first, the files every frontend reads; thumbnails_directory "default" is /app0/thumbnails, where it was empty and stopped every lookup.
0115 Runtime-IP WebUI QR: XMB tab immediately left of Settings, RGUI first main-menu entry, modal drawing and input; shared encoder in src/ps5_webui_qr.c.
0116 Bind the WebUI action in the common entry initializer; 0115 placed it in the custom-keyboard branch, unreachable from XMB/RGUI WebUI entries.
+8 -3
View File
@@ -462,9 +462,14 @@ class Scraper(unittest.TestCase):
shutil.copytree(ROOT / 'webui', self.root / 'webui', dirs_exist_ok=True)
# Page files newer than the running server: the page asks for a restart.
(self.root / 'webui/version.json').write_text('{"build": "newer-than-the-server"}\n')
# Covers the page can decode: real PNGs from the fake source for this test.
run = subprocess.run(['node', 'tests/webui_scraper_browser.cjs'], cwd=ROOT, capture_output=True, text=True, timeout=300,
env={**os.environ, 'PLAYWRIGHT_PATH': playwright, 'WEBUI_TEST_URL': f'http://127.0.0.1:{self.port}'})
# Covers must decode: the transfer-only fixture is text, and the page
# replaces failed images, racing the browser's cover-count assertion.
import base64
from unittest.mock import patch
png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==')
with patch(__name__ + '.image', return_value=png):
run = subprocess.run(['node', 'tests/webui_scraper_browser.cjs'], cwd=ROOT, capture_output=True, text=True, timeout=300,
env={**os.environ, 'PLAYWRIGHT_PATH': playwright, 'WEBUI_TEST_URL': f'http://127.0.0.1:{self.port}'})
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
+82 -1
View File
@@ -144,7 +144,7 @@ int main() {
files[name] = files[name].replace(anchor, replacement, 1)
before = files.copy()
for name, anchor, replacement, marker in edits:
if "0115" in replacement and marker not in files[name]:
if any(step in replacement for step in ("0115", "0116")) and marker not in files[name]:
files[name] = files[name].replace(anchor, replacement, 1)
self.assertEqual(before, files)
xmb = files['menu/drivers/xmb.c']
@@ -155,6 +155,87 @@ int main() {
rgui = files['menu/menu_displaylist.c'].split('case DISPLAYLIST_MAIN_MENU:', 1)[1]
self.assertLess(rgui.index('"ps5_webui"'), rgui.index('RUNLOOP_FLAG_CORE_RUNNING'))
def test_retroarch_entry_opens_modal_and_cancel_closes(self):
edits = runpy.run_path(str(ROOT / 'tools/apply-port-patches.py'))['EDITS']
files = {}
for name, anchor, replacement, marker in edits:
if name not in files:
files[name] = (ROOT / 'vendor/retroarch' / name).read_text()
if marker not in files[name]:
self.assertIn(anchor, files[name], marker)
files[name] = files[name].replace(anchor, replacement, 1)
def function(source, signature):
return source[source.index(signature):].split('\n}', 1)[0] + '\n}\n'
callbacks = files['menu/cbs/menu_cbs_ok.c']
code = r"""
#include <assert.h>
#include <string.h>
#include "ps5_webui_qr.h"
void qr_test_network(const char *, unsigned, int, long);
typedef int (*ok_cb)(const char *, const char *, unsigned, size_t, size_t);
typedef struct { ok_cb action_ok; } menu_file_list_cbs_t;
#define BIND_ACTION_OK(cbs, name) ((cbs)->action_ok = (name))
#define string_is_equal(a, b) ((a) && (b) && strcmp((a), (b)) == 0)
static int fallback_calls;
static int action_ok_lookup_setting(const char *p, const char *l, unsigned t, size_t i, size_t e)
{ fallback_calls++; return 23; }
static int menu_cbs_init_bind_ok_compare_label(menu_file_list_cbs_t *c, const char *l)
{ return -1; }
static int menu_cbs_init_bind_ok_compare_type(menu_file_list_cbs_t *c, const char *l,
const char *m, unsigned t)
{ return -1; }
"""
code += function(callbacks, 'static int action_ok_ps5_webui(')
code += function(callbacks, 'int menu_cbs_init_bind_ok(')
code += r"""
enum menu_action { MENU_ACTION_NOOP, MENU_ACTION_OK, MENU_ACTION_CANCEL, MENU_ACTION_RIGHT };
typedef struct { menu_file_list_cbs_t cbs; } menu_entry_t;
static int forwarded;
static int entry_action(void *data, menu_entry_t *entry, size_t i, enum menu_action action)
{
forwarded++;
if (action == MENU_ACTION_OK)
return entry->cbs.action_ok("WebUI", "ps5_webui", 0, i, 0);
return 17;
}
struct driver { int (*entry_action)(void *, menu_entry_t *, size_t, enum menu_action); };
struct menu_state { struct driver *driver_ctx; void *userdata; };
static struct driver driver = {entry_action};
static struct menu_state menu_driver_state = {&driver, NULL};
"""
code += function(files['menu/menu_driver.c'], 'int menu_entry_action(')
code += r"""
int main(void)
{
qr_test_network("192.0.2.42", 1, 0, 500);
menu_entry_t entry = {0};
assert(menu_cbs_init_bind_ok(NULL, "", "ps5_webui", 9, 0, 0, "", 0) == -1);
assert(menu_cbs_init_bind_ok(&entry.cbs, "WebUI", "ps5_webui", 9, 0, 0, "main_menu", 9) == 0);
assert(entry.cbs.action_ok == action_ok_ps5_webui);
assert(menu_entry_action(&entry, 0, MENU_ACTION_OK) == 0);
assert(ps5_webui_qr_visible() && forwarded == 1 && fallback_calls == 0);
assert(strcmp(ps5_webui_qr_current()->url, "http://192.0.2.42:6769/") == 0);
assert(menu_entry_action(&entry, 0, MENU_ACTION_RIGHT) == 0);
assert(ps5_webui_qr_visible() && forwarded == 1);
assert(menu_entry_action(&entry, 0, MENU_ACTION_CANCEL) == 0);
assert(!ps5_webui_qr_visible() && forwarded == 1);
assert(menu_entry_action(&entry, 0, MENU_ACTION_RIGHT) == 17 && forwarded == 2);
assert(menu_entry_action(&entry, 0, MENU_ACTION_OK) == 0 && ps5_webui_qr_visible());
assert(menu_entry_action(&entry, 0, MENU_ACTION_OK) == 0 && !ps5_webui_qr_visible());
assert(menu_cbs_init_bind_ok(&entry.cbs, "", "other", 5, 0, 0, "", 0) == -1);
assert(entry.cbs.action_ok == action_ok_lookup_setting);
assert(entry.cbs.action_ok("", "other", 0, 0, 0) == 23 && fallback_calls == 1);
}
"""
file = Path(self.tmp.name) / 'menu-action.c'
file.write_text(code)
binary = file.with_suffix('')
subprocess.run(['cc', '-std=c11', '-Isrc', str(file), str(Path(self.tmp.name) / 'qr.so'),
'-o', str(binary)], cwd=ROOT, check=True)
subprocess.run([str(binary)], check=True, cwd=self.tmp.name)
def test_upstream_encoder_is_unmodified(self):
folder = ROOT / 'third_party/qrcodegen'
for name, digest in json.loads((folder / 'source.json').read_text())['files'].items():
+8 -2
View File
@@ -65,7 +65,10 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright');
assert.equal(await page.locator('.recap-game').count(), 2);
assert.equal(await page.locator('.recap-game .kind-pill').count(), 2, 'one kind shown when one is picked');
await page.goto(process.env.WEBUI_TEST_URL + '/#games');
await page.waitForFunction(() => document.querySelectorAll('.game-cover img').length === 4);
await page.waitForFunction(() => {
const covers = [...document.querySelectorAll('.game-cover img')];
return covers.length === 4 && covers.every(img => img.complete && img.naturalWidth > 0);
});
// A card opens the game: one row a kind of media, missing ones greyed, each replaceable.
await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-cover').click();
const sheet = page.locator('#game-sheet');
@@ -96,7 +99,10 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright');
await page.waitForFunction(() => document.querySelectorAll('.game-card').length === 5);
assert.equal(await page.locator('input[name="games-view"][value="box3d"]').isChecked(), true);
await page.locator('label:has(input[name="games-view"][value="cover"])').click();
await page.waitForFunction(() => document.querySelectorAll('.game-cover img').length === 4);
await page.waitForFunction(() => {
const covers = [...document.querySelectorAll('.game-cover img')];
return covers.length === 4 && covers.every(img => img.complete && img.naturalWidth > 0);
});
// The checkbox still selects without opening.
await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-select').click();
assert.equal(await sheet.isVisible(), false);
+7
View File
@@ -3711,6 +3711,13 @@ EDITS += [
('menu/menu_driver.c', ' struct menu_state *menu_st = &menu_driver_state;\n if ( menu_st->driver_ctx\n && menu_st->driver_ctx->entry_action)', ' struct menu_state *menu_st = &menu_driver_state;\n /* patches/series, 0115: WebUI modal input */\n if (ps5_webui_qr_visible())\n {\n if (action == MENU_ACTION_CANCEL || action == MENU_ACTION_OK)\n ps5_webui_qr_close();\n return 0;\n }\n if ( menu_st->driver_ctx\n && menu_st->driver_ctx->entry_action)', 'patches/series, 0115: WebUI modal input'),
]
# Move 0115's binding out of the custom-keyboard-only branch. Preserve its
# marker so both fresh and already-patched trees converge on the same result.
EDITS += [
('menu/cbs/menu_cbs_ok.c', ' /* patches/series, 0115: WebUI action binding */\n if (string_is_equal(label, "ps5_webui"))\n {\n BIND_ACTION_OK(cbs, action_ok_ps5_webui);\n return 0;\n }\n BIND_ACTION_OK(cbs, action_ok_lookup_setting);', ' /* patches/series, 0115: WebUI action binding; moved by 0116 to the entry initializer. */\n BIND_ACTION_OK(cbs, action_ok_lookup_setting);', 'moved by 0116 to the entry initializer'),
('menu/cbs/menu_cbs_ok.c', ' BIND_ACTION_OK(cbs, action_ok_lookup_setting);\n\n if (menu_cbs_init_bind_ok_compare_label(cbs, label) == 0)', ' /* patches/series, 0116: WebUI entry action */\n if (string_is_equal(label, "ps5_webui"))\n {\n BIND_ACTION_OK(cbs, action_ok_ps5_webui);\n return 0;\n }\n BIND_ACTION_OK(cbs, action_ok_lookup_setting);\n\n if (menu_cbs_init_bind_ok_compare_label(cbs, label) == 0)', 'patches/series, 0116: WebUI entry action'),
]
# Changes that are withdrawn rather than deleted, by marker.
#
# An entry here names an insertion this driver no longer needs, and the