From b9eb82aa2570ef0fadc292c456bc0f0b22a8d6b9 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:56:28 +0000 Subject: [PATCH 1/5] Build: fetch the OpenGL SDK from a second mirror The pinned ps5-opengl 1.0.0 archive at MyLuxy/overdrive-deps now answers 404, so build_ps5.py stopped on any runner without the cache. It now tries the mirrors in dependencies.lock.json (ps5OpenGL.mirrors) in turn. PS5LM's copy is the same SDK packed again: its archive hash differs, but the pinned manifest hash and the per-file check that follows still pass (45 files). The build record names the hash of the archive used. --- ps5-react/CHANGELOG.md | 7 +++++++ ps5-react/dependencies.lock.json | 12 +++++++++++- ps5-react/docs/DEPENDENCIES.md | 2 +- ps5-react/tools/build_ps5.py | 23 +++++++++++++++++++---- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/ps5-react/CHANGELOG.md b/ps5-react/CHANGELOG.md index 9394cd2..225ff8e 100644 --- a/ps5-react/CHANGELOG.md +++ b/ps5-react/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased — OpenGL SDK mirror + +- `tools/build_ps5.py` downloads the ps5-opengl 1.0.0 archive from a list of mirrors in + `dependencies.lock.json`. The MyLuxy/overdrive-deps copy now answers 404; PS5LM's copy is the same + SDK packed again (its own archive hash, the same manifest and files). The build record names the + hash of the archive it used. + ## Unreleased — app payloads - Add `Payloads.start(name)` (native ABI v17, `payloads.start`): sends an ELF from the app folder diff --git a/ps5-react/dependencies.lock.json b/ps5-react/dependencies.lock.json index c5191a9..bc15e0e 100644 --- a/ps5-react/dependencies.lock.json +++ b/ps5-react/dependencies.lock.json @@ -55,7 +55,17 @@ "ps5OpenGL": { "version": "1.0.0", "sha256": "f93643c04c843d56143b00951df1f8042ea7706ae9f19e4e9abf158e1ead77c5", - "manifestSha256": "f4b91f672be037fbac3f82494f1225deaf4c227a03f37ac3ffa56abb213b943f" + "manifestSha256": "f4b91f672be037fbac3f82494f1225deaf4c227a03f37ac3ffa56abb213b943f", + "mirrors": [ + { + "url": "https://github.com/MyLuxy/overdrive-deps/releases/download/ps5-opengl-v1.0.0/ps5-opengl-sdk-1.0.0.tar.gz", + "sha256": "f93643c04c843d56143b00951df1f8042ea7706ae9f19e4e9abf158e1ead77c5" + }, + { + "url": "https://github.com/cobanov/PS5LM/releases/download/deps-ps5-opengl-1.0.0/ps5-opengl-sdk-1.0.0.tar.gz", + "sha256": "a3ce70a1d3278a2367c3135f034d4925dc85cc4bda30916f44aa474d37241053" + } + ] }, "sdk": { "version": "0.42", diff --git a/ps5-react/docs/DEPENDENCIES.md b/ps5-react/docs/DEPENDENCIES.md index 3d098e6..e622ea0 100644 --- a/ps5-react/docs/DEPENDENCIES.md +++ b/ps5-react/docs/DEPENDENCIES.md @@ -12,7 +12,7 @@ No proprietary Sony modules are redistributed. | PacBrew archive ports | Same hash-pinned v0.40.2 distribution: libarchive 3.7.4 (BSD), liblzma 5.4.6 (primarily public domain), bzip2 (bzip2 license), plus zlib/zstd/OpenSSL. Exact notice sources and hashes are in `licenses/archives-SOURCES.json`. | | PacBrew networking ports | Optional SHA-256-pinned v0.40.2 archive: curl 8.18.0 (curl license), OpenSSL 3.5.2 (Apache-2.0), zlib, zstd and libpsl; individual notices and source links follow the pinned ProsperoStore `third_party/PACBREW_LICENSES.json`. Native-title `compat.c` and `netdb.c` reuse that pinned reference under GPL-3.0-or-later. | | Lapy helper | Optional exact-title one-shot helper: mpereiraesaa/PS5-Lapy-JB-Daemon `54a095c0f19161825e845daa760a03b446e654fa`, MIT; protocol LGPL-2.1-or-later, ps5log GPL-3.0-or-later. The helper uses the app SDK v0.42 with `patches/lapy-sdk-042-attributes.patch`; source revision and ps5log hash are pinned in the lock file. | -| ps5-opengl | SDK 1.0.0; GPL and per-component/Mesa licenses. The release includes sources and notices. | +| ps5-opengl | SDK 1.0.0; GPL and per-component/Mesa licenses. The release includes sources and notices. Downloaded from the mirrors in `ps5OpenGL.mirrors`, in turn: the first went away in November 2026, and the next is the same SDK packed again, so its archive hash differs while `manifestSha256` and the per-file check prove the SDK identical. | | React / QuickJS-ng | React 18.3.1, reconciler 0.29.2, QuickJS-ng 0.15.0; MIT. npm lockfile and QuickJS commit are pinned. | | Tailwind CSS palette and scales | v3.4.17 default colors and theme values copied into `tools/tailwind/`; MIT. Build-time data only; the `tailwindcss` package is not installed or used. | | stb_image | v2.30 single-file JPEG/PNG decoder for remote images; public domain or MIT (choice). Fetched from a pinned commit URL and verified by SHA-256 (`stbImage` in the lock file); builds copy its license to `notices/stb_image-LICENSE`. | diff --git a/ps5-react/tools/build_ps5.py b/ps5-react/tools/build_ps5.py index 8e798ca..2ca4c6a 100644 --- a/ps5-react/tools/build_ps5.py +++ b/ps5-react/tools/build_ps5.py @@ -65,9 +65,24 @@ def main(): PS5_CLANG=str(llvm / "clang"), PS5_PAYLOAD_SDK=str(sdk), USE_CCACHE="0") archive = args.opengl_archive or DEPS / "ps5-opengl-sdk-1.0.0.tar.gz" - # A copy of the upstream release (blackbearreloaded left GitHub); GL_HASH checks it is the same file. - fetch("https://github.com/MyLuxy/overdrive-deps/releases/download/ps5-opengl-v1.0.0/ps5-opengl-sdk-1.0.0.tar.gz", - archive, GL_HASH) + # Copies of the upstream release (blackbearreloaded left GitHub), tried in turn (dependencies.lock.json). The + # first, GL_HASH's file, went away in November 2026. A later one may be the same SDK packed again, with an archive + # hash of its own: GL_MANIFEST below pins the SDK's manifest and every file is checked against it. + mirrors = [(mirror["url"], mirror["sha256"]) for mirror in LOCK["ps5OpenGL"]["mirrors"]] + if archive.exists(): + if digest(archive) not in {expected for _, expected in mirrors}: + raise RuntimeError(f"Checksum mismatch: {archive}") + else: + for index, (url, expected) in enumerate(mirrors): + try: + fetch(url, archive, expected) + break + except (OSError, RuntimeError) as error: + print(f"{url}: {error}", flush=True) + archive.with_suffix(archive.suffix + ".download").unlink(missing_ok=True) + if index == len(mirrors) - 1: + raise + gl_archive_hash = digest(archive) # for the build record: `archive` names a zip file further on gl = DEPS / "ps5-opengl-sdk-1.0.0/sdk" if not gl.exists(): with tarfile.open(archive) as package: @@ -382,7 +397,7 @@ def main(): "network_libraries": {p.name: digest(p) for p in network_libs}, "sdk_libraries": {p.name: digest(p) for p in sorted(libs.iterdir()) if p.suffix in (".a", ".so")}, "ui_reference": HUI_REV, "embedded_react": ER_REV, - "opengl_version": "1.0.0", "opengl_archive_sha256": GL_HASH, + "opengl_version": "1.0.0", "opengl_archive_sha256": gl_archive_hash, "compiler_builtins_package_sha256": BUILTINS_HASH, "sources": {**{str(p.relative_to(ROOT)): digest(p) for base in (ROOT / "native", ROOT / "runtime", ROOT / "tools", ROOT / "patches") for p in sorted(base.rglob("*")) if p.is_file() and "__pycache__" not in str(p)}, From 32f4bdda025bab173ed2b6ed4ac3cd1be8c5b505 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:56:28 +0000 Subject: [PATCH 2/5] Replace a lineupd left running by another build lineupd keeps running after Lineup closes, and every build of one version answers /version alike, so a new test build kept using the service an earlier one had started. /version now also answers a build: local, or the commit CI writes into app/api.js and lineupd.c before it builds. The app replaces a service whose version or build differs. Release builds stay local, where the version already tells them apart. --- .github/workflows/ci.yml | 7 +++++++ app/api.js | 9 ++++++--- app/payloads/lineupd.c | 8 ++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b371ef..4184f80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,13 @@ jobs: with: path: ps5-react/.deps key: deps-${{ hashFiles('ps5-react/dependencies.lock.json') }} + - name: Stamp the build + # The app replaces a lineupd of another build; test builds share a version, so the commit tells them apart. + run: | + B="$(git rev-parse --short HEAD)" + sed -i "s/^export const BUILD = 'local';/export const BUILD = '$B';/" app/api.js + sed -i "s/^#define LINEUPD_BUILD \"local\"/#define LINEUPD_BUILD \"$B\"/" app/payloads/lineupd.c + grep -q "BUILD = '$B'" app/api.js && grep -q "LINEUPD_BUILD \"$B\"" app/payloads/lineupd.c - run: scripts/test.sh - run: scripts/build.sh - name: Package diff --git a/app/api.js b/app/api.js index 613aeee..cf274cd 100644 --- a/app/api.js +++ b/app/api.js @@ -4,6 +4,9 @@ import {Archives, Downloads, FileSystem, Http, Payloads} from '@ps5-react/core'; import {T} from './i18n.js'; export const VERSION = '1.8.0'; +// "local", or the commit of a CI build, as LINEUPD_BUILD in lineupd.c: a service of another build is replaced. +export const BUILD = 'local'; +const SERVICE = `${VERSION}+${BUILD}`; const BASE = 'http://127.0.0.1:9077'; /** An error the app shows as is: a short title and what to do about it. */ @@ -55,7 +58,7 @@ async function call(path, {method = 'GET', body, timeoutMs} = {}) { async function version() { try { const {ok, data} = await call('/version', {timeoutMs: 1500}); - return ok ? data.version ?? '' : ''; + return ok ? `${data.version ?? ''}+${data.build ?? ''}` : ''; } catch { return null; } @@ -64,7 +67,7 @@ async function version() { /** Makes sure this app's version of the service answers, starting (or replacing) it when needed. */ export async function ensureService(onStep) { let running = await version(); - if (running === VERSION) return; + if (running === SERVICE) return; if (running !== null) { onStep(T.service.replacing); await call('/quit', {method: 'POST', timeoutMs: 1500}).catch(() => {}); @@ -77,7 +80,7 @@ export async function ensureService(onStep) { for (let waited = 0; waited < 5000; waited += 150) { await sleep(150); running = await version(); - if (running === VERSION) return; + if (running === SERVICE) return; } throw problem(T.service.noAnswer); } diff --git a/app/payloads/lineupd.c b/app/payloads/lineupd.c index cfefcc2..f02fdb9 100644 --- a/app/payloads/lineupd.c +++ b/app/payloads/lineupd.c @@ -7,7 +7,7 @@ // screen keeps in memory. Writes go through SQLite in place, inside one transaction, after a backup. // // Endpoints (JSON unless noted), on 127.0.0.1:9077 (0.0.0.0 when built with -DLINEUPD_LAN): -// GET /version {"version"} +// GET /version {"version","build"} // GET /profiles {"profiles":[{"userId","hex","name","avatar"}]} // GET /row?user=<10 dig>[&tab=games|media] {"user","tab","pins":"on"|"missing"|"off","games":[{"titleId", // "name","index","icon","pinned":slot|null}]}; games is the Games tab @@ -66,6 +66,10 @@ #endif #define LINEUPD_VERSION "1.8.0" +// Which build this is: "local", or the commit for a CI build (.github/workflows/ci.yml writes it, with BUILD in +// app/api.js). Builds of one version answer /version alike, so without it the app kept talking to a lineupd left +// running by an earlier test build. +#define LINEUPD_BUILD "local" #define TITLE_ID "PPSA99077" #ifdef LINEUPD_DIAG // Phase 0 diagnostic build (docs/pt-BR/PLANO-1.2.md): its own port, open to the LAN, so it runs beside the @@ -2261,7 +2265,7 @@ static int handle(int socket) { if (get && !strcmp(target, "/version")) { Buffer reply = {0}; - puts_(&reply, "{\"version\":\"" LINEUPD_VERSION "\"}"); + puts_(&reply, "{\"version\":\"" LINEUPD_VERSION "\",\"build\":\"" LINEUPD_BUILD "\"}"); respond_json(socket, 200, &reply); free(reply.data); } else if (get && !strcmp(target, "/profiles")) { From deb392773ec00bc3d9ffd7c64b1eb49ff93010c4 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:56:45 +0000 Subject: [PATCH 3/5] Manage system tiles, and move apps' concepts with them System tiles keep their tab as bits of dispLocation (0x02 Games, 0x04 Media, and the same plus 2048 on tbl_concepticoninfo_), as Art Swap and PSGFX found. The Media Gallery now moves between the tabs: move_system sets one bit and clears the other in all six places the console keeps it, in both databases, and takes it out of MyHome on Media. It is on the row of its tab bit (TAB_OF), so it orders, pins and moves from the row like an app. lineup_system and lineup_move_sys_* triggers write the bits again when the console resets them at a restart or out of rest mode, and restore_system fixes the JSON at start. The system hides its own tiles with bit 0x01 and visible = 0: Welcome can now be shown on every profile, and hidden again as the system had it. Any other system tile that is showing can hide. Game Library, App Library and Welcome refuse to hide, and only the Gallery moves. move_app now also writes an app's own concept (tbl_concepticoninfo_, tbl_conceptmetadata and its ConceptInfoJson, appinfo.db's tbl_conceptinfo), which 1.8.0 left on the old tab, with lineup_move_k* triggers; apps moved before are written once at start. More options can remove TabShifter's TBSH00001 tile from both databases and its folders. The first start keeps original-app.db and original-appinfo.db for good; backups in the same second no longer overwrite each other, and rotation keeps app.db and appinfo.db copies in pairs. New triggers and writes are made only for columns the firmware has, and /pins/uninstall removes them all. --- CHANGELOG.md | 16 + app/api.js | 21 +- app/i18n.js | 24 + app/index.jsx | 33 +- app/payloads/lineupd.c | 1400 ++++++++++++++++++++++++++++++++++++++-- docs/ARCHITECTURE.md | 46 +- 6 files changed, 1485 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52db2f5..a37a16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +- The Media Gallery moves between the Games and Media tabs (focus it on the row, Options, Move), and stays there + after a restart or rest mode. The PS Store only hides. Game Library and App Library never move or hide. +- The Media Gallery is on its tab's row: it can be pinned and moved from the row like an app. +- Welcome can be shown, on every profile, and hidden again (Options, Hidden apps). +- Every other system tile that is showing can be hidden, and stays hidden. +- Moving an app now moves its concept too, which 1.8.0 left on the old tab. Apps moved before are fixed when + Lineup starts. +- More options has Remove TabShifter leftovers: TabShifter's "TS" tile goes from both databases. +- Backups: the first start keeps both databases for good in /data/lineup/backups (original-app.db, + original-appinfo.db), and two backups in the same second no longer overwrite each other. +- Triggers and writes are made only for the columns the firmware has. +- The build downloads the OpenGL SDK from a second mirror when the first is gone. +- A lineupd left running by another build is replaced when Lineup opens: /version answers a build too. + ## 1.8.0 - A moved app stays on its new tab when it is installed again. Game Compressor's payload installs its shortcut diff --git a/app/api.js b/app/api.js index cf274cd..981f19b 100644 --- a/app/api.js +++ b/app/api.js @@ -138,8 +138,25 @@ export async function unpinAll() { } } -/** What Lineup hid on `user`, as [{titleId, name}]. */ -export const hidden = async user => (await read(`/hidden?user=${user}`, T.service.hidden)).hidden ?? []; +/** + * What Lineup hid on `user`, as hidden: [{titleId, name}], and the tab of each system tile that can move, as + * system: [{titleId, tab}] where tab is 'games', 'media' or 'none'. + */ +export async function hidden(user) { + const data = await read(`/hidden?user=${user}`, T.service.hidden); + return {hidden: data.hidden ?? [], system: data.system ?? []}; +} + +/** Removes TabShifter's tile from the home screen databases. Resolves to {removed} or {error}. */ +export async function removeTabShifter() { + try { + const reply = await call('/tabshifter/remove', {method: 'POST', timeoutMs: 15000}); + return reply.ok ? {removed: reply.data.removed ?? 0} + : {error: T.cleanup.failed(reply.data.error ?? T.service.status(reply.status))}; + } catch { + return {error: T.cleanup.failed(T.pins.noAnswer)}; + } +} /** Hides, shows or moves an app: `action` is 'hide', 'show', 'games' or 'media'. Resolves to an error message or ''. */ export async function changeApp(user, title, action) { diff --git a/app/i18n.js b/app/i18n.js index 2b1e314..f70e5f3 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -71,6 +71,8 @@ const en = { moveTo: tab => `Move to ${tab}`, hide: 'Hide from the home screen', store: 'PS Store', storeHidden: 'Hidden', storeShown: 'Showing', gallery: 'Media Gallery', galleryHidden: 'Hidden', galleryShown: 'Showing', + welcome: 'Welcome', welcomeShown: 'Showing', welcomeHidden: 'Hidden', + systemLabel: 'Other system tiles', hideTile: 'Hide', show: 'Bring back', none: 'Nothing else is hidden. To hide an app, focus it on the row and open Options.', hideTitle: name => `Hide ${name}?`, @@ -86,6 +88,16 @@ const en = { }, failed: detail => `Nothing changed (${detail}).`, }, + cleanup: { + item: 'Remove TabShifter leftovers', + title: "Remove TabShifter's tile?", + body: 'Lineup backs up both home screen databases first, then takes TabShifter\'s "TS" tile out of them and ' + + 'deletes its folders. Do this only after deleting TabShifter.', + confirm: 'Remove', cancel: 'Cancel', + done: removed => (removed ? "TabShifter's tile was removed. The home screen updates when Lineup closes." + : 'No TabShifter tile was found.'), + failed: detail => `Nothing was removed (${detail}).`, + }, report: { save: 'Save a bug report', title: 'Report saved', @@ -222,6 +234,8 @@ const pt = { moveTo: tab => `Mover para ${tab}`, hide: 'Esconder da tela inicial', store: 'PS Store', storeHidden: 'Escondida', storeShown: 'Aparecendo', gallery: 'Galeria de mídia', galleryHidden: 'Escondida', galleryShown: 'Aparecendo', + welcome: 'Boas-vindas', welcomeShown: 'Aparecendo', welcomeHidden: 'Escondida', + systemLabel: 'Outros blocos do sistema', hideTile: 'Esconder', show: 'Trazer de volta', none: 'Nada mais está escondido. Para esconder um app, deixe ele em foco na fileira e abra Opções.', hideTitle: name => `Esconder ${name}?`, @@ -237,6 +251,16 @@ const pt = { }, failed: detail => `Nada mudou (${detail}).`, }, + cleanup: { + item: 'Remover restos do TabShifter', + title: 'Remover o bloco do TabShifter?', + body: 'O Lineup faz backup dos dois bancos da tela inicial antes, tira deles o bloco "TS" do TabShifter e apaga ' + + 'as pastas dele. Faça isso só depois de apagar o TabShifter.', + confirm: 'Remover', cancel: 'Cancelar', + done: removed => (removed ? 'O bloco do TabShifter foi removido. A tela inicial atualiza quando o Lineup fechar.' + : 'Nenhum bloco do TabShifter foi encontrado.'), + failed: detail => `Nada foi removido (${detail}).`, + }, report: { save: 'Salvar relatório de bug', title: 'Relatório salvo', diff --git a/app/index.jsx b/app/index.jsx index 2f635af..3aa9712 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -274,6 +274,7 @@ const PICK_HINTS = [['cross', H.choose], ['circle', H.back]]; const STORE = 'NPXS40047'; const GALLERY = 'NPXS40075'; +const WELCOME = 'NPXS40063'; // The side sheet's pages: title and subtitle. The menu and Updates work out their own subtitle. const SHEETS = {menu: [T.menu.title], profiles: [T.profilesTitle, T.profilesSubtitle], updates: [T.updates.title], @@ -542,7 +543,7 @@ function App() { light(false); patch({lifted: false, layer: 'menu'}); // For the count beside Hidden apps; the page reads it again. - api.hidden(ref.current.user).then(hidden => patch({hidden}), () => {}); + api.hidden(ref.current.user).then(patch, () => {}); return true; } if (action === 'l1' || action === 'r1') { @@ -600,7 +601,7 @@ function App() { const {user} = ref.current; patch({layer: 'busy'}); try { - patch({hidden: await api.hidden(user), layer: 'hidden'}); + patch({...await api.hidden(user), layer: 'hidden'}); } catch (error) { close(); toast(error.detail ?? error.message); @@ -618,7 +619,7 @@ function App() { patch({layer: 'busy'}); const error = await api.changeApp(p.user, title, action); try { - if (keep.layer) patch({hidden: await api.hidden(p.user)}); + if (keep.layer) patch(await api.hidden(p.user)); await loadRow(p.user, p.profiles, p.tab, keep).catch(() => loadRow(p.user, p.profiles, 'games', keep)); } catch (problem) { patch({phase: 'error', error: problem}); @@ -626,6 +627,12 @@ function App() { } toast(error || T.apps.done[action](name)); }; + const removeTabShifter = async () => { + patch({layer: 'busy'}); + const {removed, error} = await api.removeTabShifter(); + close(); + toast(error || T.cleanup.done(removed)); + }; const askApp = layer => { if (changed(ref.current)) toast(T.tabs.saveFirst); else patch({layer}); @@ -653,6 +660,9 @@ function App() { const galleryHidden = hiddenApps.some(app => app.titleId === GALLERY); const otherHidden = hiddenApps.filter(app => app.titleId !== STORE && app.titleId !== GALLERY); const otherTab = s.tab === 'games' ? 'media' : 'games'; + // Welcome shows on every profile; the other system tiles showing can hide on this one. + const welcome = (s.system ?? []).find(tile => tile.titleId === WELCOME); + const otherSystem = (s.system ?? []).filter(tile => tile.visible && ![STORE, GALLERY, WELCOME].includes(tile.titleId)); if (s.phase === 'loading') { return ( @@ -724,6 +734,16 @@ function App() { onPress={() => changeApp(STORE, storeHidden ? 'show' : 'hide', T.apps.store)} />, changeApp(GALLERY, galleryHidden ? 'show' : 'hide', T.apps.gallery)} />, + welcome && (welcome.shown || welcome.tab !== 'none') ? ( + changeApp(WELCOME, welcome.shown ? 'hide' : 'show', T.apps.welcome)} /> + ) : null, + otherSystem.length ? {T.apps.systemLabel} : null, + ...otherSystem.map(tile => ( + changeApp(tile.titleId, 'hide', tile.name)} /> + )), , ...otherHidden.map(app => ( patch({layer: 'updates'})} />, , patch({layer: 'removePins'})} />, + patch({layer: 'removeTabShifter'})} />, patch({layer: 'star'})} />, ] : s.layer === 'star' ? [ // The repo's address as a code the phone's camera reads: the audience is at the TV, with no mouse. @@ -816,6 +837,12 @@ function App() {