32 Commits
Author SHA1 Message Date
MyLuxy 9f90d88277 fix: a solid lock on the Adventure chest 2026-10-20 22:22:15 +02:00
Elija 89e9b188cd fix: backport folders beside the game are not taken for it (#85) 2026-10-20 22:02:24 +02:00
MyLuxy b67337b094 fix: shorter Top rated tab in European Portuguese 2026-10-20 20:50:39 +02:00
MyLuxy f1ec9c94d5 feat: European Portuguese in the language list 2026-10-20 19:57:48 +02:00
Filipe Gomes 8630f9fec2 feat: European Portuguese translation (#81) 2026-10-20 19:56:32 +02:00
MyLuxy a5dfc5bd4a fix: the intro's logo no longer freezes while the store loads 2026-10-20 19:52:26 +02:00
MyLuxy 9170f56597 fix: Browse games on an empty Downloads opens Discover 2026-10-20 19:45:22 +02:00
Hemil Shah 5b4c89377d fix: the empty downloads button opens Browse (#80) 2026-10-20 18:47:01 +02:00
MyLuxyandHenianbin 4d8388d38b feat: Traditional Chinese, with its own font
Co-authored-by: Henianbin <154463651+Henianbin@users.noreply.github.com>
2026-10-20 18:39:26 +02:00
Hemil Shah cb4e695706 feat: game page button on download rows (#78) 2026-10-20 18:23:01 +02:00
MyLuxy 98d81a299f fix: dlproxy downloads stuck at 0 MB/s start over by themselves 2026-10-20 17:57:49 +02:00
MyLuxy fcf210b765 feat: pixel-art genre cards instead of covers, Sports joins Sport 2026-10-20 17:18:11 +02:00
MyLuxy b69f7e24c3 fix: header checks run off the render thread, the UI no longer freezes during fast downloads 2026-10-20 16:44:39 +02:00
MyLuxy ff1c95197c fix: store front sections skip empty ones when keeping images 2026-10-20 16:35:20 +02:00
MyLuxy 16ef0575b6 fix: a full image registry makes room, covers retry instead of showing titles 2026-10-20 02:20:10 +02:00
MyLuxy 7e905b98e1 fix: store front keeps only nearby images in use, image registry holds 512 2026-10-20 02:03:16 +02:00
MyLuxy 07d0ebf1a4 fix: warm the homebrew icons with the covers 2026-10-20 01:25:33 +02:00
MyLuxy 684f31545b fix: Homebrew page keeps its place, spinner while an app loads, emulators filter 2026-10-20 01:00:59 +02:00
MyLuxy ef1d389301 fix: covers stop thrashing the image cache on the store front 2026-10-20 00:46:09 +02:00
MyLuxy 9673ad66b8 feat: v1.0.8 2026-10-20 00:05:36 +02:00
MyLuxy afca004822 fix: shorten the kstuff credit 2026-10-19 23:58:23 +02:00
MyLuxy cdabb1bfb7 feat: credit kstuff in the README 2026-10-19 23:56:44 +02:00
MyLuxy e284d41b3f fix: fetch ps5-homebrew-ui and the ps5-opengl SDK from overdrive-deps 2026-10-19 23:54:50 +02:00
MyLuxy adde740a4f feat: extract 7z archives too large for the app's heap in rar-extract 2026-10-19 23:21:47 +02:00
Elija 0e7112e56f Fix: Accept larger encoded 7z headers, show extracted file count (#68) 2026-10-19 22:09:31 +02:00
MyLuxy e8001492bf feat: add a Homebrew section from prosperostore.net 2026-10-19 22:02:06 +02:00
MyLuxy ed9798b7d7 fix: list all 16 languages in the README 2026-10-19 20:07:08 +02:00
MyLuxy dff3d99855 feat: credit MyLuxy as current maintainer 2026-10-19 19:47:10 +02:00
MyLuxy 73bce2f73f fix: drop the home background copy at launch, it never shows on update 2026-10-19 17:57:16 +02:00
lunar-me eed910f3df docs: Add the missing preposition in the lazy-loading note in IMAGES.md (#67) 2026-10-19 17:36:57 +02:00
lunar-me 53e93e95af docs: Agree the verb after neither-nor in STORAGE.md (#66) 2026-10-19 17:36:49 +02:00
lunar-me a885231b7e docs: Agree the verb with the nearer subject in NETWORKING.md (#65) 2026-10-19 17:36:41 +02:00
114 changed files with 4058 additions and 589 deletions

No files matched your search

+6 -3
View File
@@ -43,11 +43,12 @@
## Features
- **1,800+ games** to discover, with **Top rated**, **New releases** and a featured spotlight
- **1,800+ games** to discover, with **Top rated**, a hand-picked row and a featured spotlight
- **Homebrew**: native PS5 apps and games from the community catalog at [prosperostore.net](https://prosperostore.net/), always up to date, installed from each developer's GitHub release
- **Browse the whole library** with filters, sorting, scores and download sizes at a glance
- **Downloads straight to the console**, to internal storage or any USB drive, with live speed and storage stats
- **Favorites**, **search** with an on-screen keyboard, and a **Files** page to see and clean up what's on your drives
- **13 languages**: English, Deutsch, Español, Français, Português (Brasil), Русский, Bahasa Indonesia, 日本語, 简体中文, العربية, اردو, हिन्दी, বাংলা
- **18 languages**: English, Deutsch, Español, Français, Italiano, Nederlands, Português (Brasil), Português (Portugal), Русский, Bahasa Indonesia, 日本語, 简体中文, 繁體中文, العربية, فارسی, اردو, हिन्दी, বাংলা
- **Updates itself**: a new version installs from the console, no PC needed
- Made for the controller: <kbd>L1</kbd> <kbd>R1</kbd> to switch sections, <kbd>✕</kbd> to select, <kbd>○</kbd> to go back
@@ -320,9 +321,11 @@ Nothing else in the framework is changed.
## Credits
- **[MyLuxy](https://github.com/MyLuxy)**, who decompiled Overdrive, recovered its source and rebuilt it.
- **[MyLuxy](https://github.com/MyLuxy)**, current maintainer, who decompiled Overdrive, recovered its source and rebuilt it.
- **[ps5-react](https://github.com/half144/ps5-react)**, the framework it runs on.
- **[ShadowMount+](https://github.com/drakmor/ShadowMountPlus)** by drakmor, which installs it.
- **[kstuff](https://github.com/EchoStretch/kstuff-lite)** by EchoStretch, the kernel payload that lets the console run homebrew and the games it installs.
- **[PS5 Homebrew Catalog](https://github.com/darkxex/ps5-homebrew-catalog)** by darkxex, the store API behind the Homebrew section, and every developer listed in it.
`ps5-react/` is GPL-3.0-or-later and carries its own `LICENSE`, `LICENSE-ATTRIBUTION` and
`NOTICE`. Every dependency notice is copied into `notices/` inside the built app, and
+5 -2
View File
@@ -43,11 +43,12 @@
## Возможности
- **Более 1800 игр** для поиска, с подборками **«Лучшие по оценкам»**, **«Новые релизы»** и отдельной рекомендованной игрой
- **Более 1800 игр** для поиска, с подборкой **«Лучшие по оценкам»**, рядом избранных игр и отдельной рекомендованной игрой
- **Хоумбрю**: нативные приложения и игры для PS5 из каталога сообщества [prosperostore.net](https://prosperostore.net/), всегда актуальные, устанавливаются из релизов разработчиков на GitHub
- **Просмотр всей библиотеки** с фильтрами, сортировкой, оценками и размерами загрузок — всё видно сразу
- **Загрузка прямо на консоль** — во внутренний накопитель или на любой USB-накопитель, со скоростью и занятым местом в реальном времени
- **Избранное**, **поиск** с экранной клавиатурой и страница **«Файлы»**, где видно, что лежит на накопителях, и можно это почистить
- **13 языков**: английский, немецкий, испанский, французский, португальский (Бразилия), русский, индонезийский, японский, китайский (упрощённый), арабский, урду, хинди, бенгальский
- **16 языков**: английский, немецкий, испанский, французский, итальянский, нидерландский, португальский (Бразилия), русский, индонезийский, японский, китайский (упрощённый), арабский, персидский, урду, хинди, бенгальский
- **Обновляется сам**: новая версия устанавливается прямо с консоли, без компьютера
- Сделано под геймпад: <kbd>L1</kbd> <kbd>R1</kbd> — переключение разделов, <kbd>✕</kbd> — выбрать, <kbd>○</kbd> — назад
@@ -282,6 +283,8 @@ Linux:
- **[MyLuxy](https://github.com/MyLuxy)** — декомпилировал Overdrive, восстановил его исходный код и собрал заново.
- **[ps5-react](https://github.com/half144/ps5-react)** — фреймворк, на котором всё работает.
- **[ShadowMount+](https://github.com/drakmor/ShadowMountPlus)** от drakmor — устанавливает приложение.
- **[kstuff](https://github.com/EchoStretch/kstuff-lite)** от EchoStretch — пейлоад ядра, благодаря которому консоль запускает хоумбрю и установленные игры.
- **[PS5 Homebrew Catalog](https://github.com/darkxex/ps5-homebrew-catalog)** от darkxex — API магазина для раздела «Хоумбрю», и все разработчики, которые в нём есть.
`ps5-react/` распространяется под GPL-3.0-or-later и содержит собственные `LICENSE`,
`LICENSE-ATTRIBUTION` и `NOTICE`. Все уведомления о зависимостях копируются в `notices/` внутри
+1 -1
View File
@@ -7,7 +7,7 @@
"render": { "width": 1920, "height": 1080 },
"surface": { "width": 1920, "height": 1080 },
"refreshRate": 60,
"textFonts": ["chinese", "japanese", "bengali", "devanagari", "arabic"],
"textFonts": ["chinese", "chinese-traditional", "japanese", "bengali", "devanagari", "arabic"],
"resources": ["data/releases", "data/pages", "i18n/locales"],
"filesystemAccess": "console",
"networking": true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

+12 -9
View File
@@ -11,6 +11,7 @@ import { ContinueRow } from './ContinueRow.jsx';
import { FilterDrawer } from './FilterDrawer.jsx';
import { GenreRail } from './GenreRail.jsx';
import { PageHints } from './PageHints.jsx';
import { FocusedSection, NearSection } from './near.js';
import { PopularRail } from './PopularRail.jsx';
import { RecentRow } from './RecentRow.jsx';
import { Spotlight } from './Spotlight.jsx';
@@ -46,17 +47,19 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
var Sections = (0, memo)(function Sections2({ onOpenFilters }) {
const onAction = useFavoriteAction();
return /* @__PURE__ */ (0, jsx)(FocusScope, { trap: true, onAction, children: /* @__PURE__ */ (0, jsxs)(ScrollView, { style: { flex: 1, gap: 60, paddingHorizontal: 48 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, style: { gap: 24, paddingTop: 36 }, children: [
/* @__PURE__ */ (0, jsx)(FocusedSection, {}),
// Each section keeps its images in use only while focus is in it or the next one (near.js).
/* @__PURE__ */ (0, jsx)(NearSection, { index: 0, children: /* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, style: { gap: 24, paddingTop: 36 }, children: [
/* @__PURE__ */ (0, jsx)(FolderInstallNotice, {}),
/* @__PURE__ */ (0, jsx)(Spotlight, {})
] }),
/* @__PURE__ */ (0, jsx)(PopularRail, {}),
/* @__PURE__ */ (0, jsx)(CdnRail, {}),
/* @__PURE__ */ (0, jsx)(ContinueRow, {}),
/* @__PURE__ */ (0, jsx)(RecentRow, {}),
/* @__PURE__ */ (0, jsx)(TopCharts, {}),
/* @__PURE__ */ (0, jsx)(GenreRail, {}),
/* @__PURE__ */ (0, jsx)(Catalog, { onOpenFilters }),
] }) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 1, children: /* @__PURE__ */ (0, jsx)(PopularRail, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 2, children: /* @__PURE__ */ (0, jsx)(CdnRail, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 3, children: /* @__PURE__ */ (0, jsx)(ContinueRow, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 4, children: /* @__PURE__ */ (0, jsx)(RecentRow, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 5, children: /* @__PURE__ */ (0, jsx)(TopCharts, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 6, children: /* @__PURE__ */ (0, jsx)(GenreRail, {}) }),
/* @__PURE__ */ (0, jsx)(NearSection, { index: 7, children: /* @__PURE__ */ (0, jsx)(Catalog, { onOpenFilters }) }),
/* @__PURE__ */ (0, jsx)(Footer, {})
] }) });
});
+7 -4
View File
@@ -6,6 +6,7 @@ import cdnCard from '../assets/cdn-card.png';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { memo } from 'react';
import { GameRail } from './GameRail.jsx';
import { RowCard, useRowWindow, useSectionShown } from './near.js';
import { games } from '../data/catalog.js';
import { useT } from '../i18n/index.js';
import { CDN_HOST, NO_FILTERS, onCdn, sortGames } from '../state/catalog.js';
@@ -45,6 +46,8 @@ import { Image, Text, View, motion, useIsFocused } from '@ps5-react/core';
var CdnRail = (0, memo)(function CdnRail2() {
const { openGame, focusLater } = useNavActions();
const { setFilters, setQuery } = useStoreActions();
const row = useRowWindow();
useSectionShown(SERVED.length > 0);
if (!SERVED.length) return null;
const browse = () => {
setFilters({ ...NO_FILTERS, provider: [CDN_HOST] });
@@ -55,7 +58,7 @@ import { Image, Text, View, motion, useIsFocused } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(View, { focusKey: "cdn:card", onPress: browse, children: /* @__PURE__ */ (0, jsx)(CollectionCard, {}) }),
SERVED.map((game, index) => {
const key = `cdn:${game.id}`;
return /* @__PURE__ */ (0, jsx)(
return /* @__PURE__ */ (0, jsx)(RowCard, { far: row.far(index), children: /* @__PURE__ */ (0, jsx)(
GameCard,
{
game,
@@ -63,10 +66,10 @@ import { Image, Text, View, motion, useIsFocused } from '@ps5-react/core';
detailed: true,
focusKey: key,
rowEnd: index === SERVED.length - 1,
onFocus: () => row.reached(index),
onPress: () => openGame(game.id, key)
},
game.id
);
}
) }, game.id);
})
] });
});
+2
View File
@@ -5,6 +5,7 @@ import '../assets/Inter-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { memo } from 'react';
import { GameRail } from './GameRail.jsx';
import { useSectionShown } from './near.js';
import { WAITING, shownState, stateLabel } from '../downloads/labels.js';
import { fractionOf, useJobs, useProgress } from '../downloads/useQueue.js';
import { formatPercent2 } from '../i18n/core.js';
@@ -38,6 +39,7 @@ import { Text, View, useIsFocused } from '@ps5-react/core';
const { jobs } = useJobs();
const { push, openGame } = useNavActions();
const entries2 = jobs.filter((job2) => UNFINISHED.includes(job2.state)).map((job2) => ({ job: job2, game: getGame(job2.gameId) })).filter((entry2) => entry2.game);
useSectionShown(entries2.length > 0);
if (!entries2.length) return null;
return /* @__PURE__ */ (0, jsx)(
GameRail,
+6 -1
View File
@@ -20,6 +20,11 @@ import { FocusScope, ScrollView, Text, View, useIsFocused } from '@ps5-react/cor
// the end looked for the nearest element across the whole page and could land at its top.
var Arrow = ({ name }) => /* @__PURE__ */ (0, jsx)(View, { style: { width: 48, height: 48, alignItems: "center", justifyContent: "center", borderRadius: 14999, backgroundColor: "#17171a" }, children: /* @__PURE__ */ (0, jsx)(Icon, { name, size: 16, color: colors.muted, fixed: true }) });
var LTR = { direction: "ltr" };
// A long row loads the art of its first cards (about a screen and a half of them) and, as focus moves
// along it, of the cards up to ART_AHEAD past the furthest one reached: every image in use stays in the
// decoded cache, and forty covers per row pushed the rest of the app's images out of it.
var FIRST_ART = 10;
var ART_AHEAD = 8;
function GameRail({ title, onSeeAll, seeAllKey, children }) {
return /* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { marginBottom: 24, flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [
@@ -33,4 +38,4 @@ import { FocusScope, ScrollView, Text, View, useIsFocused } from '@ps5-react/cor
] });
}
export { GameRail };
export { ART_AHEAD, FIRST_ART, GameRail };
+8 -4
View File
@@ -4,6 +4,7 @@
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { memo } from 'react';
import { GENRE_ART } from './genre-art.js';
import { useT } from '../i18n/index.js';
import { genreLabel } from '../state/catalog.js';
import { Cover } from '../ui/Cover.jsx';
@@ -12,13 +13,15 @@ import { Icon } from '../ui/Icon.jsx';
import { Shine } from '../ui/Shine.jsx';
import { colors } from '../ui/colors.js';
import { EASE_OUT } from '../ui/ease.js';
import { Text, View, motion, useIsFocused } from '@ps5-react/core';
import { Image, Text, View, motion, useIsFocused } from '@ps5-react/core';
var spread = { type: "tween", duration: 0.3, ease: EASE_OUT };
var cover = { width: 126, height: 168, borderRadius: 12 };
var side = { width: 113, height: 151, borderRadius: 12 };
var front = { position: "absolute", left: 87, top: 24, zIndex: 10 };
var OPEN = [-12.6, 12.6];
// The sprite is drawn at the size it was baked at, centred in the space the covers take.
var art = { position: "absolute", left: 78, top: 36, width: 144, height: 144 };
function Fan({ covers }) {
const focused = useIsFocused();
return covers.slice(1).map(
@@ -35,20 +38,21 @@ import { Text, View, motion, useIsFocused } from '@ps5-react/core';
)
);
}
var GenreCard = (0, memo)(function GenreCard2({ genre, count, covers, active: active2, onToggle }) {
var GenreCard = (0, memo)(function GenreCard2({ genre, count, covers, index, active: active2, onToggle, onReach }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(
motion.View,
{
focusKey: `genre:${genre}`,
onFocus: () => onReach?.(index),
onPress: () => onToggle(genre),
selected: active2,
whileFocus: { y: -6 },
transition: spread,
style: (_state) => [{ width: 300, borderRadius: 18, backgroundColor: "#17171a", paddingHorizontal: 24, paddingBottom: 24, paddingTop: 216 }, active2 ? { backgroundColor: "#212125" } : null, _state.focused ? { backgroundColor: "#212125" } : null],
children: [
/* @__PURE__ */ (0, jsx)(Fan, { covers }),
covers[0] && /* @__PURE__ */ (0, jsx)(Cover, { game: covers[0], style: [cover, front], label: false }),
GENRE_ART[genre] ? /* @__PURE__ */ (0, jsx)(Image, { source: GENRE_ART[genre], resizeMode: "contain", style: art }) : /* @__PURE__ */ (0, jsx)(Fan, { covers }),
!GENRE_ART[genre] && covers[0] && /* @__PURE__ */ (0, jsx)(Cover, { game: covers[0], style: [cover, front], label: false }),
active2 && /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", right: 18, top: 18, zIndex: 20, width: 36, height: 36, alignItems: "center", justifyContent: "center", borderRadius: 14999, backgroundColor: "#f2f2f3" }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: "check", size: 14, strokeWidth: 3, color: colors.canvas }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 24, color: "#f2f2f3", lineHeight: 36 }, children: genreLabel(genre) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: "#8a8a94", lineHeight: 30 }, children: t2("catalog.gameCount", { count }) }),
+9 -5
View File
@@ -4,6 +4,7 @@
import { jsx, jsxs } from 'react/jsx-runtime';
import { useState } from 'react';
import { GenreCard } from './GenreCard.jsx';
import { RowCard, useRowWindow } from './near.js';
import { games } from '../data/catalog.js';
import { useT } from '../i18n/index.js';
import { genres } from '../state/catalog.js';
@@ -22,22 +23,25 @@ import { FocusScope, ScrollView, View } from '@ps5-react/core';
const { filters } = useStoreState();
const { toggleFilter } = useStoreActions();
const [toggleGenre] = (0, useState)(() => (genre) => toggleFilter("genre", genre));
// Three covers a card: the cards far from where focus rests let theirs go.
const row = useRowWindow();
return /* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, children: [
/* @__PURE__ */ (0, jsx)(SectionHeader, { title: t2("browse.popularGenres") }),
/* @__PURE__ */ (0, jsxs)(View, { style: { position: "relative", marginHorizontal: -9, marginVertical: -30 }, children: [
// A row scope, like GameRail: Left and Right stop at the rail's ends.
/* @__PURE__ */ (0, jsx)(FocusScope, { row: true, children: /* @__PURE__ */ (0, jsx)(ScrollView, { style: [{ flexDirection: "row", gap: 18, paddingHorizontal: 9, paddingVertical: 30 }, { direction: "ltr" }], children: RAIL.map(
({ genre, count, covers }) => /* @__PURE__ */ (0, jsx)(
({ genre, count, covers }, index) => /* @__PURE__ */ (0, jsx)(RowCard, { far: row.far(index), children: /* @__PURE__ */ (0, jsx)(
GenreCard,
{
genre,
count,
covers,
index,
active: filters.genre.includes(genre),
onToggle: toggleGenre
},
genre
)
onToggle: toggleGenre,
onReach: row.reached
}
) }, genre)
) }) }),
/* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, bottom: 0, right: 0, width: 96, backgroundGradient: { type: "linear", to: "right", stops: [{ color: "transparent", offset: 0 }, { color: "#000000", offset: 1 }] } } })
] })
+20 -9
View File
@@ -2,7 +2,7 @@
// reconstructed from the esbuild bundle in eboot.bin
import { jsx } from 'react/jsx-runtime';
import { memo } from 'react';
import { memo, useState } from 'react';
import { GameRail } from './GameRail.jsx';
import { games } from '../data/catalog.js';
import { useT } from '../i18n/index.js';
@@ -11,16 +11,19 @@ import { FAMOUS, INDIE, picked, shuffle } from './picks.js';
import { useNavActions } from '../state/nav.jsx';
import { useStoreActions } from '../state/store.jsx';
import { GameCard } from '../ui/GameCard.jsx';
import { ART_AHEAD, FIRST_ART } from './GameRail.jsx';
import { RowCard, useRowWindow } from './near.js';
var WIDTH4 = 152;
// "You might like" (was the most popular, which only Archive.org's few games could reach): fifteen
// "You might like" (was the most popular, which only Archive.org's few games could reach): 25
// games, new on every launch, from the hand-picked famous and indie ones and the rest of the
// catalog's games that have art and a genre, so the row always has something worth opening.
var PICKS = 25;
function selectPicks() {
const famous = picked(games, FAMOUS).slice(0, 6);
const indie = picked(games, INDIE).slice(0, 3);
const famous = picked(games, FAMOUS).slice(0, 10);
const indie = picked(games, INDIE).slice(0, 5);
const chosen = new Set([...famous, ...indie]);
const others = shuffle(games.filter((game) => !chosen.has(game) && game.art.cover && game.art.hero && game.genres.length)).slice(0, 15 - chosen.size);
const others = shuffle(games.filter((game) => !chosen.has(game) && game.art.cover && game.art.hero && game.genres.length)).slice(0, PICKS - chosen.size);
return shuffle([...famous, ...indie, ...others]);
}
var POPULAR = selectPicks();
@@ -29,6 +32,10 @@ import { GameCard } from '../ui/GameCard.jsx';
const t2 = useT();
const { openGame, focusLater } = useNavActions();
const { setFilters, setQuery, setSort } = useStoreActions();
// Covers load only up to a few cards past the furthest one reached.
const [reach, setReach] = (0, useState)(FIRST_ART);
// And the ones far from where focus rests let theirs go.
const row = useRowWindow();
const seeAll = () => {
setFilters(NO_FILTERS);
setQuery("");
@@ -37,7 +44,7 @@ import { GameCard } from '../ui/GameCard.jsx';
};
return /* @__PURE__ */ (0, jsx)(GameRail, { title: t2("browse.forYou"), onSeeAll: seeAll, seeAllKey: "popular:all", children: POPULAR.map((game, index) => {
const key = `popular:${game.id}`;
return /* @__PURE__ */ (0, jsx)(
return /* @__PURE__ */ (0, jsx)(RowCard, { far: row.far(index), children: /* @__PURE__ */ (0, jsx)(
GameCard,
{
game,
@@ -45,10 +52,14 @@ import { GameCard } from '../ui/GameCard.jsx';
detailed: true,
focusKey: key,
rowEnd: index === POPULAR.length - 1,
deferArt: index >= reach,
onFocus: () => {
setReach((count) => Math.max(count, index + ART_AHEAD));
row.reached(index);
},
onPress: () => openGame(game.id, key)
},
game.id
);
}
) }, game.id);
}) });
});
+7 -4
View File
@@ -4,6 +4,7 @@
import { jsx } from 'react/jsx-runtime';
import { memo, useRef } from 'react';
import { GameRail } from './GameRail.jsx';
import { RowCard, useRowWindow, useSectionShown } from './near.js';
import { useT } from '../i18n/index.js';
import { getGame, listedId } from '../state/catalog.js';
import { useNavActions, useNavState } from '../state/nav.jsx';
@@ -19,11 +20,13 @@ import { GameCard } from '../ui/GameCard.jsx';
const kept = (0, useRef)(recent2);
if (shown) kept.current = recent2;
const { openGame } = useNavActions();
const row = useRowWindow();
const games2 = [...new Set(kept.current.map(listedId))].map(getGame).filter(Boolean);
useSectionShown(games2.length > 0);
if (!games2.length) return null;
return /* @__PURE__ */ (0, jsx)(GameRail, { title: t2("browse.recentlyViewed"), children: games2.map((game, index) => {
const key = `recent:${game.id}`;
return /* @__PURE__ */ (0, jsx)(
return /* @__PURE__ */ (0, jsx)(RowCard, { far: row.far(index), children: /* @__PURE__ */ (0, jsx)(
GameCard,
{
game,
@@ -31,10 +34,10 @@ import { GameCard } from '../ui/GameCard.jsx';
detailed: true,
focusKey: key,
rowEnd: index === games2.length - 1,
onFocus: () => row.reached(index),
onPress: () => openGame(game.id, key)
},
game.id
);
}
) }, game.id);
}) });
});
+1 -1
View File
@@ -62,7 +62,7 @@ import { Text, View } from '@ps5-react/core';
const { openGame } = useNavActions();
const show = useShowLibrary();
// "See all" opens the library on that sort, as the header's section of the same name does.
const seeAll = (id) => show(NO_FILTERS, id, "catalog:sort", { rating: "top", newest: "new" }[id] ?? "catalog");
const seeAll = (id) => show(NO_FILTERS, id, "catalog:sort", { rating: "top" }[id] ?? "catalog");
return /* @__PURE__ */ (0, jsx)(View, { scrollAnchor: true, style: { marginBottom: -18, marginTop: -12, flexDirection: "row", gap: 48 }, children: CHARTS.map(
(chart) => /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1 }, children: [
/* @__PURE__ */ (0, jsx)(SectionHeader, { title: sortLabel(chart.id), onSeeAll: () => seeAll(chart.id) }),
+45
View File
@@ -0,0 +1,45 @@
// The genre cards' pixel art (tools/genre-sprites.py), baked into the app: a card that has one shows
// it instead of three covers, so the rail loads no images. A genre without one keeps its covers.
import action from '../assets/genres/genre-action.png';
import adventure from '../assets/genres/genre-adventure.png';
import arcade from '../assets/genres/genre-arcade.png';
import brain from '../assets/genres/genre-brain.png';
import casual from '../assets/genres/genre-casual.png';
import family from '../assets/genres/genre-family.png';
import fighting from '../assets/genres/genre-fighting.png';
import horror from '../assets/genres/genre-horror.png';
import indie from '../assets/genres/genre-indie.png';
import music from '../assets/genres/genre-music.png';
import party from '../assets/genres/genre-party.png';
import puzzle from '../assets/genres/genre-puzzle.png';
import racing from '../assets/genres/genre-racing.png';
import rpg from '../assets/genres/genre-rpg.png';
import shooter from '../assets/genres/genre-shooter.png';
import simulation from '../assets/genres/genre-simulation.png';
import sport from '../assets/genres/genre-sport.png';
import strategy from '../assets/genres/genre-strategy.png';
// By the catalog's genre names.
var GENRE_ART = {
Action: action,
Adventure: adventure,
Arcade: arcade,
"Brain Training": brain,
Casual: casual,
Family: family,
Fighting: fighting,
Horror: horror,
Indie: indie,
"Music/Rhythm": music,
Party: party,
Puzzle: puzzle,
Racing: racing,
RPG: rpg,
Shooter: shooter,
Simulation: simulation,
Sport: sport,
Strategy: strategy
};
export { GENRE_ART };
+77
View File
@@ -0,0 +1,77 @@
// Which images the store front keeps in use. An image in use is never evicted from the decoded cache,
// and the store front mounts every section and every card of its rows, so it held over a hundred images
// at once: the cache stayed full and, with the Homebrew page's many small icons, the engine's image
// registry ran out ("the engine image registry is full") and new covers never drew.
//
// Down the page, the section focus is in and the one above and below keep theirs; the rest let them go
// (ReleaseImages: an image released stays cached until the room is needed, and one on screen at that
// moment stays). A section with nothing to show (no recent games, no download to continue) is not
// counted, so the one past it is still the next. Along a row, the cards near the one focus last rested
// on keep theirs.
import { jsx } from 'react/jsx-runtime';
import { createContext, useContext, useEffect, useState } from 'react';
import { ReleaseImages, useFocus } from '@ps5-react/core';
// The sections in page order, by the prefix of their elements' focus keys.
var SECTIONS = { spotlight: 0, popular: 1, cdn: 2, continue: 3, recent: 4, chart: 5, genre: 6, card: 7, catalog: 7, chip: 7 };
var NEAR = 1;
var current = 0;
var listeners = /* @__PURE__ */ new Set();
var empty = /* @__PURE__ */ new Set();
var notify = () => {
for (const listener of listeners) listener(current);
};
// Where a section sits among the ones shown.
var rank = (index) => index - [...empty].filter((other) => other < index).length;
var SectionIndex = (0, createContext)(-1);
// Rendered once by the page: follows focus and tells the sections, which re-render only when they turn
// near or far. Focus elsewhere (the header, another screen) leaves the last section as it was.
function FocusedSection() {
const { focusedKey } = useFocus();
(0, useEffect)(() => {
const index = SECTIONS[String(focusedKey ?? "").split(":")[0]];
if (index === void 0 || index === current) return;
current = index;
notify();
}, [focusedKey]);
return null;
}
function NearSection({ index, children }) {
const far = (focused) => Math.abs(rank(index) - rank(focused)) > NEAR;
const [released, setReleased] = (0, useState)(() => far(current));
(0, useEffect)(() => {
const listener = (focused) => setReleased(far(focused));
listeners.add(listener);
listener(current);
return () => {
listeners.delete(listener);
};
}, [index]);
return /* @__PURE__ */ (0, jsx)(SectionIndex.Provider, { value: index, children: /* @__PURE__ */ (0, jsx)(ReleaseImages, { when: released, children }) });
}
// Called by a section that can have nothing to show, before it returns null.
function useSectionShown(shown) {
const index = (0, useContext)(SectionIndex);
(0, useEffect)(() => {
if (index < 0 || shown === !empty.has(index)) return;
if (shown) empty.delete(index);
else empty.add(index);
notify();
}, [index, shown]);
}
// Cards further than this from the one focus last rested on let their images go; a screen shows six
// or seven. The centre moves in steps of four, so a row re-renders every few cards, not on every one.
var ROW_WINDOW = 8;
var STEP = 4;
// `reached` keeps its identity across renders, so a memoized card given it does not re-render.
function useRowWindow() {
const [center, setCenter] = (0, useState)(0);
const [reached] = (0, useState)(() => (index) => setCenter(Math.floor(index / STEP) * STEP));
return { far: (index) => Math.abs(index - center) > ROW_WINDOW, reached };
}
function RowCard({ far, children }) {
return /* @__PURE__ */ (0, jsx)(ReleaseImages, { when: far, children });
}
export { FocusedSection, NearSection, RowCard, useRowWindow, useSectionShown };
+23 -4
View File
@@ -1,10 +1,11 @@
// ../pub-game-store/native/downloads/DownloadRow.jsx
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: game page info button; title/meta columns rebalanced so the meta still ends on the row's right edge.
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { memo, useEffect, useState } from 'react';
import { RowProgress } from './RowProgress.jsx';
import { RowProgress, ROW } from './RowProgress.jsx';
import { StatsStrip } from './StatsStrip.jsx';
import { PANEL_KEY } from './SummaryPanel.jsx';
import { STALE_PARTIAL, cause, explainError } from './errors.js';
@@ -12,6 +13,7 @@ import { WAITING, clearAction, shownState, stateLabel } from './labels.js';
import { shapeOf } from './useQueue.js';
import { isOverdriveCdn } from '../game/sources.js';
import { focusEnd, useLocale, useT } from '../i18n/index.js';
import { getGame } from '../state/catalog.js';
import { BUSY, COMPLETE } from '../state/download-queue.js';
import { Button2 } from '../ui/Button.jsx';
import { CdnAura, CdnSpark } from '../ui/CdnAura.jsx';
@@ -72,6 +74,19 @@ import { Text, View } from '@ps5-react/core';
}
);
}
function InfoButton({ id, onPress, onFocus }) {
return /* @__PURE__ */ (0, jsx)(
Button2,
{
variant: "secondary",
size: "icon",
icon: "info",
focusKey: `downloads-info:${id}`,
onFocus,
onPress
}
);
}
function DownloadRowView({ job: job2, game, autoFocus, run, onOpenGame, onFocusRow }) {
const t2 = useT();
const { state, release } = job2;
@@ -79,6 +94,9 @@ import { Text, View } from '@ps5-react/core';
const complete = COMPLETE.includes(state);
const openGame = () => onOpenGame(job2);
const main = primary(job2, (name) => run(job2, name), openGame);
// A job outside the catalog has no page to open, and a primary button that already leads there
// (continue on page) needs no second one; the triangle shortcut opens it either way.
const infoPage = !getGame(job2.gameId) || main.onPress === openGame ? null : () => openGame();
const onFocus = () => onFocusRow(job2);
const meta = [shownFormat(job2, release).toUpperCase(), `${release.provider} ${release.version || ""}`.trim(), job2.root].join(" \xB7 ");
const problem = job2.error && explainError(job2.error, Boolean(release.parts));
@@ -91,8 +109,8 @@ import { Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 30, paddingHorizontal: 30, paddingVertical: 24 }, children: [
/* @__PURE__ */ (0, jsx)(Cover, { game, label: false, style: THUMB2 }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 15 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { width: 870, flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between" }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { width: 540, fontFamily: "Inter-Regular", fontSize: 27, fontWeight: 600, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 42, letterSpacing: -0.68 }, children: job2.title }),
/* @__PURE__ */ (0, jsxs)(View, { style: { width: ROW, flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between" }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { width: 505, fontFamily: "Inter-Regular", fontSize: 27, fontWeight: 600, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 42, letterSpacing: -0.68 }, children: job2.title }),
/* @__PURE__ */ (0, jsxs)(View, { style: { width: 315, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 9 }, children: [
cdn ? /* @__PURE__ */ (0, jsx)(CdnSpark, { size: 13 }) : null,
/* @__PURE__ */ (0, jsx)(Text, { style: cdn ? { maxWidth: 285, fontFamily: "Inter-Regular", fontSize: 21, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 } : { width: 315, textAlign: "end", fontFamily: "Inter-Regular", fontSize: 21, color: "#8a8a94", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: meta })
@@ -111,9 +129,10 @@ import { Text, View } from '@ps5-react/core';
detail: shown === "finishing" ? t2("downloads.writingToDisk") : null
}
),
note ? /* @__PURE__ */ (0, jsx)(Text, { style: { width: 870, fontFamily: "Inter-Regular", fontSize: 21, color: problem ? "#f2f2f3" : mismatch ? "#fde68a" : "#a3a3ab", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: note }) : null
note ? /* @__PURE__ */ (0, jsx)(Text, { style: { width: ROW, fontFamily: "Inter-Regular", fontSize: 21, color: problem ? "#f2f2f3" : mismatch ? "#fde68a" : "#a3a3ab", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: note }) : null
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12 }, children: [
infoPage ? /* @__PURE__ */ (0, jsx)(InfoButton, { id: job2.id, onPress: infoPage, onFocus }) : null,
/* @__PURE__ */ (0, jsx)(
Button2,
{
+4 -4
View File
@@ -59,16 +59,16 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
)
] });
}
function Empty({ error: error2, onBack }) {
function Empty({ error: error2, onBrowse }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, alignItems: "center", justifyContent: "center", paddingBottom: 144 }, children: [
/* @__PURE__ */ (0, jsx)(Icon, { name: "download", size: 32, color: colors.subtle }),
/* @__PURE__ */ (0, jsx)(Text, { style: { marginTop: 24, fontFamily: "Inter-Regular", fontSize: 27, fontWeight: 600, color: "#f2f2f3", lineHeight: 42 }, children: t2(error2 ? "downloads.unavailable" : "downloads.empty") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { marginTop: 6, width: 720, textAlign: "center", fontFamily: "Inter-Regular", fontSize: 24, color: "#a3a3ab", numberOfLines: 3, ellipsizeMode: "tail", lineHeight: 36 }, children: error2 || t2("downloads.emptyDetail") }),
/* @__PURE__ */ (0, jsx)(View, { style: { marginTop: 36 }, children: /* @__PURE__ */ (0, jsx)(Button2, { variant: "outline", label: t2("downloads.browseGames"), focusKey: EMPTY_KEY, autoFocus: true, onPress: onBack }) })
/* @__PURE__ */ (0, jsx)(View, { style: { marginTop: 36 }, children: /* @__PURE__ */ (0, jsx)(Button2, { variant: "outline", label: t2("downloads.browseGames"), focusKey: EMPTY_KEY, autoFocus: true, onPress: onBrowse }) })
] });
}
function DownloadsScreen({ onBack, onOpenGame }) {
function DownloadsScreen({ onBack, onBrowse, onOpenGame }) {
const t2 = useT();
const { jobs, error: error2 } = useJobs();
(0, useEffect)(() => {
@@ -137,7 +137,7 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.active", jobs: active2, first, handlers }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.stopped", jobs: stopped, first, handlers }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.completed", jobs: done, first, handlers })
] }) }) : /* @__PURE__ */ (0, jsx)(Empty, { error: error2, onBack }),
] }) }) : /* @__PURE__ */ (0, jsx)(Empty, { error: error2, onBrowse }),
/* @__PURE__ */ (0, jsx)(SummaryPanel, { error: error2, onFocus: () => {
focused.current = null;
} })
+10 -4
View File
@@ -1,5 +1,7 @@
// ../pub-game-store/native/downloads/RowProgress.jsx
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: progress track width derived from the text row width, so the bar ends where the
// right-aligned size does (the download row's info button narrowed the column).
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
@@ -11,9 +13,13 @@ import { formatBytes } from '../state/size.js';
import { ProgressBar } from '../ui/ProgressBar.jsx';
import { ShimmerText } from '../ui/ShimmerText.jsx';
import { colors } from '../ui/colors.js';
import { logical } from '../ui/scale.js';
import { Text, View } from '@ps5-react/core';
var TRACK2 = 580;
// ProgressBar scales what it is given by px(); the rows below are drawn in raw pixels. Passing the
// logical width makes the track end exactly on the rows' right edge at any screen size.
var ROW = 830;
var TRACK2 = logical(ROW);
// `open`: work with no known total (an extraction past its size estimate), drawn as a full, dimmed bar.
var LABEL = { fontFamily: "Inter-Regular", fontSize: 21, lineHeight: 30 };
function RowProgress({ id, label, moving, waiting: waiting2, complete, open, detail: detail2 }) {
@@ -24,8 +30,8 @@ import { Text, View } from '@ps5-react/core';
const size = detail2 ?? (complete ? formatBytes(total || written) : total ? t2("download.status.progress", { done: formatBytes(written), total: formatBytes(total) }) : written ? t2("download.soFar", { size: formatBytes(written) }) : "");
return /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 12 }, children: [
/* @__PURE__ */ (0, jsx)(ProgressBar, { fraction: complete || open ? 1 : fractionOf(progress), width: TRACK2, active: (moving && !open) || complete }),
/* @__PURE__ */ (0, jsxs)(View, { style: { width: 870, flexDirection: "row", justifyContent: "space-between" }, children: [
waiting2 ? /* @__PURE__ */ (0, jsx)(View, { style: { width: 540 }, children: /* @__PURE__ */ (0, jsx)(ShimmerText, { textStyle: LABEL, background: colors.surface, children: label }) }) : /* @__PURE__ */ (0, jsxs)(Text, { style: { width: 540, fontFamily: "Inter-Regular", fontSize: 21, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { width: ROW, flexDirection: "row", justifyContent: "space-between" }, children: [
waiting2 ? /* @__PURE__ */ (0, jsx)(View, { style: { width: 505 }, children: /* @__PURE__ */ (0, jsx)(ShimmerText, { textStyle: LABEL, background: colors.surface, children: label }) }) : /* @__PURE__ */ (0, jsxs)(Text, { style: { width: 505, fontFamily: "Inter-Regular", fontSize: 21, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: [
fitBytes(label, SPAN_MAX),
/* @__PURE__ */ (0, jsx)(Text, { style: { color: "#a3a3ab" }, children: left ? fitBytes(` ${left}`, SPAN_MAX) : "" })
] }),
@@ -34,4 +40,4 @@ import { Text, View } from '@ps5-react/core';
] });
}
export { RowProgress };
export { RowProgress, ROW };
+3 -2
View File
@@ -38,7 +38,7 @@ import { FileSystem, ScrollView, Text, View } from '@ps5-react/core';
}
function Activity({ onFocus }) {
const t2 = useT();
const { active: active2, speed, left, connections, peak, low, mean } = useActivity();
const { active: active2, speed, left, connections, peak, low, mean, extracting, extractedFiles } = useActivity();
const limit = useSpeedLimit();
return /* @__PURE__ */ (0, jsx)(Block, { title: t2("downloads.panel.activity"), children: /* @__PURE__ */ (0, jsxs)(View, { focusable: true, focusKey: PANEL_KEY, onFocus, style: (_state) => [{ gap: 18, borderRadius: 12, padding: 12 }, _state.focused ? { backgroundColor: "#212125" } : null], children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 45, fontWeight: 600, color: "#f2f2f3", lineHeight: 54, letterSpacing: -1.13 }, children: active2 ? formatSpeed(speed) : t2("downloads.panel.idle") }),
@@ -56,7 +56,8 @@ import { FileSystem, ScrollView, Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(Figure, { label: t2("downloads.panel.average"), value: mean ? formatSpeed(mean) : "\u2014" }),
/* @__PURE__ */ (0, jsx)(Figure, { label: t2("downloads.panel.connections"), value: connections ? formatNumber2(connections) : "\u2014" }),
// Why a download goes no faster: the cap chosen in Settings (issue #15).
/* @__PURE__ */ (0, jsx)(Figure, { label: t2("settings.speedLimit"), value: limit ? t2("units.speed", { n: String(limit) }) : t2("settings.speedUnlimited") })
/* @__PURE__ */ (0, jsx)(Figure, { label: t2("settings.speedLimit"), value: limit ? t2("units.speed", { n: String(limit) }) : t2("settings.speedUnlimited") }),
extracting && extractedFiles > 0 ? /* @__PURE__ */ (0, jsx)(Figure, { label: t2("downloads.filesExtracted"), value: formatNumber2(extractedFiles) }) : null
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 21, color: "#8a8a94", lineHeight: 30 }, INNER], children: awake() ? t2("downloads.panel.awake") : " " })
] }) });
+4
View File
@@ -17,6 +17,10 @@ import { t } from '../i18n/core.js';
[STALE_PARTIAL, "staleProgress", "restart"],
[/file changed since this partial/i, "fileChanged", "restart"],
[/needs more memory to extract/i, "archiveTooLarge", "otherSource"],
// A 7z too large for the app's heap extracts in rar-extract (a payload): with the console short of free
// memory it stops before decoding, and without a payload loader it cannot start.
[/too little free memory/i, "consoleMemoryBusy", "closeAppsRetry"],
[/needs the payload loader to extract/i, "archiveNoLoader", "startLoaderRetry"],
// The engine's heap had no room for the extraction; a fresh launch frees it.
[/not enough memory to extract/i, "noMemory", "reopenApp"],
// Every known password was tried before this shows (download-queue.js), and the downloaded files stay:
+3 -2
View File
@@ -46,12 +46,13 @@ import { downloadQueue } from '../state/downloads.js';
return progress;
}
function useActivity() {
const [activity, setActivity] = (0, useState)({ speed: 0, left: 0, active: 0, connections: 0, peak: 0, low: 0, mean: 0 });
const [activity, setActivity] = (0, useState)({ speed: 0, left: 0, active: 0, connections: 0, peak: 0, low: 0, mean: 0, extracting: false, extractedFiles: 0 });
(0, useEffect)(() => {
const measure = createActivity();
let shown = null;
return subscribe2((jobs) => {
const next = measure(jobs, Date.now());
const extracting = jobs.find((job2) => job2.state === "extracting");
const next = { ...measure(jobs, Date.now()), extracting: Boolean(extracting), extractedFiles: extracting?.extractedFiles || 0 };
if (shown && Object.keys(next).every((key) => shown[key] === next[key])) return;
shown = next;
setActivity(next);
+2 -1
View File
@@ -15,7 +15,8 @@ import { Text, View, useIsFocused } from '@ps5-react/core';
const t2 = useT();
const { stack } = useNavState();
const previous = stack[stack.length - 2];
return previous.name === "game" ? getGame(previous.id).title : t2(`game.back.${previous.name}`);
// A homebrew app can leave the catalog while its page is open: the label then names the library.
return previous.name === "game" ? getGame(previous.id)?.title ?? t2("game.back.browse") : t2(`game.back.${previous.name}`);
}
function Label2() {
const focused = useIsFocused();
+2 -1
View File
@@ -71,7 +71,8 @@ import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
const begin = () => {
if (stale) restart();
else if (lacking) return;
else if (status === "idle" && release) onRequestDialog({ kind: "tips", game, release, submit: () => download.advance(), changeSource: () => chooseSource() });
// A homebrew app has one source, its developer's GitHub release: the tips are about choosing hosts.
else if (status === "idle" && release && !game.homebrew) onRequestDialog({ kind: "tips", game, release, submit: () => download.advance(), changeSource: () => chooseSource() });
else download.advance();
};
const { openEdition, focusLater, push } = useNavActions();
+13 -1
View File
@@ -8,6 +8,7 @@ import { preferredRelease, requiredFirmware } from './sources.js';
import { useT } from '../i18n/index.js';
import en from '../i18n/locales/en.json';
import { formatDate3 } from '../state/catalog.js';
import { detailsOf } from '../state/homebrew.js';
import { formatBytes, formatSize } from '../state/size.js';
import { PlatformBadge } from '../ui/PlatformBadge.jsx';
import { Text, View } from '@ps5-react/core';
@@ -25,7 +26,18 @@ import { Text, View } from '@ps5-react/core';
const firmware = preferred ? requiredFirmware(preferred) : null;
const size = bytes ? [label("game.facts.downloadSize"), formatBytes(bytes)] : [label("game.facts.installSize"), game.sizeGb ? formatSize(game.sizeGb) : "\u2014"];
const platform = /* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: game.platforms });
const rows = [
// A homebrew app has a developer, a version, a license and a sandbox verdict, not a publisher.
const app = game.homebrew;
const rows = app ? [
[label("game.facts.platform"), platform],
[label("game.facts.titleId"), game.titleId],
[label("game.facts.developer"), game.developer || "\u2014"],
[label("game.facts.version"), detailsOf(game)?.version || app.version || "\u2014"],
[label("game.facts.releaseDate"), formatDate3(game.releaseDate)],
[label("game.facts.license"), detailsOf(game)?.license || "\u2014"],
app.sandbox ? [label("game.facts.sandbox"), t2(`homebrew.sandbox.${app.sandbox}`)] : null,
size
].filter(Boolean) : [
[label("game.facts.platform"), platform],
[label("game.facts.titleId"), game.id],
[label("game.facts.publisher"), game.publisher || "\u2014"],
+3 -1
View File
@@ -58,7 +58,9 @@ import { Image, Text, View, motion } from '@ps5-react/core';
const { rtl } = useLocale();
const { toggleWish } = useStoreActions();
const wished = useWishlist().includes(listedId(game.id));
const nextDown = game.shots.length > 0 ? "game:shots" : void 0;
// Down from the buttons goes to the screenshots, or else to the description's Show more: without
// either named, focus took the nearest card of "More like this" past the description.
const nextDown = game.shots.length > 0 ? "game:shots" : game.about.some((text) => text.trim()) ? "game:about" : void 0;
return /* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, style: { position: "relative", overflow: "hidden", borderBottomWidth: 2, borderColor: "#2a2a2f" }, children: [
/* @__PURE__ */ (0, jsx)(Backdrop, { game }, game.id),
/* @__PURE__ */ (0, jsx)(View, { style: rtl ? { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundGradient: { type: "linear", to: "left", stops: [{ color: "#000000", offset: 0 }, { color: "#000000d9", offset: 0.45 }, { color: "#00000040", offset: 1 }] } } : { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundGradient: { type: "linear", to: "right", stops: [{ color: "#000000", offset: 0 }, { color: "#000000d9", offset: 0.45 }, { color: "#00000040", offset: 1 }] } } }),
+161
View File
@@ -0,0 +1,161 @@
// The Homebrew page (the header's Homebrew item): every app the community catalog lists, newest release
// first, by kind or category. And an app's own page, which first reads the app's file from the catalog
// (its download and description) and is then a game's page.
import '../assets/Inter-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { useCallback, useEffect, useRef, useState } from 'react';
import { GameScreen } from '../game/index.jsx';
import { SPAN_MAX, fitBytes } from '../i18n/fit.js';
import { useLocale, useT } from '../i18n/index.js';
import { detailsOf, loadDetails, refresh, useHomebrew } from '../state/homebrew.js';
import { useNavActions } from '../state/nav.jsx';
import { Button2 } from '../ui/Button.jsx';
import { FocusRing } from '../ui/FocusRing.jsx';
import { DETAILED_CAPTION, GameCard, useFavoriteAction } from '../ui/GameCard.jsx';
import { Spinner } from '../ui/Spinner.jsx';
import { FocusScope, ScrollView, Text, View, VirtualList, useIsFocused } from '@ps5-react/core';
var COLUMNS = 6;
var CARD_WIDTH = 182;
var CARD_HEIGHT = 243 + DETAILED_CAPTION;
var TITLE = { fontFamily: "Inter-Regular", fontSize: 30, fontWeight: 600, color: "#f2f2f3", lineHeight: 42, letterSpacing: -0.75 };
var NOTE = { fontFamily: "Inter-Regular", fontSize: 21, color: "#a3a3ab", lineHeight: 30 };
var SOURCE = "prosperostore.net";
// The chips: the catalog's kinds, and emulators, a category of apps people look for on their own.
var FILTERS = [
{ id: "all", label: "homebrew.kind.all", test: () => true },
{ id: "app", label: "homebrew.kind.app", test: (app) => app.homebrew.kind === "app" },
{ id: "game", label: "homebrew.kind.game", test: (app) => app.homebrew.kind === "game" },
{ id: "emulators", label: "genre.emulators", test: (app) => app.homebrew.category === "emulators" },
{ id: "tool", label: "homebrew.kind.tool", test: (app) => app.homebrew.kind === "tool" }
];
// An app's file is read once focus has rested on its card this long, so its page usually opens at once.
var PREFETCH_MS = 250;
var prefetching = null;
function prefetch(app) {
clearTimeout(prefetching);
prefetching = setTimeout(() => void loadDetails(app).catch(() => {}), PREFETCH_MS);
}
function ChipLabel({ label, count, current }) {
const focused = useIsFocused();
return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Text, { focused, style: [{ fontFamily: "Inter-Regular", fontSize: 21, color: current ? "#0d0d0f" : "#f2f2f3d9", lineHeight: 30 }, focused && !current ? { color: "#f2f2f3" } : null], children: label }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: current ? "#0d0d0f99" : "#8a8a94", lineHeight: 30 }, children: String(count) })
] });
}
function Chip({ id, label, count, current, onPress }) {
return /* @__PURE__ */ (0, jsx)(View, { focusKey: `homebrew-kind:${id}`, onPress, style: (state) => [
{ height: 60, flexDirection: "row", alignItems: "center", gap: 12, borderRadius: 14999, paddingHorizontal: 24, backgroundColor: current ? "#f2f2f3" : "#17171a" },
state.focused && !current ? { backgroundColor: "#2b2b30" } : null
], children: /* @__PURE__ */ (0, jsx)(ChipLabel, { label, count, current }) });
}
function Grid({ apps }) {
const { openGame } = useNavActions();
const renderItem = (0, useCallback)(
({ item: app, index }) => /* @__PURE__ */ (0, jsx)(
GameCard,
{
game: app,
width: CARD_WIDTH,
detailed: true,
rowEnd: index % COLUMNS === COLUMNS - 1 || index === apps.length - 1,
rowStart: index % COLUMNS === 0,
onFocus: () => prefetch(app),
onPress: () => openGame(app.id, `card:${app.id}`)
}
),
[apps, openGame]
);
return /* @__PURE__ */ (0, jsx)(VirtualList, { style: { marginTop: 36 }, data: apps, numColumns: COLUMNS, itemHeight: CARD_HEIGHT, rowGap: 40, columnGap: 24, initialNumRows: 3, keyExtractor: (app) => app.id, renderItem });
}
// Waiting for the list, or why there is none, with a way to ask again.
function Status({ failed }) {
const t2 = useT();
if (!failed) return /* @__PURE__ */ (0, jsx)(View, { style: { marginTop: 120, alignItems: "center" }, children: /* @__PURE__ */ (0, jsx)(Spinner, {}) });
return /* @__PURE__ */ (0, jsxs)(View, { style: { marginTop: 96, alignItems: "center", gap: 30 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: [NOTE, { width: 900, textAlign: "center" }], children: t2("homebrew.failed") }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("homebrew.retry"), focusKey: "homebrew:retry", autoFocus: true, onPress: () => void refresh(true) })
] });
}
// The filter chosen; the page stays mounted under an app's page (index.jsx), so it is only for a later
// visit from the header.
var chosenFilter = "all";
function HomebrewScreen({ onBack }) {
const t2 = useT();
const { rtl } = useLocale();
const { apps, status } = useHomebrew();
const [filter, setFilter] = (0, useState)(chosenFilter);
const choose = (next) => {
chosenFilter = next;
setFilter(next);
};
// On a first launch the list can arrive after the page opened, with nothing focused yet.
const { focusLater } = useNavActions();
const listed = apps.length > 0;
const listedAtOpen = (0, useRef)(listed);
(0, useEffect)(() => {
if (listed && !listedAtOpen.current) focusLater(`homebrew-kind:${chosenFilter}`);
}, [listed]);
const counts = Object.fromEntries(FILTERS.map(({ id, test }) => [id, apps.filter(test).length]));
const chips = FILTERS.filter(({ id }) => id === "all" || counts[id] > 0);
const active = chips.find(({ id }) => id === filter) ?? FILTERS[0];
const shown = apps.filter(active.test);
const onAction = useFavoriteAction();
const back2 = () => {
onBack();
return true;
};
// Not a trap: Up from the chips reaches the header, as on Downloads and Files.
return /* @__PURE__ */ (0, jsx)(FocusScope, { autoFocus: true, onBack: back2, onAction, children: /* @__PURE__ */ (0, jsxs)(ScrollView, { style: { flex: 1, paddingHorizontal: 48, paddingTop: 48, paddingBottom: 96 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { scrollAnchor: true, children: [
/* @__PURE__ */ (0, jsxs)(Text, { style: TITLE, children: [
fitBytes(t2("nav.homebrew"), SPAN_MAX),
apps.length ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontWeight: 400, color: "#8a8a94" }, children: fitBytes(` ${t2("homebrew.count", { count: apps.length })}`, SPAN_MAX) }) : null
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: [NOTE, { marginTop: 12, width: 1400, textAlign: rtl ? "right" : "left" }], children: t2("homebrew.about", { source: SOURCE }) }),
apps.length ? /* @__PURE__ */ (0, jsx)(FocusScope, { row: true, children: /* @__PURE__ */ (0, jsx)(View, { style: { marginTop: 36, flexDirection: "row", flexWrap: "wrap", gap: 12 }, children: chips.map(
({ id, label }) => /* @__PURE__ */ (0, jsx)(Chip, { id, label: t2(label), count: counts[id], current: active.id === id, onPress: () => choose(id) }, id)
) }) }) : null
] }),
apps.length ? /* @__PURE__ */ (0, jsx)(Grid, { apps: shown }, active.id) : /* @__PURE__ */ (0, jsx)(Status, { failed: status === "failed" })
] }) });
}
// An app's page: its file is read first, so the page opens with its download ready. While it is read
// the screen shows a spinner alone; a failure says so, with Retry and Back.
function HomebrewGame({ game, ...props }) {
const t2 = useT();
const [state, setState] = (0, useState)(() => !game ? "failed" : detailsOf(game) ? "ready" : "loading");
const [attempt, setAttempt] = (0, useState)(0);
(0, useEffect)(() => {
if (!game || state === "ready") return void 0;
let live = true;
setState("loading");
loadDetails(game).then(() => live && setState("ready"), (error2) => {
console.log(`[homebrew] ${game.id} not read: ${error2.message}`);
if (live) setState("failed");
});
return () => {
live = false;
};
}, [game?.id, attempt]);
if (state === "ready") return /* @__PURE__ */ (0, jsx)(GameScreen, { game, ...props });
const back2 = () => {
props.onBack();
return true;
};
if (state === "loading") {
return /* @__PURE__ */ (0, jsx)(FocusScope, { trap: true, autoFocus: true, onBack: back2, children: /* @__PURE__ */ (0, jsx)(View, { style: { flex: 1, alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ (0, jsx)(Spinner, {}) }) });
}
return /* @__PURE__ */ (0, jsx)(FocusScope, { trap: true, autoFocus: true, onBack: back2, children: /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, alignItems: "center", justifyContent: "center", gap: 30 }, children: [
game ? /* @__PURE__ */ (0, jsx)(Text, { style: TITLE, children: game.title }) : null,
/* @__PURE__ */ (0, jsx)(Text, { style: [NOTE, { width: 900, textAlign: "center" }], children: t2("homebrew.appFailed") }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 15 }, children: [
game ? /* @__PURE__ */ (0, jsx)(Button2, { label: t2("homebrew.retry"), focusKey: "homebrew-app:retry", onPress: () => setAttempt((count) => count + 1) }) : null,
/* @__PURE__ */ (0, jsx)(Button2, { variant: "outline", label: t2("hints.back"), focusKey: "homebrew-app:back", onPress: props.onBack })
] })
] }) });
}
export { HomebrewGame, HomebrewScreen };
+21 -2
View File
@@ -1,6 +1,6 @@
// ../pub-game-store/native/i18n/locales.js
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: Italian.
// MODIFIED: Italian, traditional Chinese, European Portuguese.
var NBSP = "\xA0";
@@ -26,6 +26,15 @@
date: "{y}\u5E74{M}\u6708{d}\u65E5",
list: ["\u3001", "\u548C", "\u548C"]
},
{
code: "zh-Hant",
name: "\u7E41\u9AD4\u4E2D\u6587",
group: ",",
decimal: ".",
percent: "{n}%",
date: "{y}\u5E74{M}\u6708{d}\u65E5",
list: ["\u3001", "\u548C", "\u548C"]
},
{
code: "hi",
name: "\u0939\u093F\u0928\u094D\u0926\u0940",
@@ -90,6 +99,16 @@
months: ["jan.", "fev.", "mar.", "abr.", "mai.", "jun.", "jul.", "ago.", "set.", "out.", "nov.", "dez."],
list: [", ", " e ", " e "]
},
{
code: "pt-PT",
name: "Portugu\xEAs (Portugal)",
group: NBSP,
decimal: ",",
percent: "{n}%",
date: "{d} de {MMM} de {y}",
months: ["jan.", "fev.", "mar.", "abr.", "mai.", "jun.", "jul.", "ago.", "set.", "out.", "nov.", "dez."],
list: [", ", " e ", " e "]
},
{
code: "ru",
name: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
@@ -181,7 +200,7 @@
const [language, ...rest2] = tag.replace(/_/g, "-").split("-");
const exact = LOCALES.find((locale2) => locale2.code.toLowerCase() === tag.toLowerCase().replace(/_/g, "-"));
if (exact) return exact.code;
if (language.toLowerCase() === "zh") return rest2.some((part) => /^(hant|tw|hk|mo)$/i.test(part)) ? null : "zh-Hans";
if (language.toLowerCase() === "zh") return rest2.some((part) => /^(hant|tw|hk|mo)$/i.test(part)) && !rest2.some((part) => /^hans$/i.test(part)) ? "zh-Hant" : "zh-Hans";
return LOCALES.find((locale2) => locale2.code.split("-")[0] === language.toLowerCase())?.code ?? null;
}
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {جميع الأجزاء} other {الأرشيف}} تم التحقق منه واستخراجه. جاهز للتثبيت.",
"downloads.extractedTo": "تم الاستخراج إلى {path}",
"downloads.extractedNothing": "تم الاستخراج إلى {path}، لكن الأرشيف لا يحتوي على لعبة للتثبيت (لا PKG ولا مجلد لعبة). أزل هذا التنزيل واختر مصدرًا آخر.",
"downloads.filesExtracted": "الملفات المستخرجة",
"downloads.installFailed": "رفض الجهاز الحزمة. اضغط تثبيت للمحاولة مرة أخرى.",
"downloads.installHint": "أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
"downloads.installRequested": "تم طلب التثبيت. أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "هذا الجهاز",
"drives.usb": "محرك USB {number}",
"errors.action.checkConnection": "تحقق من اتصال الإنترنت للجهاز، ثم أعد المحاولة.",
"errors.action.closeAppsRetry": "أغلق التطبيقات والألعاب الأخرى، ثم أعد المحاولة.",
"errors.action.freeOrOtherDrive": "حرر مساحة، أو اختر محرك أقراص آخر تحت حفظ على.",
"errors.action.freeThenResume": "حرر مساحة على محرك الأقراص، ثم استأنف.",
"errors.action.freeThenRetry": "حرر مساحة على محرك الأقراص، ثم أعد المحاولة، أو ألغ وحفظ في مكان آخر.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "أعد المحاولة بعد دقائق.",
"errors.action.retryOrOtherSource": "أعد المحاولة، أو اختر مصدر آخر.",
"errors.action.retryPart": "أعد المحاولة لتنزيل هذا الجزء مرة أخرى.",
"errors.action.startLoaderRetry": "شغّل payload loader (‏etaHEN أو elfldr)، ثم أعد المحاولة.",
"errors.archiveNoLoader": "يحتاج هذا الأرشيف إلى payload loader على المنفذ 9021 لاستخراجه",
"errors.archivePassword": "كلمة مرور الأرشيف لا تعمل",
"errors.archivePasswordMissing": "يحتاج الأرشيف إلى كلمة مرور لا يذكرها المصدر",
"errors.archivePasswordUnknown": "لا تفتح أي من كلمات المرور المعروفة هذا الأرشيف",
"errors.action.passwordKeepFiles": "تبقى الملفات المنزّلة محفوظة: اختر مصدرًا آخر، أو أعد المحاولة بعد تحديث Overdrive.",
"errors.archiveTooLarge": "لا يمكن استخراج هذا الأرشيف على الجهاز",
"errors.consoleMemoryBusy": "ذاكرة الجهاز الحرة غير كافية حاليًا",
"errors.damaged": "وصل الملف تالفاً",
"errors.driveDisconnected": "محرك الأقراص مقطوع الاتصال",
"errors.fileChanged": "تغير الملف على الخادم",
@@ -257,6 +262,7 @@
"game.addToFavorites": "إضافة إلى المفضلة",
"game.back.browse": "المكتبة",
"game.back.downloads": "التحميلات",
"game.back.homebrew": "هوم برو",
"game.back.search": "البحث",
"game.details": "التفاصيل",
"game.facts.downloadSize": "حجم التحميل",
@@ -274,17 +280,23 @@
"game.showMore": "عرض المزيد",
"genre.action": "إثارة",
"genre.adventure": "مغامرة",
"genre.ai": "ذكاء اصطناعي",
"genre.arcade": "Arcade",
"genre.boardGame": "لعبة طاولة",
"genre.brainTraining": "تدريب الدماغ",
"genre.casual": "عابث",
"genre.consolePorts": "منافذ من أجهزة الألعاب",
"genre.educational": "تعليمي",
"genre.emulators": "محاكيات",
"genre.family": "عائلي",
"genre.fighting": "قتال",
"genre.homebrewGames": "ألعاب منزلية الصنع",
"genre.horror": "رعب",
"genre.indie": "Indie",
"genre.music": "موسيقى",
"genre.musicRhythm": "موسيقى/إيقاع",
"genre.party": "حفلة",
"genre.pcPorts": "منافذ من الكمبيوتر",
"genre.puzzle": "ألغاز",
"genre.quiz": "اختبار",
"genre.racing": "سباق",
@@ -294,6 +306,10 @@
"genre.sport": "رياضة",
"genre.sports": "رياضات",
"genre.strategy": "استراتيجية",
"genre.streaming": "بث",
"genre.tools": "أدوات",
"genre.utilities": "أدوات مساعدة",
"genre.video": "فيديو",
"hints.back": "رجوع",
"hints.clear": "مسح",
"hints.close": "إغلاق",
@@ -315,6 +331,19 @@
"hints.top": "الأعلى",
"hints.type": "نوع",
"hints.view": "عرض",
"homebrew.about": "تطبيقات وألعاب أصلية لجهاز PS5 من كتالوج المجتمع على {source}. يُنزَّل كل منها من إصدار مطوّره على GitHub ويُتحقَّق منه قبل التثبيت.",
"homebrew.appFailed": "تعذّرت قراءة تفاصيل هذا التطبيق الآن، أو لم يعد في الكتالوج. حاول مرة أخرى بعد قليل.",
"homebrew.count": "{count, plural, zero {لا توجد تطبيقات} one {تطبيق واحد} two {تطبيقان} few {# تطبيقات} many {# تطبيقًا} other {# تطبيق}}",
"homebrew.failed": "تعذّر الوصول إلى كتالوج التطبيقات الآن. تحقّق من اتصال الجهاز وحاول مرة أخرى.",
"homebrew.kind.all": "الكل",
"homebrew.kind.app": "التطبيقات",
"homebrew.kind.game": "الألعاب",
"homebrew.kind.tool": "الأدوات",
"homebrew.releaseNotes": "الجديد في {version}",
"homebrew.retry": "إعادة المحاولة",
"homebrew.sandbox.leaves": "وصول كامل",
"homebrew.sandbox.stays": "داخل بيئة العزل",
"homebrew.sandbox.unclear": "غير واضح",
"install.added": "أضيف إلى التحميلات",
"install.complete": "اكتمل التحميل",
"install.manage": "أدره في التحميلات",
@@ -337,9 +366,11 @@
"language.name.ja": "اليابانية",
"language.name.nl": "الهولندية",
"language.name.pt-BR": "البرتغالية (البرازيل)",
"language.name.pt-PT": "البرتغالية (البرتغال)",
"language.name.ru": "الروسية",
"language.name.ur": "الأردية",
"language.name.zh-Hans": "الصينية (المبسطة)",
"language.name.zh-Hant": "الصينية (التقليدية)",
"language.subtitle": "عناوين الألعاب والأوصاف تبقى كما يدرجها المتجر.",
"language.system": "لغة النظام",
"language.title": "اللغة",
@@ -347,7 +378,7 @@
"nav.browse": "تصفح",
"nav.discover": "استكشاف",
"nav.files": "الملفات",
"nav.new": "الجديد",
"nav.homebrew": "هوم برو",
"nav.search": "البحث",
"nav.top": "الأفضل",
"search.noMatch": "لا توجد ألعاب تطابق \"{query}\"",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {সমস্ত অংশ} other {সংরক্ষণাগার}} যাচাই এবং বের করা হয়েছে। ইনস্টল করার জন্য প্রস্তুত।",
"downloads.extractedTo": "{path} এ বের করা হয়েছে",
"downloads.extractedNothing": "{path}-এ এক্সট্র্যাক্ট হয়েছে, কিন্তু আর্কাইভে ইনস্টল করার মতো কোনো গেম নেই (PKG বা গেম ফোল্ডার কিছুই নেই)। এই ডাউনলোড সরিয়ে অন্য উৎস বেছে নিন।",
"downloads.filesExtracted": "এক্সট্র্যাক্ট করা ফাইল",
"downloads.installFailed": "কনসোল প্যাকেজটি প্রত্যাখ্যান করেছে। আবার চেষ্টা করতে ইনস্টল চাপুন।",
"downloads.installHint": "Overdrive বন্ধ করুন যাতে এটি শেষ হয়: ShadowMount ইনস্টল করে একবার কোনো গেম খোলা না থাকে।",
"downloads.installRequested": "ইনস্টলের অনুরোধ করা হয়েছে। Overdrive বন্ধ করুন: কোনো গেম খোলা না থাকলে ShadowMount ইনস্টল করে।",
@@ -154,6 +155,7 @@
"drives.thisDevice": "এই ডিভাইস",
"drives.usb": "USB ড্রাইভ {number}",
"errors.action.checkConnection": "কনসোলের ইন্টারনেট সংযোগ পরীক্ষা করুন, তারপর আবার চেষ্টা করুন।",
"errors.action.closeAppsRetry": "অন্য অ্যাপ ও গেম বন্ধ করে আবার চেষ্টা করুন।",
"errors.action.freeOrOtherDrive": "স্থান খালি করুন, বা 'এ সংরক্ষণ করুন' এর অধীনে একটি অন্যান্য ড্রাইভ বেছে নিন।",
"errors.action.freeThenResume": "ড্রাইভে স্থান খালি করুন, তারপর পুনরায় শুরু করুন।",
"errors.action.freeThenRetry": "ড্রাইভে জায়গা খালি করে আবার চেষ্টা করুন, বা বাতিল করে অন্য কোথাও রাখুন।",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "কয়েক মিনিট পরে আবার চেষ্টা করুন।",
"errors.action.retryOrOtherSource": "আবার চেষ্টা করুন, বা একটি অন্যান্য উৎস বেছে নিন।",
"errors.action.retryPart": "ওই অংশটি আবার ডাউনলোড করতে আবার চেষ্টা করুন।",
"errors.action.startLoaderRetry": "payload loader (etaHEN বা একটি elfldr) চালু করে আবার চেষ্টা করুন।",
"errors.archiveNoLoader": "এই আর্কাইভ এক্সট্র্যাক্ট করতে পোর্ট 9021-এ payload loader লাগবে",
"errors.archivePassword": "আর্কাইভের পাসওয়ার্ড কাজ করছে না",
"errors.archivePasswordMissing": "এই আর্কাইভের এমন পাসওয়ার্ড দরকার যা উৎসে দেওয়া নেই",
"errors.archivePasswordUnknown": "পরিচিত কোনো পাসওয়ার্ডে এই আর্কাইভ খোলে না",
"errors.action.passwordKeepFiles": "ডাউনলোড করা ফাইল রাখা থাকে: অন্য উৎস বেছে নিন, অথবা Overdrive আপডেট করার পর আবার চেষ্টা করুন।",
"errors.archiveTooLarge": "এই সংরক্ষণাগার কনসোলে বের করা যায় না",
"errors.consoleMemoryBusy": "এই মুহূর্তে কনসোলে খালি মেমরি কম",
"errors.damaged": "ফাইল ক্ষতিগ্রস্ত এসেছে",
"errors.driveDisconnected": "ড্রাইভ সংযোগ বিচ্ছিন্ন",
"errors.fileChanged": "ফাইল সার্ভারে পরিবর্তিত হয়েছে",
@@ -257,6 +262,7 @@
"game.addToFavorites": "প্রিয়তে যোগ করুন",
"game.back.browse": "লাইব্রেরি",
"game.back.downloads": "ডাউনলোড",
"game.back.homebrew": "হোমব্রু",
"game.back.search": "খোঁজ",
"game.details": "বিবরণ",
"game.facts.downloadSize": "ডাউনলোড আকার",
@@ -274,17 +280,23 @@
"game.showMore": "আরও দেখান",
"genre.action": "অ্যাকশন",
"genre.adventure": "দুঃসাহসিকতা",
"genre.ai": "এআই",
"genre.arcade": "Arcade",
"genre.boardGame": "বোর্ড গেম",
"genre.brainTraining": "মস্তিষ্ক প্রশিক্ষণ",
"genre.casual": "অনানুষ্ঠানিক",
"genre.consolePorts": "কনসোল পোর্ট",
"genre.educational": "শিক্ষামূলক",
"genre.emulators": "এমুলেটর",
"genre.family": "পরিবার",
"genre.fighting": "লড়াই",
"genre.homebrewGames": "হোমব্রু গেম",
"genre.horror": "ভীতিকর",
"genre.indie": "Indie",
"genre.music": "সংগীত",
"genre.musicRhythm": "সঙ্গীত/ছন্দ",
"genre.party": "পার্টি",
"genre.pcPorts": "পিসি পোর্ট",
"genre.puzzle": "ধাঁধা",
"genre.quiz": "কুইজ",
"genre.racing": "রেসিং",
@@ -294,6 +306,10 @@
"genre.sport": "খেলা",
"genre.sports": "খেলা",
"genre.strategy": "কৌশল",
"genre.streaming": "স্ট্রিমিং",
"genre.tools": "টুল",
"genre.utilities": "ইউটিলিটি",
"genre.video": "ভিডিও",
"hints.back": "পিছনে",
"hints.clear": "পরিষ্কার করুন",
"hints.close": "বন্ধ করুন",
@@ -315,6 +331,19 @@
"hints.top": "শীর্ষ",
"hints.type": "টাইপ করুন",
"hints.view": "দেখুন",
"homebrew.about": "{source}-এর কমিউনিটি ক্যাটালগ থেকে PS5-এর নেটিভ অ্যাপ ও গেম। প্রতিটি তার ডেভেলপারের GitHub রিলিজ থেকে ডাউনলোড হয় এবং ইনস্টলের আগে যাচাই করা হয়।",
"homebrew.appFailed": "এই মুহূর্তে এই অ্যাপের বিবরণ পড়া যাচ্ছে না, অথবা এটি আর ক্যাটালগে নেই। একটু পরে আবার চেষ্টা করুন।",
"homebrew.count": "{count, plural, one {# অ্যাপ} other {# অ্যাপ}}",
"homebrew.failed": "এই মুহূর্তে হোমব্রু ক্যাটালগে পৌঁছানো যাচ্ছে না। কনসোলের সংযোগ দেখে আবার চেষ্টা করুন।",
"homebrew.kind.all": "সব",
"homebrew.kind.app": "অ্যাপ",
"homebrew.kind.game": "গেম",
"homebrew.kind.tool": "টুল",
"homebrew.releaseNotes": "{version}-এ নতুন যা আছে",
"homebrew.retry": "আবার চেষ্টা করুন",
"homebrew.sandbox.leaves": "সম্পূর্ণ অ্যাক্সেস",
"homebrew.sandbox.stays": "স্যান্ডবক্সের ভেতরে",
"homebrew.sandbox.unclear": "অস্পষ্ট",
"install.added": "ডাউনলোডে যোগ করা হয়েছে",
"install.complete": "ডাউনলোড সম্পূর্ণ",
"install.manage": "এটি ডাউনলোডে পরিচালনা করুন",
@@ -337,9 +366,11 @@
"language.name.ja": "জাপানি",
"language.name.nl": "ডাচ",
"language.name.pt-BR": "পর্তুগিজ (ব্রাজিল)",
"language.name.pt-PT": "পর্তুগিজ (পর্তুগাল)",
"language.name.ru": "রুশ",
"language.name.ur": "উর্দু",
"language.name.zh-Hans": "চীনা (সরলীকৃত)",
"language.name.zh-Hant": "চীনা (প্রথাগত)",
"language.subtitle": "গেম শিরোনাম এবং বিবরণ স্টোর তালিকা করে।",
"language.system": "সিস্টেম ভাষা",
"language.title": "ভাষা",
@@ -347,7 +378,7 @@
"nav.browse": "ব্রাউজ করুন",
"nav.discover": "আবিষ্কার করুন",
"nav.files": "ফাইল",
"nav.new": "নতুন রিলিজ",
"nav.homebrew": "হোমব্রু",
"nav.search": "খোঁজ",
"nav.top": "শীর্ষ রেটেড",
"search.noMatch": "কোনো গেম '{query}' এর সাথে মেলে না",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {Alle Teile} other {Archiv}} überprüft und extrahiert. Zum Installieren bereit.",
"downloads.extractedTo": "Extrahiert zu {path}",
"downloads.extractedNothing": "Nach {path} entpackt, aber das Archiv enthält kein installierbares Spiel (weder PKG noch Spielordner). Entferne diesen Download und wähle eine andere Quelle.",
"downloads.filesExtracted": "Extrahierte Dateien",
"downloads.installFailed": "Die Konsole hat das Paket abgelehnt. Drücke Installieren, um es erneut zu versuchen.",
"downloads.installHint": "Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
"downloads.installRequested": "Installation angefordert. Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "Dieses Gerät",
"drives.usb": "USB-Laufwerk {number}",
"errors.action.checkConnection": "Überprüfen Sie die Internetverbindung der Konsole, dann erneut versuchen.",
"errors.action.closeAppsRetry": "Schließe andere Apps und Spiele, dann Erneut versuchen.",
"errors.action.freeOrOtherDrive": "Speicherplatz freimachen oder anderes Laufwerk unter Speichern auf wählen.",
"errors.action.freeThenResume": "Speicherplatz freimachen, dann Fortsetzen.",
"errors.action.freeThenRetry": "Speicherplatz freimachen, dann Erneut versuchen, oder abbrechen und woanders speichern.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "In ein paar Minuten erneut versuchen.",
"errors.action.retryOrOtherSource": "Erneut versuchen oder andere Quelle wählen.",
"errors.action.retryPart": "Erneut versuchen, um diesen Teil noch einmal herunterzuladen.",
"errors.action.startLoaderRetry": "Starte den Payload-Loader (etaHEN oder einen elfldr), dann Erneut versuchen.",
"errors.archiveNoLoader": "Dieses Archiv braucht zum Entpacken den Payload-Loader auf Port 9021",
"errors.archivePassword": "Das Passwort des Archivs funktioniert nicht",
"errors.archivePasswordMissing": "Das Archiv braucht ein Passwort, das die Quelle nicht angibt",
"errors.archivePasswordUnknown": "Keines der bekannten Passwörter öffnet dieses Archiv",
"errors.action.passwordKeepFiles": "Die heruntergeladenen Dateien bleiben erhalten: Wähle eine andere Quelle oder tippe nach einem Overdrive-Update auf Erneut versuchen.",
"errors.archiveTooLarge": "Dieses Archiv kann auf der Konsole nicht extrahiert werden",
"errors.consoleMemoryBusy": "Die Konsole hat gerade zu wenig freien Speicher",
"errors.damaged": "Datei kam beschädigt an",
"errors.driveDisconnected": "Laufwerk getrennt",
"errors.fileChanged": "Datei hat sich auf dem Server geändert",
@@ -257,6 +262,7 @@
"game.addToFavorites": "Zu Favoriten",
"game.back.browse": "Bibliothek",
"game.back.downloads": "Downloads",
"game.back.homebrew": "Homebrew",
"game.back.search": "Suche",
"game.details": "Details",
"game.facts.downloadSize": "Download-Größe",
@@ -274,17 +280,23 @@
"game.showMore": "Mehr anzeigen",
"genre.action": "Action",
"genre.adventure": "Abenteuer",
"genre.ai": "KI",
"genre.arcade": "Arcade",
"genre.boardGame": "Brettspiel",
"genre.brainTraining": "Gehirn-Training",
"genre.casual": "Casual",
"genre.consolePorts": "Konsolen-Ports",
"genre.educational": "Lernspiel",
"genre.emulators": "Emulatoren",
"genre.family": "Familie",
"genre.fighting": "Kampf",
"genre.homebrewGames": "Homebrew-Spiele",
"genre.horror": "Horror",
"genre.indie": "Indie",
"genre.music": "Musik",
"genre.musicRhythm": "Musik/Rhythmus",
"genre.party": "Party",
"genre.pcPorts": "PC-Ports",
"genre.puzzle": "Puzzle",
"genre.quiz": "Quiz",
"genre.racing": "Rennen",
@@ -294,6 +306,10 @@
"genre.sport": "Sport",
"genre.sports": "Sports",
"genre.strategy": "Strategie",
"genre.streaming": "Streaming",
"genre.tools": "Tools",
"genre.utilities": "Dienstprogramme",
"genre.video": "Video",
"hints.back": "Zurück",
"hints.clear": "Löschen",
"hints.close": "Schließen",
@@ -315,6 +331,19 @@
"hints.top": "Oben",
"hints.type": "Text",
"hints.view": "Ansicht",
"homebrew.about": "Native PS5-Apps und -Spiele aus dem Community-Katalog von {source}. Jede wird aus dem GitHub-Release ihres Entwicklers geladen und vor der Installation geprüft.",
"homebrew.appFailed": "Die Details dieser App können gerade nicht gelesen werden, oder sie ist nicht mehr im Katalog. Versuche es gleich noch einmal.",
"homebrew.count": "{count, plural, one {# App} other {# Apps}}",
"homebrew.failed": "Der Homebrew-Katalog ist gerade nicht erreichbar. Prüfe die Verbindung der Konsole und versuche es erneut.",
"homebrew.kind.all": "Alle",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Spiele",
"homebrew.kind.tool": "Tools",
"homebrew.releaseNotes": "Neu in {version}",
"homebrew.retry": "Erneut versuchen",
"homebrew.sandbox.leaves": "Voller Zugriff",
"homebrew.sandbox.stays": "Bleibt in der Sandbox",
"homebrew.sandbox.unclear": "Unklar",
"install.added": "Zu Downloads hinzugefügt",
"install.complete": "Download abgeschlossen",
"install.manage": "In Downloads verwalten",
@@ -337,9 +366,11 @@
"language.name.ja": "Japanisch",
"language.name.nl": "Niederländisch",
"language.name.pt-BR": "Portugiesisch (Brasilien)",
"language.name.pt-PT": "Portugiesisch (Portugal)",
"language.name.ru": "Russisch",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Chinesisch (Vereinfacht)",
"language.name.zh-Hant": "Chinesisch (Traditionell)",
"language.subtitle": "Spieltitel und Beschreibungen bleiben wie im Store aufgeführt.",
"language.system": "Systemsprache",
"language.title": "Sprache",
@@ -347,7 +378,7 @@
"nav.browse": "Stöbern",
"nav.discover": "Entdecken",
"nav.files": "Dateien",
"nav.new": "Neuheiten",
"nav.homebrew": "Homebrew",
"nav.search": "Suche",
"nav.top": "Bestenliste",
"search.noMatch": "Keine Spiele für « {query} »",
+36 -1
View File
@@ -111,6 +111,7 @@
"downloads.extractedTo": "Extracted to {path}",
"downloads.extractedNothing": "Extracted to {path}, but the archive holds no game to install (no PKG and no game folder). Remove this download and pick another source.",
"downloads.extractedLeft": "Other folders stayed in {path}.",
"downloads.filesExtracted": "Files extracted",
"downloads.installFailed": "The console refused the package. Press Install to try again.",
"downloads.installHint": "Close Overdrive to finish: ShadowMount installs once no game is open.",
"downloads.titleMismatch": "The files are {found}, the game is {expected}: another game, or another region?",
@@ -157,6 +158,7 @@
"drives.thisDevice": "This device",
"drives.usb": "USB drive {number}",
"errors.action.checkConnection": "Check the console's internet connection, then Retry.",
"errors.action.closeAppsRetry": "Close other apps and games, then Retry.",
"errors.action.freeOrOtherDrive": "Free space, or choose another drive under Save to.",
"errors.action.freeThenResume": "Free space on the drive, then Resume.",
"errors.action.freeThenRetry": "Free space on the drive, then Retry, or cancel and save elsewhere.",
@@ -171,11 +173,14 @@
"errors.action.retryLater": "Retry in a few minutes.",
"errors.action.retryOrOtherSource": "Retry, or pick another source.",
"errors.action.retryPart": "Retry to download that part again.",
"errors.action.startLoaderRetry": "Start your payload loader (etaHEN or an elfldr), then Retry.",
"errors.archiveNoLoader": "This archive needs the payload loader on port 9021 to extract",
"errors.archivePassword": "The archive's password doesn't work",
"errors.archivePasswordMissing": "This archive needs a password the source doesn't give",
"errors.archivePasswordUnknown": "None of the known passwords opens this archive",
"errors.action.passwordKeepFiles": "Your downloaded files are kept: pick another source, or Retry after updating Overdrive.",
"errors.archiveTooLarge": "This archive can't be extracted on the console",
"errors.consoleMemoryBusy": "The console is short of free memory right now",
"errors.damaged": "The file arrived damaged",
"errors.driveDisconnected": "Drive disconnected",
"errors.fileChanged": "The file changed on the server",
@@ -260,15 +265,20 @@
"game.addToFavorites": "Add to favorites",
"game.back.browse": "Library",
"game.back.downloads": "Downloads",
"game.back.homebrew": "Homebrew",
"game.back.search": "Search",
"game.details": "Details",
"game.facts.developer": "Developer",
"game.facts.downloadSize": "Download size",
"game.facts.firmware": "Min. firmware",
"game.facts.installSize": "Install size",
"game.facts.license": "License",
"game.facts.platform": "Platform",
"game.facts.publisher": "Publisher",
"game.facts.releaseDate": "Release date",
"game.facts.sandbox": "Sandbox",
"game.facts.titleId": "Title ID",
"game.facts.version": "Version",
"game.favorite": "Favorite",
"game.features": "Features",
"game.metacritic": "Metacritic",
@@ -279,17 +289,23 @@
"game.showMore": "Show more",
"genre.action": "Action",
"genre.adventure": "Adventure",
"genre.ai": "AI",
"genre.arcade": "Arcade",
"genre.boardGame": "Board Game",
"genre.brainTraining": "Brain Training",
"genre.casual": "Casual",
"genre.consolePorts": "Console Ports",
"genre.educational": "Educational",
"genre.emulators": "Emulators",
"genre.family": "Family",
"genre.fighting": "Fighting",
"genre.homebrewGames": "Homebrew Games",
"genre.horror": "Horror",
"genre.indie": "Indie",
"genre.music": "Music",
"genre.musicRhythm": "Music/Rhythm",
"genre.party": "Party",
"genre.pcPorts": "PC Ports",
"genre.puzzle": "Puzzle",
"genre.quiz": "Quiz",
"genre.racing": "Racing",
@@ -299,6 +315,10 @@
"genre.sport": "Sport",
"genre.sports": "Sports",
"genre.strategy": "Strategy",
"genre.streaming": "Streaming",
"genre.tools": "Tools",
"genre.utilities": "Utilities",
"genre.video": "Video",
"hints.back": "Back",
"hints.clear": "Clear",
"hints.close": "Close",
@@ -320,6 +340,19 @@
"hints.top": "Top",
"hints.type": "Type",
"hints.view": "View",
"homebrew.about": "Native PS5 apps and games from the community catalog at {source}. Each one downloads from its developer's GitHub release and is checked before it installs.",
"homebrew.appFailed": "This app's details can't be read right now, or it's no longer in the catalog. Try again in a moment.",
"homebrew.count": "{count, plural, one {# app} other {# apps}}",
"homebrew.failed": "The homebrew catalog can't be reached right now. Check the console's connection and try again.",
"homebrew.kind.all": "All",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Games",
"homebrew.kind.tool": "Tools",
"homebrew.releaseNotes": "What's new in {version}",
"homebrew.retry": "Try again",
"homebrew.sandbox.leaves": "Full console access",
"homebrew.sandbox.stays": "Stays sandboxed",
"homebrew.sandbox.unclear": "Unclear",
"install.added": "Added to downloads",
"install.complete": "Download complete",
"install.manage": "Manage it in Downloads",
@@ -342,9 +375,11 @@
"language.name.ja": "Japanese",
"language.name.nl": "Dutch",
"language.name.pt-BR": "Portuguese (Brazil)",
"language.name.pt-PT": "Portuguese (Portugal)",
"language.name.ru": "Russian",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Chinese (Simplified)",
"language.name.zh-Hant": "Chinese (Traditional)",
"language.subtitle": "Game titles and descriptions stay as the store lists them.",
"language.system": "System language",
"language.title": "Language",
@@ -352,7 +387,7 @@
"nav.browse": "Browse",
"nav.discover": "Discover",
"nav.files": "Files",
"nav.new": "New releases",
"nav.homebrew": "Homebrew",
"nav.search": "Search",
"nav.top": "Top rated",
"search.noMatch": "No games match \u201C{query}\u201D",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {Todas las partes} other {Archivo}} verificado y extraído. Listo para instalar.",
"downloads.extractedTo": "Extraído a {path}",
"downloads.extractedNothing": "Extraído en {path}, pero el archivo no contiene un juego que instalar (ni un PKG ni una carpeta de juego). Quita esta descarga y elige otra fuente.",
"downloads.filesExtracted": "Archivos extraídos",
"downloads.installFailed": "La consola rechazó el paquete. Pulsa Instalar para volver a intentarlo.",
"downloads.installHint": "Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
"downloads.installRequested": "Instalación solicitada. Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "Este dispositivo",
"drives.usb": "Drive USB {number}",
"errors.action.checkConnection": "Comprueba la conexión a internet de la consola, luego Reintentar.",
"errors.action.closeAppsRetry": "Cierra otras apps y juegos, luego Reintentar.",
"errors.action.freeOrOtherDrive": "Libera espacio o elige otro drive en Guardar en.",
"errors.action.freeThenResume": "Libera espacio en el drive, luego Reanudar.",
"errors.action.freeThenRetry": "Libera espacio en el drive, luego Reintentar, o cancela y guarda en otro lugar.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "Reintentar en unos minutos.",
"errors.action.retryOrOtherSource": "Reintentar o elige otra fuente.",
"errors.action.retryPart": "Reintenta para volver a descargar esa parte.",
"errors.action.startLoaderRetry": "Inicia el payload loader (etaHEN o un elfldr), luego Reintentar.",
"errors.archiveNoLoader": "Este archivo necesita el payload loader en el puerto 9021 para extraerse",
"errors.archivePassword": "La contraseña del archivo no funciona",
"errors.archivePasswordMissing": "El archivo necesita una contraseña que la fuente no indica",
"errors.archivePasswordUnknown": "Ninguna de las contraseñas conocidas abre este archivo",
"errors.action.passwordKeepFiles": "Los archivos descargados se conservan: elige otra fuente o pulsa Reintentar tras actualizar Overdrive.",
"errors.archiveTooLarge": "Este archivo no se puede extraer en la consola",
"errors.consoleMemoryBusy": "Ahora mismo la consola tiene poca memoria libre",
"errors.damaged": "El archivo llegó dañado",
"errors.driveDisconnected": "Drive desconectado",
"errors.fileChanged": "El archivo cambió en el servidor",
@@ -257,6 +262,7 @@
"game.addToFavorites": "Añadir a favoritos",
"game.back.browse": "Biblioteca",
"game.back.downloads": "Descargas",
"game.back.homebrew": "Homebrew",
"game.back.search": "Búsqueda",
"game.details": "Detalles",
"game.facts.downloadSize": "Tamaño de descarga",
@@ -274,17 +280,23 @@
"game.showMore": "Mostrar más",
"genre.action": "Acción",
"genre.adventure": "Aventura",
"genre.ai": "IA",
"genre.arcade": "Arcade",
"genre.boardGame": "Juego de mesa",
"genre.brainTraining": "Entrenamiento cerebral",
"genre.casual": "Casual",
"genre.consolePorts": "Ports de consola",
"genre.educational": "Educativo",
"genre.emulators": "Emuladores",
"genre.family": "Familiar",
"genre.fighting": "Luchas",
"genre.homebrewGames": "Juegos homebrew",
"genre.horror": "Terror",
"genre.indie": "Indie",
"genre.music": "Música",
"genre.musicRhythm": "Música/Ritmo",
"genre.party": "Fiesta",
"genre.pcPorts": "Ports de PC",
"genre.puzzle": "Puzle",
"genre.quiz": "Trivial",
"genre.racing": "Carreras",
@@ -294,6 +306,10 @@
"genre.sport": "Deporte",
"genre.sports": "Deportes",
"genre.strategy": "Estrategia",
"genre.streaming": "Streaming",
"genre.tools": "Herramientas",
"genre.utilities": "Utilidades",
"genre.video": "Vídeo",
"hints.back": "Atrás",
"hints.clear": "Limpiar",
"hints.close": "Cerrar",
@@ -315,6 +331,19 @@
"hints.top": "Top",
"hints.type": "Escribe",
"hints.view": "Ver",
"homebrew.about": "Apps y juegos nativos de PS5 del catálogo de la comunidad en {source}. Cada uno se descarga de la release de GitHub de su desarrollador y se verifica antes de instalarse.",
"homebrew.appFailed": "Ahora no se pueden leer los detalles de esta app, o ya no está en el catálogo. Vuelve a intentarlo en un momento.",
"homebrew.count": "{count, plural, one {# app} other {# apps}}",
"homebrew.failed": "Ahora mismo no se puede acceder al catálogo homebrew. Comprueba la conexión de la consola y vuelve a intentarlo.",
"homebrew.kind.all": "Todo",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Juegos",
"homebrew.kind.tool": "Herramientas",
"homebrew.releaseNotes": "Novedades de la {version}",
"homebrew.retry": "Reintentar",
"homebrew.sandbox.leaves": "Acceso total",
"homebrew.sandbox.stays": "Dentro del sandbox",
"homebrew.sandbox.unclear": "No está claro",
"install.added": "Agregado a descargas",
"install.complete": "Descarga completada",
"install.manage": "Gestiona en Descargas",
@@ -337,9 +366,11 @@
"language.name.ja": "日本語",
"language.name.nl": "Neerlandés",
"language.name.pt-BR": "Português (Brasil)",
"language.name.pt-PT": "Português (Portugal)",
"language.name.ru": "Русский",
"language.name.ur": "اردو",
"language.name.zh-Hans": "简体中文",
"language.name.zh-Hant": "繁體中文",
"language.subtitle": "Los títulos y descripciones de los juegos permanecen como los lista la tienda.",
"language.system": "Idioma del sistema",
"language.title": "Idioma",
@@ -347,7 +378,7 @@
"nav.browse": "Explorar",
"nav.discover": "Descubrir",
"nav.files": "Archivos",
"nav.new": "Lanzamientos",
"nav.homebrew": "Homebrew",
"nav.search": "Búsqueda",
"nav.top": "Top",
"search.noMatch": "Ningún juego coincide con “{query}”",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {همهٔ بخش‌ها} other {آرشیو}} تأیید و استخراج شد. آمادهٔ نصب است.",
"downloads.extractedTo": "استخراج شد به {path}",
"downloads.extractedNothing": "در {path} استخراج شد، اما آرشیو بازی قابل نصبی ندارد (نه PKG و نه پوشه بازی). این دانلود را حذف کنید و منبع دیگری انتخاب کنید.",
"downloads.filesExtracted": "فایل‌های استخراج‌شده",
"downloads.installFailed": "کنسول بسته را نپذیرفت. برای تلاش دوباره، گزینهٔ «نصب» را انتخاب کنید.",
"downloads.installHint": "Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
"downloads.installRequested": "نصب درخواست شد. Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
@@ -155,6 +156,7 @@
"drives.thisDevice": "این دستگاه",
"drives.usb": "درایو USB {number}",
"errors.action.checkConnection": "اتصال اینترنت کنسول را چک کنید، سپس Retry را بزنید.",
"errors.action.closeAppsRetry": "برنامه‌ها و بازی‌های دیگر را ببندید، سپس Retry را بزنید.",
"errors.action.freeOrOtherDrive": "فضا آزاد کنید یا در «ذخیره روی» درایو دیگری انتخاب کنید.",
"errors.action.freeThenResume": "روی درایو فضا آزاد کنید، سپس Resume را بزنید.",
"errors.action.freeThenRetry": "روی درایو فضا آزاد کنید، سپس Retry را بزنید یا لغو کنید و جای دیگری ذخیره کنید.",
@@ -169,11 +171,14 @@
"errors.action.retryLater": "چند دقیقه دیگر دوباره امتحان کنید.",
"errors.action.retryOrOtherSource": "دوباره امتحان کنید یا منبع دیگری انتخاب کنید.",
"errors.action.retryPart": "برای دانلود دوبارهٔ این بخش، گزینهٔ «تلاش دوباره» را انتخاب کنید.",
"errors.action.startLoaderRetry": "payload loader (etaHEN یا یک elfldr) را اجرا کنید، سپس Retry را بزنید.",
"errors.archiveNoLoader": "برای استخراج این آرشیو، payload loader روی پورت 9021 لازم است",
"errors.archivePassword": "رمز آرشیو کار نمی‌کند",
"errors.archivePasswordMissing": "این آرشیو رمزی می‌خواهد که منبع آن را نگفته است",
"errors.archivePasswordUnknown": "هیچ‌کدام از رمزهای شناخته‌شده این آرشیو را باز نمی‌کند",
"errors.action.passwordKeepFiles": "فایل‌های دانلودشده حفظ می‌شوند: منبع دیگری انتخاب کنید یا پس از به‌روزرسانی Overdrive دوباره تلاش کنید.",
"errors.archiveTooLarge": "این آرشیو روی کنسول قابل استخراج نیست",
"errors.consoleMemoryBusy": "اکنون حافظهٔ آزاد کنسول کم است",
"errors.damaged": "فایل آسیب‌دیده رسید",
"errors.driveDisconnected": "درایو قطع شد",
"errors.fileChanged": "فایل روی سرور تغییر کرده",
@@ -258,6 +263,7 @@
"game.addToFavorites": "افزودن به علاقه‌مندی‌ها",
"game.back.browse": "کتابخانه",
"game.back.downloads": "دانلودها",
"game.back.homebrew": "هوم‌برو",
"game.back.search": "جستجو",
"game.details": "جزئیات",
"game.facts.downloadSize": "حجم دانلود",
@@ -275,17 +281,23 @@
"game.showMore": "نمایش بیشتر",
"genre.action": "اکشن",
"genre.adventure": "ماجراجویی",
"genre.ai": "هوش مصنوعی",
"genre.arcade": "آرکید",
"genre.boardGame": "بازی رومیزی",
"genre.brainTraining": "تمرین ذهن",
"genre.casual": "کژوال",
"genre.consolePorts": "پورت‌های کنسولی",
"genre.educational": "آموزشی",
"genre.emulators": "شبیه‌سازها",
"genre.family": "خانوادگی",
"genre.fighting": "مبارزه",
"genre.homebrewGames": "بازی‌های هوم‌برو",
"genre.horror": "ترسناک",
"genre.indie": "مستقل",
"genre.music": "موسیقی",
"genre.musicRhythm": "موسیقی/ریتم",
"genre.party": "مهمانی",
"genre.pcPorts": "پورت‌های رایانه",
"genre.puzzle": "پازل",
"genre.quiz": "کوییز",
"genre.racing": "مسابقه‌ای",
@@ -295,6 +307,10 @@
"genre.sport": "ورزشی",
"genre.sports": "ورزشی",
"genre.strategy": "استراتژی",
"genre.streaming": "استریم",
"genre.tools": "ابزارها",
"genre.utilities": "ابزارهای کاربردی",
"genre.video": "ویدیو",
"hints.back": "بازگشت",
"hints.clear": "پاک کردن",
"hints.close": "بستن",
@@ -316,6 +332,19 @@
"hints.top": "بالا",
"hints.type": "تایپ",
"hints.view": "مشاهده",
"homebrew.about": "برنامه‌ها و بازی‌های بومی PS5 از کاتالوگ جامعه در {source}. هر کدام از انتشار گیت‌هاب سازنده‌اش دانلود و پیش از نصب بررسی می‌شود.",
"homebrew.appFailed": "اکنون خواندن جزئیات این برنامه ممکن نیست، یا دیگر در کاتالوگ نیست. کمی بعد دوباره تلاش کنید.",
"homebrew.count": "{count, plural, one {# برنامه} other {# برنامه}}",
"homebrew.failed": "اکنون دسترسی به کاتالوگ هوم‌برو ممکن نیست. اتصال کنسول را بررسی کنید و دوباره تلاش کنید.",
"homebrew.kind.all": "همه",
"homebrew.kind.app": "برنامه‌ها",
"homebrew.kind.game": "بازی‌ها",
"homebrew.kind.tool": "ابزارها",
"homebrew.releaseNotes": "تازه‌های {version}",
"homebrew.retry": "تلاش دوباره",
"homebrew.sandbox.leaves": "دسترسی کامل",
"homebrew.sandbox.stays": "درون سندباکس",
"homebrew.sandbox.unclear": "نامشخص",
"install.added": "به دانلودها اضافه شد",
"install.complete": "دانلود کامل شد",
"install.manage": "در دانلودها مدیریت کنید",
@@ -338,9 +367,11 @@
"language.name.ja": "ژاپنی",
"language.name.nl": "هلندی",
"language.name.pt-BR": "پرتغالی (برزیل)",
"language.name.pt-PT": "پرتغالی (پرتغال)",
"language.name.ru": "روسی",
"language.name.ur": "اردو",
"language.name.zh-Hans": "چینی (ساده‌شده)",
"language.name.zh-Hant": "چینی (سنتی)",
"language.subtitle": "عنوان و توضیحات بازی‌ها همان‌طور که فروشگاه فهرست کرده می‌مانند.",
"language.system": "زبان سیستم",
"language.title": "زبان",
@@ -348,7 +379,7 @@
"nav.browse": "مرور",
"nav.discover": "کشف",
"nav.files": "فایل‌ها",
"nav.new": "تازه‌ها",
"nav.homebrew": "هوم‌برو",
"nav.search": "جستجو",
"nav.top": "برترین‌ها",
"search.noMatch": "هیچ بازی‌ای با «{query}» مطابقت ندارد",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {Toutes les parties} other {L'archive}} vérifiées et extraites. Prêt à installer.",
"downloads.extractedTo": "Extrait vers {path}",
"downloads.extractedNothing": "Extrait dans {path}, mais l'archive ne contient aucun jeu à installer (ni PKG ni dossier de jeu). Retirez ce téléchargement et choisissez une autre source.",
"downloads.filesExtracted": "Fichiers extraits",
"downloads.installFailed": "La console a refusé le paquet. Appuyez sur Installer pour réessayer.",
"downloads.installHint": "Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
"downloads.installRequested": "Installation demandée. Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "Ce périphérique",
"drives.usb": "Disque USB {number}",
"errors.action.checkConnection": "Vérifiez la connexion Internet de la console, puis Réessayez.",
"errors.action.closeAppsRetry": "Fermez les autres apps et jeux, puis Réessayez.",
"errors.action.freeOrOtherDrive": "Libérez l'espace ou choisissez un autre disque sous Enregistrer sur.",
"errors.action.freeThenResume": "Libérez l'espace, puis Reprenez.",
"errors.action.freeThenRetry": "Libérez l'espace, puis Réessayez, ou annulez et enregistrez ailleurs.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "Réessayez dans quelques minutes.",
"errors.action.retryOrOtherSource": "Réessayez ou choisissez une autre source.",
"errors.action.retryPart": "Réessayez pour retélécharger cette partie.",
"errors.action.startLoaderRetry": "Lancez le payload loader (etaHEN ou un elfldr), puis Réessayez.",
"errors.archiveNoLoader": "Cette archive a besoin du payload loader sur le port 9021 pour être extraite",
"errors.archivePassword": "Le mot de passe de l'archive ne fonctionne pas",
"errors.archivePasswordMissing": "L'archive demande un mot de passe que la source n'indique pas",
"errors.archivePasswordUnknown": "Aucun des mots de passe connus n'ouvre cette archive",
"errors.action.passwordKeepFiles": "Les fichiers téléchargés sont conservés : choisissez une autre source, ou Réessayez après la mise à jour d'Overdrive.",
"errors.archiveTooLarge": "Cette archive ne peut pas être extraite sur la console",
"errors.consoleMemoryBusy": "La console manque de mémoire libre pour le moment",
"errors.damaged": "Le fichier est arrivé endommagé",
"errors.driveDisconnected": "Disque déconnecté",
"errors.fileChanged": "Le fichier a changé sur le serveur",
@@ -257,6 +262,7 @@
"game.addToFavorites": "Ajouter aux favoris",
"game.back.browse": "Bibliothèque",
"game.back.downloads": "Téléchargements",
"game.back.homebrew": "Homebrew",
"game.back.search": "Recherche",
"game.details": "Détails",
"game.facts.downloadSize": "Taille de téléchargement",
@@ -274,17 +280,23 @@
"game.showMore": "Afficher plus",
"genre.action": "Action",
"genre.adventure": "Aventure",
"genre.ai": "IA",
"genre.arcade": "Arcade",
"genre.boardGame": "Jeu de plateau",
"genre.brainTraining": "Entraînement cérébral",
"genre.casual": "Casual",
"genre.consolePorts": "Portages console",
"genre.educational": "Éducatif",
"genre.emulators": "Émulateurs",
"genre.family": "Famille",
"genre.fighting": "Combat",
"genre.homebrewGames": "Jeux homebrew",
"genre.horror": "Horreur",
"genre.indie": "Indie",
"genre.music": "Musique",
"genre.musicRhythm": "Musique/Rythme",
"genre.party": "Fête",
"genre.pcPorts": "Portages PC",
"genre.puzzle": "Puzzle",
"genre.quiz": "Quiz",
"genre.racing": "Course",
@@ -294,6 +306,10 @@
"genre.sport": "Sport",
"genre.sports": "Sports",
"genre.strategy": "Stratégie",
"genre.streaming": "Streaming",
"genre.tools": "Outils",
"genre.utilities": "Utilitaires",
"genre.video": "Vidéo",
"hints.back": "Retour",
"hints.clear": "Effacer",
"hints.close": "Fermer",
@@ -315,6 +331,19 @@
"hints.top": "Haut",
"hints.type": "Taper",
"hints.view": "Afficher",
"homebrew.about": "Apps et jeux PS5 natifs du catalogue communautaire de {source}. Chacun se télécharge depuis la release GitHub de son développeur et est vérifié avant l'installation.",
"homebrew.appFailed": "Impossible de lire les détails de cette app pour le moment, ou elle n'est plus au catalogue. Réessayez dans un instant.",
"homebrew.count": "{count, plural, one {# app} other {# apps}}",
"homebrew.failed": "Le catalogue homebrew est injoignable pour le moment. Vérifiez la connexion de la console et réessayez.",
"homebrew.kind.all": "Tout",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Jeux",
"homebrew.kind.tool": "Outils",
"homebrew.releaseNotes": "Nouveautés de la {version}",
"homebrew.retry": "Réessayer",
"homebrew.sandbox.leaves": "Accès complet",
"homebrew.sandbox.stays": "Reste en sandbox",
"homebrew.sandbox.unclear": "Incertain",
"install.added": "Ajouté aux téléchargements",
"install.complete": "Téléchargement terminé",
"install.manage": "Le gérer dans Téléchargements",
@@ -337,9 +366,11 @@
"language.name.ja": "Japonais",
"language.name.nl": "Néerlandais",
"language.name.pt-BR": "Portugais (Brésil)",
"language.name.pt-PT": "Portugais (Portugal)",
"language.name.ru": "Russe",
"language.name.ur": "Ourdou",
"language.name.zh-Hans": "Chinois (simplifié)",
"language.name.zh-Hant": "Chinois (traditionnel)",
"language.subtitle": "Les titres et descriptions des jeux restent comme les liste le magasin.",
"language.system": "Langue système",
"language.title": "Langue",
@@ -347,7 +378,7 @@
"nav.browse": "Parcourir",
"nav.discover": "Découvrir",
"nav.files": "Fichiers",
"nav.new": "Nouvelles",
"nav.homebrew": "Homebrew",
"nav.search": "Recherche",
"nav.top": "Meilleur",
"search.noMatch": "Aucun jeu ne correspond à « {query} »",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {सभी भाग} other {संग्रह}} सत्यापित और निकाले गए। इंस्टॉल करने के लिए तैयार।",
"downloads.extractedTo": "{path} में निकाले गए",
"downloads.extractedNothing": "{path} में निकाला गया, लेकिन आर्काइव में इंस्टॉल करने लायक कोई गेम नहीं है (न PKG, न गेम फ़ोल्डर)। यह डाउनलोड हटाएँ और कोई दूसरा स्रोत चुनें।",
"downloads.filesExtracted": "एक्सट्रैक्ट की गई फ़ाइलें",
"downloads.installFailed": "कंसोल ने पैकेज अस्वीकार कर दिया। फिर से कोशिश करने के लिए इंस्टॉल दबाएँ।",
"downloads.installHint": "पूरा करने के लिए Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
"downloads.installRequested": "इंस्टॉल का अनुरोध भेजा गया। Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
@@ -154,6 +155,7 @@
"drives.thisDevice": "यह डिवाइस",
"drives.usb": "USB ड्राइव {number}",
"errors.action.checkConnection": "कंसोल का इंटरनेट कनेक्शन जाँचें, फिर फिर से कोशिश करें।",
"errors.action.closeAppsRetry": "दूसरे ऐप और गेम बंद करें, फिर फिर से कोशिश करें।",
"errors.action.freeOrOtherDrive": "स्थान खाली करें, या 'में सेव करें' के तहत एक अन्य ड्राइव चुनें।",
"errors.action.freeThenResume": "ड्राइव पर स्थान खाली करें, फिर फिर से शुरू करें।",
"errors.action.freeThenRetry": "ड्राइव पर स्थान खाली करें, फिर फिर से कोशिश करें, या इसे रद्द करें और कहीं और सेव करें।",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "कुछ मिनट में फिर से कोशिश करें।",
"errors.action.retryOrOtherSource": "फिर से कोशिश करें, या एक अन्य स्रोत चुनें।",
"errors.action.retryPart": "उस भाग को फिर से डाउनलोड करने के लिए दोबारा कोशिश करें।",
"errors.action.startLoaderRetry": "payload loader (etaHEN या कोई elfldr) चालू करें, फिर फिर से कोशिश करें।",
"errors.archiveNoLoader": "इस आर्काइव को निकालने के लिए पोर्ट 9021 पर payload loader चाहिए",
"errors.archivePassword": "आर्काइव का पासवर्ड काम नहीं कर रहा",
"errors.archivePasswordMissing": "इस आर्काइव को ऐसा पासवर्ड चाहिए जो स्रोत में नहीं दिया गया",
"errors.archivePasswordUnknown": "कोई भी ज्ञात पासवर्ड यह आर्काइव नहीं खोलता",
"errors.action.passwordKeepFiles": "डाउनलोड की गई फ़ाइलें सुरक्षित रहती हैं: कोई दूसरा स्रोत चुनें, या Overdrive अपडेट करने के बाद दोबारा कोशिश करें।",
"errors.archiveTooLarge": "इस संग्रह को कंसोल पर निकाला नहीं जा सकता",
"errors.consoleMemoryBusy": "अभी कंसोल में खाली मेमोरी कम है",
"errors.damaged": "फ़ाइल क्षतिग्रस्त आई",
"errors.driveDisconnected": "ड्राइव डिस्कनेक्ट हुई",
"errors.fileChanged": "फ़ाइल सर्वर पर बदल गई",
@@ -257,6 +262,7 @@
"game.addToFavorites": "पसंदीदा में जोड़ें",
"game.back.browse": "लाइब्रेरी",
"game.back.downloads": "डाउनलोड",
"game.back.homebrew": "होमब्रू",
"game.back.search": "खोजें",
"game.details": "विवरण",
"game.facts.downloadSize": "डाउनलोड साइज़",
@@ -274,17 +280,23 @@
"game.showMore": "अधिक दिखाएँ",
"genre.action": "एक्शन",
"genre.adventure": "साहसिक",
"genre.ai": "एआई",
"genre.arcade": "आर्केड",
"genre.boardGame": "बोर्ड गेम",
"genre.brainTraining": "दिमाग की ट्रेनिंग",
"genre.casual": "आकस्मिक",
"genre.consolePorts": "कंसोल पोर्ट",
"genre.educational": "शैक्षणिक",
"genre.emulators": "एमुलेटर",
"genre.family": "परिवार",
"genre.fighting": "लड़ाई",
"genre.homebrewGames": "होमब्रू गेम",
"genre.horror": "भयानक",
"genre.indie": "इंडी",
"genre.music": "संगीत",
"genre.musicRhythm": "संगीत/लय",
"genre.party": "पार्टी",
"genre.pcPorts": "पीसी पोर्ट",
"genre.puzzle": "पहेली",
"genre.quiz": "क्विज़",
"genre.racing": "रेसिंग",
@@ -294,6 +306,10 @@
"genre.sport": "खेल",
"genre.sports": "खेल",
"genre.strategy": "रणनीति",
"genre.streaming": "स्ट्रीमिंग",
"genre.tools": "टूल",
"genre.utilities": "यूटिलिटी",
"genre.video": "वीडियो",
"hints.back": "वापस",
"hints.clear": "साफ करें",
"hints.close": "बंद करें",
@@ -315,6 +331,19 @@
"hints.top": "शीर्ष",
"hints.type": "टाइप करें",
"hints.view": "देखें",
"homebrew.about": "{source} के कम्युनिटी कैटलॉग से PS5 के नेटिव ऐप और गेम। हर एक अपने डेवलपर की GitHub रिलीज़ से डाउनलोड होता है और इंस्टॉल से पहले जाँचा जाता है।",
"homebrew.appFailed": "अभी इस ऐप का विवरण नहीं पढ़ा जा सकता, या यह अब कैटलॉग में नहीं है। थोड़ी देर बाद फिर से कोशिश करें।",
"homebrew.count": "{count, plural, one {# ऐप} other {# ऐप}}",
"homebrew.failed": "अभी होमब्रू कैटलॉग तक नहीं पहुँचा जा सकता। कंसोल का कनेक्शन जाँचें और फिर से कोशिश करें।",
"homebrew.kind.all": "सभी",
"homebrew.kind.app": "ऐप",
"homebrew.kind.game": "गेम",
"homebrew.kind.tool": "टूल",
"homebrew.releaseNotes": "{version} में नया क्या है",
"homebrew.retry": "फिर से कोशिश करें",
"homebrew.sandbox.leaves": "पूरी पहुँच",
"homebrew.sandbox.stays": "सैंडबॉक्स में",
"homebrew.sandbox.unclear": "अस्पष्ट",
"install.added": "डाउनलोड में जोड़ा गया",
"install.complete": "डाउनलोड पूर्ण",
"install.manage": "इसे डाउनलोड में प्रबंधित करें",
@@ -337,9 +366,11 @@
"language.name.ja": "जापानी",
"language.name.nl": "डच",
"language.name.pt-BR": "पुर्तगाली (ब्राजील)",
"language.name.pt-PT": "पुर्तगाली (पुर्तगाल)",
"language.name.ru": "रूसी",
"language.name.ur": "उर्दू",
"language.name.zh-Hans": "चीनी (सरलीकृत)",
"language.name.zh-Hant": "चीनी (पारंपरिक)",
"language.subtitle": "गेम शीर्षक और विवरण स्टोर जैसा ही रहते हैं।",
"language.system": "सिस्टम भाषा",
"language.title": "भाषा",
@@ -347,7 +378,7 @@
"nav.browse": "ब्राउज़ करें",
"nav.discover": "एक्सप्लोर",
"nav.files": "फ़ाइलें",
"nav.new": "नई रिलीज़",
"nav.homebrew": "होमब्रू",
"nav.search": "खोजें",
"nav.top": "सर्वोत्तम रेटेड",
"search.noMatch": "कोई गेम '{query}' से मेल नहीं खाते",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {All parts} other {Archive}} diverifikasi dan diekstrak. Siap untuk diinstal.",
"downloads.extractedTo": "Diekstrak ke {path}",
"downloads.extractedNothing": "Diekstrak ke {path}, tetapi arsip tidak berisi game untuk dipasang (tidak ada PKG maupun folder game). Hapus unduhan ini dan pilih sumber lain.",
"downloads.filesExtracted": "File diekstrak",
"downloads.installFailed": "Konsol menolak paket. Tekan Pasang untuk mencoba lagi.",
"downloads.installHint": "Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
"downloads.installRequested": "Pemasangan diminta. Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "Perangkat ini",
"drives.usb": "Drive USB {number}",
"errors.action.checkConnection": "Periksa koneksi internet konsol, lalu coba ulang.",
"errors.action.closeAppsRetry": "Tutup aplikasi dan game lain, lalu coba ulang.",
"errors.action.freeOrOtherDrive": "Bebaskan ruang, atau pilih drive lain di bawah Simpan ke.",
"errors.action.freeThenResume": "Bebaskan ruang di drive, lalu lanjutkan.",
"errors.action.freeThenRetry": "Bebaskan ruang di drive, lalu coba ulang, atau batalkan dan simpan di tempat lain.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "Coba ulang dalam beberapa menit.",
"errors.action.retryOrOtherSource": "Coba ulang, atau pilih sumber lain.",
"errors.action.retryPart": "Coba lagi untuk mengunduh ulang bagian itu.",
"errors.action.startLoaderRetry": "Jalankan payload loader (etaHEN atau elfldr), lalu coba ulang.",
"errors.archiveNoLoader": "Arsip ini memerlukan payload loader di port 9021 untuk diekstrak",
"errors.archivePassword": "Kata sandi arsip tidak berfungsi",
"errors.archivePasswordMissing": "Arsip ini butuh kata sandi yang tidak disebutkan sumbernya",
"errors.archivePasswordUnknown": "Tidak ada kata sandi yang dikenal yang bisa membuka arsip ini",
"errors.action.passwordKeepFiles": "File yang diunduh tetap disimpan: pilih sumber lain, atau Coba lagi setelah memperbarui Overdrive.",
"errors.archiveTooLarge": "Arsip ini tidak dapat diekstrak di konsol",
"errors.consoleMemoryBusy": "Memori kosong konsol sedang tidak cukup",
"errors.damaged": "File tiba dalam kondisi rusak",
"errors.driveDisconnected": "Drive terputus",
"errors.fileChanged": "File berubah di server",
@@ -257,6 +262,7 @@
"game.addToFavorites": "Tambah ke favorit",
"game.back.browse": "Perpustakaan",
"game.back.downloads": "Unduhan",
"game.back.homebrew": "Homebrew",
"game.back.search": "Cari",
"game.details": "Detail",
"game.facts.downloadSize": "Ukuran unduhan",
@@ -274,17 +280,23 @@
"game.showMore": "Tampilkan lebih banyak",
"genre.action": "Aksi",
"genre.adventure": "Petualangan",
"genre.ai": "AI",
"genre.arcade": "Arkade",
"genre.boardGame": "Permainan Papan",
"genre.brainTraining": "Pelatihan Otak",
"genre.casual": "Santai",
"genre.consolePorts": "Port konsol",
"genre.educational": "Pendidikan",
"genre.emulators": "Emulator",
"genre.family": "Keluarga",
"genre.fighting": "Pertarungan",
"genre.homebrewGames": "Game homebrew",
"genre.horror": "Horor",
"genre.indie": "Indie",
"genre.music": "Musik",
"genre.musicRhythm": "Musik/Ritme",
"genre.party": "Pesta",
"genre.pcPorts": "Port PC",
"genre.puzzle": "Teka-teki",
"genre.quiz": "Kuis",
"genre.racing": "Balapan",
@@ -294,6 +306,10 @@
"genre.sport": "Olahraga",
"genre.sports": "Olahraga",
"genre.strategy": "Strategi",
"genre.streaming": "Streaming",
"genre.tools": "Alat",
"genre.utilities": "Utilitas",
"genre.video": "Video",
"hints.back": "Kembali",
"hints.clear": "Hapus",
"hints.close": "Tutup",
@@ -315,6 +331,19 @@
"hints.top": "Atas",
"hints.type": "Ketik",
"hints.view": "Lihat",
"homebrew.about": "Aplikasi dan game PS5 native dari katalog komunitas di {source}. Masing-masing diunduh dari rilis GitHub pengembangnya dan diperiksa sebelum dipasang.",
"homebrew.appFailed": "Detail aplikasi ini tidak dapat dibaca saat ini, atau aplikasinya sudah tidak ada di katalog. Coba lagi sebentar lagi.",
"homebrew.count": "{count, plural, other {# aplikasi}}",
"homebrew.failed": "Katalog homebrew tidak dapat dijangkau saat ini. Periksa koneksi konsol, lalu coba lagi.",
"homebrew.kind.all": "Semua",
"homebrew.kind.app": "Aplikasi",
"homebrew.kind.game": "Game",
"homebrew.kind.tool": "Alat",
"homebrew.releaseNotes": "Yang baru di {version}",
"homebrew.retry": "Coba lagi",
"homebrew.sandbox.leaves": "Akses penuh",
"homebrew.sandbox.stays": "Tetap di sandbox",
"homebrew.sandbox.unclear": "Tidak jelas",
"install.added": "Ditambahkan ke unduhan",
"install.complete": "Unduhan selesai",
"install.manage": "Kelola di Unduhan",
@@ -337,9 +366,11 @@
"language.name.ja": "Jepang",
"language.name.nl": "Belanda",
"language.name.pt-BR": "Portugis (Brasil)",
"language.name.pt-PT": "Portugis (Portugal)",
"language.name.ru": "Rusia",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Cina (Disederhanakan)",
"language.name.zh-Hant": "Cina (Tradisional)",
"language.subtitle": "Judul dan deskripsi game tetap seperti yang ditulis di toko.",
"language.system": "Bahasa sistem",
"language.title": "Bahasa",
@@ -347,7 +378,7 @@
"nav.browse": "Jelajahi",
"nav.discover": "Temukan",
"nav.files": "File",
"nav.new": "Rilis baru",
"nav.homebrew": "Homebrew",
"nav.search": "Cari",
"nav.top": "Teratas",
"search.noMatch": "Tidak ada game yang cocok \"{query}\"",
+32 -1
View File
@@ -123,6 +123,7 @@
"downloads.extractedTo": "Estratto in {path}",
"downloads.extractedNothing": "Estratto in {path}, ma l'archivio non contiene un gioco da installare (né un PKG né una cartella di gioco). Rimuovi questo download e scegli un'altra fonte.",
"downloads.extractedLeft": "Le altre cartelle sono rimaste in {path}.",
"downloads.filesExtracted": "File estratti",
"downloads.installFailed": "La console ha rifiutato il pacchetto. Premi Installa per riprovare.",
"downloads.installHint": "Chiudi Overdrive per completare: ShadowMount installa quando nessun gioco è aperto.",
"downloads.titleMismatch": "I file sono {found}, il gioco è {expected}: un altro gioco o un'altra regione?",
@@ -169,6 +170,7 @@
"drives.thisDevice": "Questo dispositivo",
"drives.usb": "Unità USB {number}",
"errors.action.checkConnection": "Controlla la connessione a Internet della console, poi Riprova.",
"errors.action.closeAppsRetry": "Chiudi le altre app e i giochi, poi Riprova.",
"errors.action.freeOrOtherDrive": "Libera spazio, oppure scegli un'altra unità in Salva su.",
"errors.action.freeThenResume": "Libera spazio sull'unità, poi Riprendi.",
"errors.action.freeThenRetry": "Libera spazio sull'unità, poi Riprova, oppure annulla e salva altrove.",
@@ -183,11 +185,14 @@
"errors.action.retryLater": "Riprova tra qualche minuto.",
"errors.action.retryOrOtherSource": "Riprova, oppure scegli un'altra fonte.",
"errors.action.retryPart": "Riprova per scaricare di nuovo quella parte.",
"errors.action.startLoaderRetry": "Avvia il payload loader (etaHEN o un elfldr), poi Riprova.",
"errors.archiveNoLoader": "Per estrarre questo archivio serve il payload loader sulla porta 9021",
"errors.archivePassword": "La password dell'archivio non funziona",
"errors.archivePasswordMissing": "L'archivio richiede una password che la fonte non indica",
"errors.archivePasswordUnknown": "Nessuna delle password conosciute apre questo archivio",
"errors.action.passwordKeepFiles": "I file scaricati restano salvati: scegli un'altra fonte, oppure Riprova dopo aver aggiornato Overdrive.",
"errors.archiveTooLarge": "Questo archivio non si può estrarre sulla console",
"errors.consoleMemoryBusy": "Al momento la console ha poca memoria libera",
"errors.damaged": "Il file è arrivato danneggiato",
"errors.driveDisconnected": "Unità scollegata",
"errors.fileChanged": "Il file è cambiato sul server",
@@ -272,6 +277,7 @@
"game.addToFavorites": "Aggiungi ai preferiti",
"game.back.browse": "Libreria",
"game.back.downloads": "Download",
"game.back.homebrew": "Homebrew",
"game.back.search": "Cerca",
"game.details": "Dettagli",
"game.facts.downloadSize": "Dimensione del download",
@@ -289,17 +295,23 @@
"game.showMore": "Mostra altro",
"genre.action": "Azione",
"genre.adventure": "Avventura",
"genre.ai": "IA",
"genre.arcade": "Arcade",
"genre.boardGame": "Gioco da tavolo",
"genre.brainTraining": "Allenamento mentale",
"genre.casual": "Casual",
"genre.consolePorts": "Port da console",
"genre.educational": "Educativo",
"genre.emulators": "Emulatori",
"genre.family": "Per la famiglia",
"genre.fighting": "Picchiaduro",
"genre.homebrewGames": "Giochi homebrew",
"genre.horror": "Horror",
"genre.indie": "Indie",
"genre.music": "Musica",
"genre.musicRhythm": "Musica/Ritmo",
"genre.party": "Party",
"genre.pcPorts": "Port da PC",
"genre.puzzle": "Rompicapo",
"genre.quiz": "Quiz",
"genre.racing": "Corse",
@@ -309,6 +321,10 @@
"genre.sport": "Sport",
"genre.sports": "Sport",
"genre.strategy": "Strategia",
"genre.streaming": "Streaming",
"genre.tools": "Strumenti",
"genre.utilities": "Utilità",
"genre.video": "Video",
"hints.back": "Indietro",
"hints.clear": "Cancella",
"hints.close": "Chiudi",
@@ -330,6 +346,19 @@
"hints.top": "In alto",
"hints.type": "Scrivi",
"hints.view": "Vedi",
"homebrew.about": "App e giochi nativi per PS5 dal catalogo della community su {source}. Ognuno si scarica dalla release GitHub del suo sviluppatore e viene verificato prima dell'installazione.",
"homebrew.appFailed": "Al momento non è possibile leggere i dettagli di questa app, oppure non è più nel catalogo. Riprova tra poco.",
"homebrew.count": "{count, plural, one {# app} other {# app}}",
"homebrew.failed": "Al momento il catalogo homebrew non è raggiungibile. Controlla la connessione della console e riprova.",
"homebrew.kind.all": "Tutti",
"homebrew.kind.app": "App",
"homebrew.kind.game": "Giochi",
"homebrew.kind.tool": "Strumenti",
"homebrew.releaseNotes": "Novità della {version}",
"homebrew.retry": "Riprova",
"homebrew.sandbox.leaves": "Accesso completo",
"homebrew.sandbox.stays": "Resta nella sandbox",
"homebrew.sandbox.unclear": "Non chiaro",
"install.added": "Aggiunto ai download",
"install.complete": "Download completato",
"install.manage": "Gestiscilo in Download",
@@ -352,9 +381,11 @@
"language.name.ja": "Giapponese",
"language.name.nl": "Olandese",
"language.name.pt-BR": "Portoghese (Brasile)",
"language.name.pt-PT": "Portoghese (Portogallo)",
"language.name.ru": "Russo",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Cinese (semplificato)",
"language.name.zh-Hant": "Cinese (tradizionale)",
"language.subtitle": "Titoli e descrizioni dei giochi restano come li riporta lo store.",
"language.system": "Lingua di sistema",
"language.title": "Lingua",
@@ -362,7 +393,7 @@
"nav.browse": "Sfoglia",
"nav.discover": "Scopri",
"nav.files": "File",
"nav.new": "Nuove uscite",
"nav.homebrew": "Homebrew",
"nav.search": "Cerca",
"nav.top": "Più votati",
"search.noMatch": "Nessun gioco corrisponde a “{query}”",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {すべてのパート} other {アーカイブ}}を検証して展開しました。インストールできます。",
"downloads.extractedTo": "展開先:{path}",
"downloads.extractedNothing": "{path} に展開しましたが、インストールできるゲーム(PKG もゲームフォルダーも)がありません。このダウンロードを削除して別のソースを選んでください。",
"downloads.filesExtracted": "展開済みファイル",
"downloads.installFailed": "本体がパッケージを拒否しました。「インストール」を押して再試行してください。",
"downloads.installHint": "Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
"downloads.installRequested": "インストールを受け付けました。Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
@@ -154,6 +155,7 @@
"drives.thisDevice": "この本体",
"drives.usb": "USBドライブ{number}",
"errors.action.checkConnection": "本体のインターネット接続を確認して、再試行してください。",
"errors.action.closeAppsRetry": "ほかのアプリやゲームを終了してから再試行してください。",
"errors.action.freeOrOtherDrive": "空き容量を確保するか、保存先で別のドライブを選んでください。",
"errors.action.freeThenResume": "ドライブの空き容量を確保して、再開してください。",
"errors.action.freeThenRetry": "ドライブの空き容量を確保して再試行するか、キャンセルして別の場所に保存してください。",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "数分後に再試行してください。",
"errors.action.retryOrOtherSource": "再試行するか、別の入手元を選んでください。",
"errors.action.retryPart": "再試行すると、そのパートをもう一度ダウンロードします。",
"errors.action.startLoaderRetry": "ペイロードローダー(etaHEN または elfldr)を起動してから再試行してください。",
"errors.archiveNoLoader": "このアーカイブの展開には、ポート9021のペイロードローダーが必要です",
"errors.archivePassword": "アーカイブのパスワードが正しくありません",
"errors.archivePasswordMissing": "このアーカイブにはソースに記載のないパスワードが必要です",
"errors.archivePasswordUnknown": "既知のパスワードではこのアーカイブを開けません",
"errors.action.passwordKeepFiles": "ダウンロードしたファイルは保持されます。別のソースを選ぶか、Overdrive を更新してから再試行してください。",
"errors.archiveTooLarge": "このアーカイブは本体上で展開できません",
"errors.consoleMemoryBusy": "本体の空きメモリーが不足しています",
"errors.damaged": "ファイルが破損しています",
"errors.driveDisconnected": "ドライブが切断されました",
"errors.fileChanged": "サーバー上のファイルが変更されました",
@@ -257,6 +262,7 @@
"game.addToFavorites": "お気に入りに追加",
"game.back.browse": "ライブラリ",
"game.back.downloads": "ダウンロード",
"game.back.homebrew": "自作ソフト",
"game.back.search": "検索",
"game.details": "詳細",
"game.facts.downloadSize": "ダウンロードサイズ",
@@ -274,17 +280,23 @@
"game.showMore": "もっと見る",
"genre.action": "アクション",
"genre.adventure": "アドベンチャー",
"genre.ai": "AI",
"genre.arcade": "アーケード",
"genre.boardGame": "ボードゲーム",
"genre.brainTraining": "脳トレ",
"genre.casual": "カジュアル",
"genre.consolePorts": "コンソール移植",
"genre.educational": "教育",
"genre.emulators": "エミュレーター",
"genre.family": "ファミリー",
"genre.fighting": "格闘",
"genre.homebrewGames": "自作ゲーム",
"genre.horror": "ホラー",
"genre.indie": "インディー",
"genre.music": "音楽",
"genre.musicRhythm": "音楽/リズム",
"genre.party": "パーティー",
"genre.pcPorts": "PC移植",
"genre.puzzle": "パズル",
"genre.quiz": "クイズ",
"genre.racing": "レース",
@@ -294,6 +306,10 @@
"genre.sport": "スポーツ",
"genre.sports": "スポーツ",
"genre.strategy": "ストラテジー",
"genre.streaming": "ストリーミング",
"genre.tools": "ツール",
"genre.utilities": "ユーティリティ",
"genre.video": "ビデオ",
"hints.back": "戻る",
"hints.clear": "クリア",
"hints.close": "閉じる",
@@ -315,6 +331,19 @@
"hints.top": "先頭へ",
"hints.type": "入力",
"hints.view": "表示",
"homebrew.about": "{source} のコミュニティカタログにある PS5 ネイティブのアプリとゲーム。どれも開発者の GitHub リリースからダウンロードされ、インストール前に検証されます。",
"homebrew.appFailed": "このアプリの詳細を読み込めません。カタログから削除された可能性もあります。しばらくしてからもう一度お試しください。",
"homebrew.count": "{count, plural, other {#個のアプリ}}",
"homebrew.failed": "自作ソフトのカタログに接続できません。本体の接続を確認して、もう一度お試しください。",
"homebrew.kind.all": "すべて",
"homebrew.kind.app": "アプリ",
"homebrew.kind.game": "ゲーム",
"homebrew.kind.tool": "ツール",
"homebrew.releaseNotes": "{version} の新機能",
"homebrew.retry": "再試行",
"homebrew.sandbox.leaves": "本体へのフルアクセス",
"homebrew.sandbox.stays": "サンドボックス内",
"homebrew.sandbox.unclear": "不明",
"install.added": "ダウンロードに追加しました",
"install.complete": "ダウンロード完了",
"install.manage": "ダウンロードで管理できます",
@@ -337,9 +366,11 @@
"language.name.ja": "日本語",
"language.name.nl": "オランダ語",
"language.name.pt-BR": "ポルトガル語(ブラジル)",
"language.name.pt-PT": "ポルトガル語(ポルトガル)",
"language.name.ru": "ロシア語",
"language.name.ur": "ウルドゥー語",
"language.name.zh-Hans": "中国語(簡体字)",
"language.name.zh-Hant": "中国語(繁体字)",
"language.subtitle": "ゲームのタイトルと説明はストアの表記のままです。",
"language.system": "システムの言語",
"language.title": "言語",
@@ -347,7 +378,7 @@
"nav.browse": "探す",
"nav.discover": "見つける",
"nav.files": "ファイル",
"nav.new": "新作",
"nav.homebrew": "自作ソフト",
"nav.search": "検索",
"nav.top": "評価の高い",
"search.noMatch": "「{query}」に一致するゲームはありません",
+32 -1
View File
@@ -111,6 +111,7 @@
"downloads.extractedTo": "Uitgepakt naar {path}",
"downloads.extractedNothing": "Uitgepakt naar {path}, maar het archief bevat geen spel om te installeren (geen PKG en geen spelmap). Verwijder deze download en kies een andere bron.",
"downloads.extractedLeft": "Andere mappen zijn in {path} gebleven.",
"downloads.filesExtracted": "Uitgepakte bestanden",
"downloads.installFailed": "De console heeft het pakket geweigerd. Druk op Installeren om het opnieuw te proberen.",
"downloads.installHint": "Sluit Overdrive om de installatie af te ronden. ShadowMount installeert zodra er geen game geopend is.",
"downloads.titleMismatch": "De bestanden zijn {found}, maar de game is {expected}: een andere game of een andere regio?",
@@ -157,6 +158,7 @@
"drives.thisDevice": "Dit apparaat",
"drives.usb": "USB\u2013schijf {number}",
"errors.action.checkConnection": "Controleer de internetverbinding van de console en probeer het opnieuw.",
"errors.action.closeAppsRetry": "Sluit andere apps en games en probeer het opnieuw.",
"errors.action.freeOrOtherDrive": "Maak ruimte vrij of kies een andere schijf bij Opslaan op.",
"errors.action.freeThenResume": "Maak ruimte vrij op de schijf en hervat daarna.",
"errors.action.freeThenRetry": "Maak ruimte vrij op de schijf en probeer het opnieuw, of annuleer en sla het bestand ergens anders op.",
@@ -171,11 +173,14 @@
"errors.action.retryLater": "Probeer het over een paar minuten opnieuw.",
"errors.action.retryOrOtherSource": "Probeer het opnieuw of kies een andere bron.",
"errors.action.retryPart": "Probeer dat deel opnieuw te downloaden.",
"errors.action.startLoaderRetry": "Start de payload loader (etaHEN of een elfldr) en probeer het opnieuw.",
"errors.archiveNoLoader": "Dit archief heeft de payload loader op poort 9021 nodig om uit te pakken",
"errors.archivePassword": "Het wachtwoord van het archief is onjuist",
"errors.archivePasswordMissing": "Het archief vraagt een wachtwoord dat de bron niet vermeldt",
"errors.archivePasswordUnknown": "Geen van de bekende wachtwoorden opent dit archief",
"errors.action.passwordKeepFiles": "De gedownloade bestanden blijven bewaard: kies een andere bron, of kies Opnieuw na een update van Overdrive.",
"errors.archiveTooLarge": "Dit archief kan niet op de console worden uitgepakt",
"errors.consoleMemoryBusy": "De console heeft nu te weinig vrij geheugen",
"errors.damaged": "Het bestand is beschadigd aangekomen",
"errors.driveDisconnected": "Schijfverbinding verbroken",
"errors.fileChanged": "Het bestand is gewijzigd op de server",
@@ -260,6 +265,7 @@
"game.addToFavorites": "Toevoegen aan favorieten",
"game.back.browse": "Bibliotheek",
"game.back.downloads": "Downloads",
"game.back.homebrew": "Homebrew",
"game.back.search": "Zoeken",
"game.details": "Details",
"game.facts.downloadSize": "Downloadgrootte",
@@ -279,17 +285,23 @@
"game.showMore": "Meer weergeven",
"genre.action": "Actie",
"genre.adventure": "Avontuur",
"genre.ai": "AI",
"genre.arcade": "Arcade",
"genre.boardGame": "Bordspel",
"genre.brainTraining": "Hersentraining",
"genre.casual": "Ontspanning",
"genre.consolePorts": "Consoleports",
"genre.educational": "Educatief",
"genre.emulators": "Emulators",
"genre.family": "Familie",
"genre.fighting": "Vechtspel",
"genre.homebrewGames": "Homebrewgames",
"genre.horror": "Horror",
"genre.indie": "Indie",
"genre.music": "Muziek",
"genre.musicRhythm": "Muziek/ritme",
"genre.party": "Feest",
"genre.pcPorts": "Pc-ports",
"genre.puzzle": "Puzzel",
"genre.quiz": "Quiz",
"genre.racing": "Racen",
@@ -299,6 +311,10 @@
"genre.sport": "Sport",
"genre.sports": "Sportgames",
"genre.strategy": "Strategie",
"genre.streaming": "Streaming",
"genre.tools": "Tools",
"genre.utilities": "Hulpprogramma's",
"genre.video": "Video",
"hints.back": "Terug",
"hints.clear": "Wissen",
"hints.close": "Sluiten",
@@ -320,6 +336,19 @@
"hints.top": "Bovenaan",
"hints.type": "Typen",
"hints.view": "Bekijken",
"homebrew.about": "Native PS5-apps en -games uit de communitycatalogus van {source}. Elke app komt uit de GitHub-release van de ontwikkelaar en wordt gecontroleerd voor de installatie.",
"homebrew.appFailed": "De details van deze app kunnen nu niet worden gelezen, of de app staat niet meer in de catalogus. Probeer het zo opnieuw.",
"homebrew.count": "{count, plural, one {# app} other {# apps}}",
"homebrew.failed": "De homebrewcatalogus is nu niet bereikbaar. Controleer de verbinding van de console en probeer het opnieuw.",
"homebrew.kind.all": "Alles",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Games",
"homebrew.kind.tool": "Tools",
"homebrew.releaseNotes": "Nieuw in {version}",
"homebrew.retry": "Opnieuw proberen",
"homebrew.sandbox.leaves": "Volledige toegang",
"homebrew.sandbox.stays": "Blijft in sandbox",
"homebrew.sandbox.unclear": "Onduidelijk",
"install.added": "Toegevoegd aan downloads",
"install.complete": "Download voltooid",
"install.manage": "Beheren via Downloads",
@@ -342,9 +371,11 @@
"language.name.ja": "Japans",
"language.name.nl": "Nederlands",
"language.name.pt-BR": "Portugees (Brazilië)",
"language.name.pt-PT": "Portugees (Portugal)",
"language.name.ru": "Russisch",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Chinees (vereenvoudigd)",
"language.name.zh-Hant": "Chinees (traditioneel)",
"language.subtitle": "Gametitels en beschrijvingen blijven zoals de winkel ze weergeeft.",
"language.system": "Systeemtaal",
"language.title": "Taal",
@@ -352,7 +383,7 @@
"nav.browse": "Bladeren",
"nav.discover": "Ontdekken",
"nav.files": "Bestanden",
"nav.new": "Nieuwe releases",
"nav.homebrew": "Homebrew",
"nav.search": "Zoeken",
"nav.top": "Best beoordeeld",
"search.noMatch": "Geen games komen overeen met \u201C{query}\u201D",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {Todas as partes} other {Arquivo}} verificado e extraído. Pronto para instalar.",
"downloads.extractedTo": "Extraído para {path}",
"downloads.extractedNothing": "Extraído em {path}, mas o arquivo não contém um jogo para instalar (nem PKG nem pasta de jogo). Remova este download e escolha outra fonte.",
"downloads.filesExtracted": "Arquivos extraídos",
"downloads.installFailed": "O console recusou o pacote. Aperte Instalar para tentar de novo.",
"downloads.installHint": "Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
"downloads.installRequested": "Instalação solicitada. Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
@@ -154,6 +155,7 @@
"drives.thisDevice": "Este dispositivo",
"drives.usb": "Drive USB {number}",
"errors.action.checkConnection": "Verifique a conexão de internet do console, depois Tente novamente.",
"errors.action.closeAppsRetry": "Feche outros apps e jogos, depois Tente novamente.",
"errors.action.freeOrOtherDrive": "Libere espaço ou escolha outro drive em Salvar em.",
"errors.action.freeThenResume": "Libere espaço no drive, depois Retome.",
"errors.action.freeThenRetry": "Libere espaço no drive, depois Tente novamente, ou cancele e salve em outro lugar.",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "Tente novamente em alguns minutos.",
"errors.action.retryOrOtherSource": "Tente novamente ou escolha outra fonte.",
"errors.action.retryPart": "Tente de novo para baixar essa parte outra vez.",
"errors.action.startLoaderRetry": "Inicie o payload loader (etaHEN ou um elfldr), depois Tente novamente.",
"errors.archiveNoLoader": "Este arquivo precisa do payload loader na porta 9021 para ser extraído",
"errors.archivePassword": "A senha do arquivo não funciona",
"errors.archivePasswordMissing": "O arquivo precisa de uma senha que a fonte não informa",
"errors.archivePasswordUnknown": "Nenhuma das senhas conhecidas abre este arquivo",
"errors.action.passwordKeepFiles": "Os arquivos baixados são mantidos: escolha outra fonte ou toque em Tentar novamente após atualizar o Overdrive.",
"errors.archiveTooLarge": "Este arquivo não pode ser extraído no console",
"errors.consoleMemoryBusy": "O console está com pouca memória livre agora",
"errors.damaged": "O arquivo chegou danificado",
"errors.driveDisconnected": "Drive desconectado",
"errors.fileChanged": "O arquivo mudou no servidor",
@@ -257,6 +262,7 @@
"game.addToFavorites": "Adicionar aos favoritos",
"game.back.browse": "Biblioteca",
"game.back.downloads": "Downloads",
"game.back.homebrew": "Homebrew",
"game.back.search": "Busca",
"game.details": "Detalhes",
"game.facts.downloadSize": "Tamanho do download",
@@ -274,17 +280,23 @@
"game.showMore": "Mostrar mais",
"genre.action": "Ação",
"genre.adventure": "Aventura",
"genre.ai": "IA",
"genre.arcade": "Arcade",
"genre.boardGame": "Jogo de Tabuleiro",
"genre.brainTraining": "Treinamento Cerebral",
"genre.casual": "Casual",
"genre.consolePorts": "Ports de console",
"genre.educational": "Educativo",
"genre.emulators": "Emuladores",
"genre.family": "Família",
"genre.fighting": "Luta",
"genre.homebrewGames": "Jogos homebrew",
"genre.horror": "Terror",
"genre.indie": "Indie",
"genre.music": "Música",
"genre.musicRhythm": "Música/Ritmo",
"genre.party": "Festa",
"genre.pcPorts": "Ports de PC",
"genre.puzzle": "Quebra-cabeça",
"genre.quiz": "Quiz",
"genre.racing": "Corrida",
@@ -294,6 +306,10 @@
"genre.sport": "Esporte",
"genre.sports": "Esportes",
"genre.strategy": "Estratégia",
"genre.streaming": "Streaming",
"genre.tools": "Ferramentas",
"genre.utilities": "Utilitários",
"genre.video": "Vídeo",
"hints.back": "Voltar",
"hints.clear": "Limpar",
"hints.close": "Fechar",
@@ -315,6 +331,19 @@
"hints.top": "Top",
"hints.type": "Digitar",
"hints.view": "Ver",
"homebrew.about": "Apps e jogos nativos de PS5 do catálogo da comunidade em {source}. Cada um é baixado da release no GitHub do seu desenvolvedor e verificado antes de instalar.",
"homebrew.appFailed": "Não é possível ler os detalhes deste app agora, ou ele não está mais no catálogo. Tente de novo em instantes.",
"homebrew.count": "{count, plural, one {# app} other {# apps}}",
"homebrew.failed": "Não foi possível acessar o catálogo homebrew agora. Verifique a conexão do console e tente de novo.",
"homebrew.kind.all": "Tudo",
"homebrew.kind.app": "Apps",
"homebrew.kind.game": "Jogos",
"homebrew.kind.tool": "Ferramentas",
"homebrew.releaseNotes": "Novidades da {version}",
"homebrew.retry": "Tentar de novo",
"homebrew.sandbox.leaves": "Acesso total",
"homebrew.sandbox.stays": "Fica no sandbox",
"homebrew.sandbox.unclear": "Incerto",
"install.added": "Adicionado aos downloads",
"install.complete": "Download concluído",
"install.manage": "Gerencie em Downloads",
@@ -337,9 +366,11 @@
"language.name.ja": "日本語",
"language.name.nl": "Holandês",
"language.name.pt-BR": "Português (Brasil)",
"language.name.pt-PT": "Português (Portugal)",
"language.name.ru": "Русский",
"language.name.ur": "اردو",
"language.name.zh-Hans": "简体中文",
"language.name.zh-Hant": "繁體中文",
"language.subtitle": "Títulos e descrições de jogos permanecem como a loja os lista.",
"language.system": "Idioma do sistema",
"language.title": "Idioma",
@@ -347,7 +378,7 @@
"nav.browse": "Navegar",
"nav.discover": "Descobrir",
"nav.files": "Arquivos",
"nav.new": "Lançamentos",
"nav.homebrew": "Homebrew",
"nav.search": "Busca",
"nav.top": "Top",
"search.noMatch": "Nenhum jogo corresponde a “{query}”",
+514
View File
@@ -0,0 +1,514 @@
{
"browse.addToFavorites": "Adicionar aos favoritos",
"browse.continueDownloading": "Continuar a descarregar",
"browse.continueFailed": "Falhou \u00B7 abrir para tentar novamente",
"browse.favorited": "Favorito",
"browse.folderInstall": "As instalações de pastas deixarão de receber atualizações em breve. Instale a versão de ficheiro único manualmente",
"browse.footer": "Biblioteca Spectrum",
"browse.popularGenres": "Géneros populares",
"browse.popularNow": "Populares agora",
"browse.forYou": "Poderá gostar",
"browse.recentlyViewed": "Vistos recentemente",
"browse.reviews": "{count} análises",
"browse.viewGame": "Ver jogo",
"catalog.empty.browseAll": "Explorar a biblioteca completa",
"catalog.empty.filtersHint": "Remova um filtro acima ou abra os Filtros para alterá-los.",
"catalog.empty.filtersTitle": "Nenhum jogo corresponde a estes filtros",
"catalog.empty.queryHint": "Verifique a ortografia, remova um filtro ou tente outro título.",
"catalog.empty.queryTitle": "Nada encontrado para \u201C{query}\u201D",
"catalog.empty.wishlistHint": "Abra um jogo e escolha \u201CAdicionar aos favoritos\u201D para o manter aqui.",
"catalog.empty.wishlistTitle": "Ainda sem favoritos",
"catalog.favorites": "Os seus favoritos",
"catalog.gameCount": "{count, plural, one {# jogo} other {# jogos}}",
"catalog.library": "Explorar biblioteca",
"catalog.resultsFor": "Resultados para \u201C{query}\u201D",
"common.seeAll": "Ver tudo",
"download.browser.action": "Abra-o, passe a verificação e insira o link do ficheiro abaixo.",
"download.browser.partTitle": "A parte {part} de {parts} precisa da página do anfitrião",
"download.browser.title": "Este anfitrião precisa primeiro da sua página web",
"download.button.awaiting-browser": "Abrir página do anfitrião",
"download.button.downloaded": "Analisar com o ShadowMount",
"download.button.extracted": "Ficheiros extraídos",
"download.button.extracting": "A extrair",
"download.button.extraction-required": "Arquivo descarregado",
"download.button.failed": "Tentar novamente",
"download.button.file-downloaded": "Ficheiro descarregado",
"download.button.finishing": "A concluir",
"download.button.idle": "Descarregar",
"download.button.installed": "Instalado",
"download.button.installer-required": "Instalar PKG",
"download.button.installing": "A instalar",
"download.button.no-space": "Espaço insuficiente",
"download.button.pause": "Pausar",
"download.button.paused": "Retomar",
"download.button.pausing": "A pausar",
"download.button.queued": "Pausar transferência na fila",
"download.button.removing": "A eliminar ficheiros",
"download.button.stale": "Reiniciar transferência",
"download.button.unavailable": "Sem fonte de transferência",
"download.button.verifying": "A verificar",
"download.enterLink": "Introduzir link do ficheiro",
"download.enterPartLink": "Introduzir link do ficheiro para a parte {part} de {parts}",
"download.fileLink": "Link do ficheiro",
"download.fileLinkDetail": "Introduza o link do próprio ficheiro, aquele que inicia a transferência após a verificação do anfitrião, e não o endereço da página.",
"download.free": "{size} livre",
"download.moreOptions": "Mais opções",
"download.noFit": "Não cabe nesta unidade",
"download.openQueue": "Abrir Transferências",
"download.optionsTitle": "Opções de transferência",
"download.otherSource.detail": "Escolha um anfitrião ou versão diferente.",
"download.otherSource.label": "Escolher outra fonte",
"download.partFileLink": "Parte {part} de {parts}: link do ficheiro",
"download.remove.detail": "Interrompe-a e deixa qualquer ficheiro descarregado na unidade.",
"download.remove.label": "Remover da fila",
"download.restart.detail": "Reinicia a transferência do início.",
"download.restart.detailSaved": "Elimina os {size} descarregados até agora e recomeça.",
"download.restart.label": "Reiniciar do zero",
"download.saveTo": "Guardar em {drive}",
"download.soFar": "{size} até agora",
"download.source": "Fonte: {source}",
"download.space.free": "Liberte {size} em {drive} e tente novamente.",
"download.space.otherDrive": "{drive} tem {size} livres: escolha-a em Guardar em.",
"download.status.awaitingLink": "À espera de um link de ficheiro",
"download.status.needs": "Necessita de {size}",
"download.status.noSpace": "Não cabe nesta unidade",
"download.status.progress": "{done} de {total}",
"download.status.ready": "Pronto a descarregar",
"download.status.unavailable": "Esta entrada do catálogo não tem URL de ficheiro",
"downloadState.awaiting-browser": "À espera da verificação do browser",
"downloadState.connecting": "A ligar\u2026",
"downloadState.downloaded": "Pronto a instalar",
"downloadState.downloading": "A descarregar",
"downloadState.extracted": "Ficheiros extraídos",
"downloadState.extracting": "A extrair os ficheiros descarregados\u2026",
"downloadState.extraction-required": "Descarregado; extraia o arquivo",
"downloadState.failed": "Falhou",
"downloadState.file-downloaded": "Ficheiro descarregado; inspecione o seu formato",
"downloadState.finishing": "A concluir\u2026",
"downloadState.installed": "Instalado",
"downloadState.installer-required": "Descarregado; pronto a instalar",
"downloadState.installing": "A instalar",
"downloadState.paused": "Pausado",
"downloadState.pausing": "A pausar\u2026",
"downloadState.queued": "Em fila de espera",
"downloadState.removing": "A eliminar ficheiros\u2026",
"downloadState.resolving": "A ligar\u2026",
"downloadState.verifying": "A verificar os hashes dos ficheiros\u2026",
"downloads.action.continueOnPage": "Continuar",
"downloads.action.gamePage": "Página do jogo",
"downloads.action.install": "Instalar",
"downloads.action.pause": "Pausar",
"downloads.action.pausing": "A pausar",
"downloads.action.restart": "Reiniciar",
"downloads.action.resume": "Retomar",
"downloads.action.retry": "Tentar novamente",
"downloads.afterVerification": "Após a verificação, regresse aqui e introduza o URL final do ficheiro.",
"downloads.archiveNotDeleted": "Não foi possível eliminar o arquivo descarregado: {error}",
"downloads.browseGames": "Explorar jogos",
"downloads.empty": "Sem transferências",
"downloads.emptyDetail": "Os jogos que descarrega aparecem aqui com o respetivo progresso.",
"downloads.extractedReady": "{parts, select, all {Todas as partes verificadas e extraídas} other {Arquivo verificado e extraído}}. Pronto a instalar.",
"downloads.extractedTo": "Extraído para {path}",
"downloads.extractedNothing": "Extraído para {path}, mas o arquivo não contém nenhum jogo para instalar (sem PKG e sem pasta de jogo). Remova esta transferência e escolha outra fonte.",
"downloads.extractedLeft": "Outras pastas permaneceram em {path}.",
"downloads.filesExtracted": "Ficheiros extraídos",
"downloads.installFailed": "A consola recusou o pacote. Prima Instalar para tentar novamente.",
"downloads.installHint": "Feche o Overdrive para concluir: o ShadowMount instala assim que nenhum jogo estiver aberto.",
"downloads.titleMismatch": "Os ficheiros são {found}, o jogo é {expected}: outro jogo ou outra região?",
"downloads.installRequested": "Instalação solicitada. Feche o Overdrive para concluir: o ShadowMount instala assim que nenhum jogo estiver aberto.",
"downloads.installed": "Instalado. Está no ecrã inicial.",
"downloads.installedFreed": "Instalado. O PKG foi eliminado: {size} libertados.",
"downloads.notice.captured": "Link capturado, regresse ao Overdrive",
"downloads.notice.complete": "Transferência concluída",
"downloads.notice.failed": "Transferência falhou",
"downloads.notice.installed": "Jogo instalado",
"downloads.packagesExtracted": "Pacotes extraídos para {path}. É necessário um instalador de PKG.",
"downloads.panel.activity": "ATIVIDADE",
"downloads.panel.average": "Média",
"downloads.panel.awake": "A consola mantém-se desperta até as transferências terminarem.",
"downloads.panel.connections": "Ligações",
"downloads.panel.downloading": "A descarregar",
"downloads.panel.idle": "Inativo",
"downloads.panel.installNote": "Os jogos concluídos são instalados através do ShadowMount, que aguarda até que nenhum jogo esteja aberto. Feche o Overdrive para permitir a conclusão.",
"downloads.panel.installing": "A INSTALAR",
"downloads.panel.low": "Baixa",
"downloads.panel.none": "Nenhuma",
"downloads.panel.peak": "Pico",
"downloads.panel.storage": "ARMAZENAMENTO",
"downloads.panel.timeLeft": "Tempo restante",
"downloads.panel.unavailable": "Indisponível",
"downloads.panel.used": "{used} de {total} utilizados",
"downloads.part": "Parte {part} de {parts}",
"downloads.problem": "{title}. {action}",
"downloads.section.active": "ATIVAS",
"downloads.section.completed": "CONCLUÍDAS",
"downloads.section.stopped": "INTERROMPIDAS",
"downloads.stats.download": "Descarregar",
"downloads.stats.write": "Escrita",
"downloads.summary.completed": "{count} concluídas",
"downloads.summary.failed": "{count} falhadas",
"downloads.summary.inProgress": "{count} em curso",
"downloads.summary.paused": "{count} pausadas",
"downloads.timeLeft": "{time} restantes",
"downloads.title": "Transferências",
"downloads.unavailable": "As transferências estão indisponíveis",
"downloads.writingToDisk": "A escrever no disco\u2026",
"drives.extended": "Armazenamento alargado {number}",
"drives.internal": "Armazenamento interno",
"drives.thisDevice": "Este dispositivo",
"drives.usb": "Unidade USB {number}",
"errors.action.checkConnection": "Verifique a ligação à Internet da consola e tente novamente.",
"errors.action.closeAppsRetry": "Feche outras aplicações e jogos e tente novamente.",
"errors.action.freeOrOtherDrive": "Liberte espaço ou escolha outra unidade em Guardar em.",
"errors.action.freeThenResume": "Liberte espaço na unidade e retome.",
"errors.action.freeThenRetry": "Liberte espaço na unidade e tente novamente, ou cancele e guarde noutro local.",
"errors.action.openSource": "Abra a fonte e introduza o URL final do ficheiro.",
"errors.action.otherSource": "Cancele-a e escolha outra fonte.",
"errors.action.installByHand": "Instale manualmente a partir de downloads/packages com o etaHEN ou o onionHEN.",
"errors.action.reconnect": "Volte a ligar a unidade original e retome.",
"errors.action.reopenApp": "Feche e reabra o Overdrive e tente novamente.",
"errors.action.restart": "Reinicie para o descarregar novamente.",
"errors.action.restartFromZero": "Reinicie do zero.",
"errors.action.resume": "Retome para continuar.",
"errors.action.retryLater": "Tente novamente dentro de alguns minutos.",
"errors.action.retryOrOtherSource": "Tente novamente ou escolha outra fonte.",
"errors.action.retryPart": "Tente novamente para descarregar essa parte outra vez.",
"errors.action.startLoaderRetry": "Inicie o seu carregador de payloads (etaHEN ou elfldr) e tente novamente.",
"errors.archiveNoLoader": "Este arquivo precisa do carregador de payloads na porta 9021 para ser extraído",
"errors.archivePassword": "A palavra-passe do arquivo não funciona",
"errors.archivePasswordMissing": "Este arquivo precisa de uma palavra-passe que a fonte não fornece",
"errors.archivePasswordUnknown": "Nenhuma das palavras-passe conhecidas abre este arquivo",
"errors.action.passwordKeepFiles": "Os seus ficheiros descarregados foram guardados: escolha outra fonte ou tente novamente após atualizar o Overdrive.",
"errors.archiveTooLarge": "Este arquivo não pode ser extraído na consola",
"errors.consoleMemoryBusy": "A consola tem pouca memória livre de momento",
"errors.damaged": "O ficheiro chegou danificado",
"errors.driveDisconnected": "Unidade desligada",
"errors.fileChanged": "O ficheiro foi alterado no servidor",
"errors.interrupted": "Interrompido quando a aplicação foi fechada",
"errors.linkGone": "O link de transferência já não funciona",
"errors.notGameFile": "O anfitrião enviou uma página web em vez do jogo",
"errors.pkgFirmware": "Os PKGs falsos da PS5 só se instalam no firmware 11.60 ou anterior",
"errors.pkgNoLoader": "O PKG está pronto, mas não pôde ser instalado automaticamente: sem carregador de payloads na porta 9021",
"errors.missingParts": "Faltam partes nesta fonte",
"errors.needsBrowser": "Esta fonte precisa de um browser web",
"errors.noConnection": "Sem ligação ao servidor",
"errors.noMemory": "Memória insuficiente para extrair",
"errors.noSpace": "Espaço livre insuficiente",
"errors.noSpaceExtract": "Espaço livre insuficiente para extrair este jogo",
"errors.action.freeThenRetryExtract": "Liberte espaço na unidade e tente novamente. A transferência é mantida.",
"errors.noSpaceOn": "Espaço insuficiente em {root}: necessita de {need}, {free} livres{queued, select, yes { após as transferências em fila} other {}}",
"errors.partIncomplete": "A parte {part} parece incompleta",
"errors.partialDeleted": "Os respetivos ficheiros descarregados foram eliminados",
"errors.serverBusy": "Servidor ocupado",
"errors.staleProgress": "O progresso guardado não pode ser retomado",
"feature.cloudSaves": "Guardados na nuvem",
"feature.coOp": "Cooperativo",
"feature.crossPlay": "Cross-play",
"feature.dualsenseFeatures": "Funcionalidades do DualSense",
"feature.onlineMultiplayer": "Multijogador online",
"feature.singlePlayer": "Um jogador",
"files.byShadowMount": "O ShadowMount instala-o",
"files.confirmDelete": "Prima novamente para eliminar \u00B7 {size}",
"files.deleteFailed": "Não foi possível eliminar este ficheiro. Tente novamente.",
"files.deleted": "Eliminado {name}",
"files.drives": "Unidades",
"files.empty": "Ainda não há nada aqui",
"files.emptyDetail": "As transferências vão parar a estas pastas e os ficheiros que copiar via FTP também aparecem aqui.",
"files.free": "{free} livres de {total}",
"files.installDone": "Instalado. Está no ecrã inicial.",
"files.installFailed": "A consola recusou este pacote. Verifique se o kstuff está carregado e tente novamente.",
"files.installFailedReason": "Falha na instalação: {reason}",
"files.installed": "Instalado",
"files.installing": "A instalar \u00B7 {percent}",
"files.kind.all": "Todos os ficheiros",
"files.kind.archives": "Arquivos",
"files.kind.extracted": "Extraídos",
"files.kind.images": "Imagens de jogos",
"files.kind.other": "Outros ficheiros",
"files.kind.packages": "Pacotes",
"files.kinds": "Mostrar",
"files.notInstalled": "Não instalado",
"files.otherData": "Jogos e sistema",
"files.shadowMountOff": "O ShadowMount não está em execução",
"files.shadowMountOn": "O ShadowMount está em execução",
"files.summary": "{count, plural, one {# ficheiro} other {# ficheiros}} \u00B7 {size}",
"files.title": "Ficheiros",
"files.type.archive": "Arquivo",
"files.type.file": "Ficheiro",
"files.type.folder": "Pasta",
"files.type.image": "Imagem de jogo",
"files.type.package": "PKG",
"files.waitingShadowMount": "À espera do ShadowMount",
"filters.access.direct": "Transferência direta",
"filters.clearAll": "Limpar tudo",
"filters.clearFacet": "Limpar",
"filters.facet.access": "Transferência",
"filters.facet.feature": "Funcionalidades",
"filters.facet.genre": "Género",
"filters.facet.platform": "Plataforma",
"filters.facet.provider": "Anfitrião de transferência",
"filters.facet.release": "Lançamento",
"filters.facet.size": "Tamanho",
"filters.favorites": "Favoritos",
"filters.more": "+{count} mais",
"filters.queryChip": "\u201C{query}\u201D",
"filters.release.current": "2020\u20132023",
"filters.release.older": "Antes de 2020",
"filters.release.recent": "Desde 2024",
"filters.selected": "{count} selecionados",
"filters.show": "Mostrar {count, plural, one {# jogo} other {# jogos}}",
"filters.size.large": "Mais de 50 GB",
"filters.size.medium": "10\u201350 GB",
"filters.size.small": "Menos de 10 GB",
"filters.title": "Filtros",
"game.about": "Acerca deste jogo",
"game.addToFavorites": "Adicionar aos favoritos",
"game.back.browse": "Biblioteca",
"game.back.downloads": "Transferências",
"game.back.homebrew": "Homebrew",
"game.back.search": "Pesquisar",
"game.details": "Detalhes",
"game.facts.developer": "Programador",
"game.facts.downloadSize": "Tamanho da transferência",
"game.facts.firmware": "Firmware mín.",
"game.facts.installSize": "Tamanho da instalação",
"game.facts.license": "Licença",
"game.facts.platform": "Plataforma",
"game.facts.publisher": "Editora",
"game.facts.releaseDate": "Data de lançamento",
"game.facts.sandbox": "Sandbox",
"game.facts.titleId": "ID do título",
"game.facts.version": "Versão",
"game.favorite": "Favorito",
"game.features": "Funcionalidades",
"game.metacritic": "Metacritic",
"game.moreLikeThis": "Semelhantes",
"game.released": "Lançado em {year}",
"game.shotPosition": "{index} / {total}",
"game.showLess": "Mostrar menos",
"game.showMore": "Mostrar mais",
"genre.action": "Ação",
"genre.adventure": "Aventura",
"genre.ai": "IA",
"genre.arcade": "Arcade",
"genre.boardGame": "Jogo de Tabuleiro",
"genre.brainTraining": "Treino Cerebral",
"genre.casual": "Casual",
"genre.consolePorts": "Ports para Consolas",
"genre.educational": "Educativo",
"genre.emulators": "Emuladores",
"genre.family": "Família",
"genre.fighting": "Luta",
"genre.homebrewGames": "Jogos Homebrew",
"genre.horror": "Terror",
"genre.indie": "Indie",
"genre.music": "Música",
"genre.musicRhythm": "Música/Ritmo",
"genre.party": "Festa",
"genre.pcPorts": "Ports para PC",
"genre.puzzle": "Puzzles",
"genre.quiz": "Quiz",
"genre.racing": "Corridas",
"genre.rpg": "RPG",
"genre.shooter": "Shooter",
"genre.simulation": "Simulação",
"genre.sport": "Desporto",
"genre.sports": "Desporto",
"genre.strategy": "Estratégia",
"genre.streaming": "Streaming",
"genre.tools": "Ferramentas",
"genre.utilities": "Utilitários",
"genre.video": "Vídeo",
"hints.back": "Voltar",
"hints.clear": "Limpar",
"hints.close": "Fechar",
"hints.confirm": "Confirmar",
"hints.confirmDelete": "Eliminar definitivamente",
"hints.delete": "Eliminar",
"hints.favorite": "Favorito",
"hints.filters": "Filtros",
"hints.gamePage": "Página do jogo",
"hints.install": "Instalar",
"hints.pauseResume": "Pausar / Retomar",
"hints.previousNext": "Anterior / Seguinte",
"hints.remove": "Remover",
"hints.search": "Pesquisar",
"hints.sections": "Secções",
"hints.select": "Selecionar",
"hints.space": "Espaço",
"hints.toggle": "Alternar",
"hints.top": "Topo",
"hints.type": "Escrever",
"hints.view": "Ver",
"homebrew.about": "Aplicações e jogos nativos para a PS5 do catálogo da comunidade em {source}. Cada um é descarregado a partir do lançamento no GitHub do respetivo programador e verificado antes de ser instalado.",
"homebrew.appFailed": "De momento, não é possível ler os detalhes desta aplicação ou já não se encontra no catálogo. Tente novamente daqui a pouco.",
"homebrew.count": "{count, plural, one {# aplicação} other {# aplicações}}",
"homebrew.failed": "De momento, não é possível aceder ao catálogo homebrew. Verifique a ligação da consola e tente novamente.",
"homebrew.kind.all": "Todos",
"homebrew.kind.app": "Aplicações",
"homebrew.kind.game": "Jogos",
"homebrew.kind.tool": "Ferramentas",
"homebrew.releaseNotes": "Novidades na versão {version}",
"homebrew.retry": "Tentar novamente",
"homebrew.sandbox.leaves": "Acesso total à consola",
"homebrew.sandbox.stays": "Mantém-se em sandbox",
"homebrew.sandbox.unclear": "Desconhecido",
"install.added": "Adicionado às transferências",
"install.complete": "Transferência concluída",
"install.manage": "Gerir em Transferências",
"keyboard.clear": "Limpar",
"keyboard.delete": "Apagar",
"keyboard.done": "Concluído",
"keyboard.lowercase": "Minúsculas",
"keyboard.space": "Espaço",
"keyboard.uppercase": "Maiúsculas",
"language.name.ar": "Árabe",
"language.name.bn": "Bengali",
"language.name.de": "Alemão",
"language.name.en": "Inglês",
"language.name.es": "Espanhol",
"language.name.fa": "Persa",
"language.name.fr": "Francês",
"language.name.hi": "Hindi",
"language.name.id": "Indonésio",
"language.name.it": "Italiano",
"language.name.ja": "Japonês",
"language.name.nl": "Neerlandês",
"language.name.pt-BR": "Português (Brasil)",
"language.name.pt-PT": "Português (Portugal)",
"language.name.ru": "Russo",
"language.name.ur": "Urdu",
"language.name.zh-Hans": "Chinês (Simplificado)",
"language.name.zh-Hant": "Chinês (Tradicional)",
"language.subtitle": "Os títulos e as descrições dos jogos mantêm-se tal como aparecem na loja.",
"language.system": "Idioma do sistema",
"language.title": "Idioma",
"nav.allGames": "Todos os jogos",
"nav.browse": "Explorar",
"nav.discover": "Descobrir",
"nav.files": "Ficheiros",
"nav.homebrew": "Homebrew",
"nav.search": "Pesquisar",
"nav.top": "Melhores",
"search.noMatch": "Nenhum jogo corresponde a \u201C{query}\u201D",
"search.placeholder": "Pesquisar na biblioteca",
"search.popular": "Populares",
"search.recent": "Pesquisas recentes",
"search.resultsFor": "Resultados para \u201C{query}\u201D",
"search.tryGenre": "Verifique a ortografia ou experimente um género:",
"settings.controller": "Comando",
"settings.deletePackages": "Eliminar PKGs após a instalação",
"settings.deletePackagesDetail": "Liberta o espaço assim que a consola instala o jogo. Desative para manter os ficheiros.",
"settings.on": "Ligado",
"settings.off": "Desligado",
"settings.downloads": "Transferências",
"settings.speedLimit": "Limite de velocidade",
"settings.speedLimitDetail": "Deixa largura de banda para outros dispositivos. As atualizações de aplicações nunca são limitadas.",
"settings.speedUnlimited": "Ilimitado",
"settings.title": "Definições",
"settings.vibration": "Vibração ao navegar",
"settings.vibrationDetail": "Um pulso ligeiro ao mover, selecionar e voltar",
"settings.vibrationOff": "Desligado",
"shadowMount.offAction": "O jogo aparece assim que o carregar. Adicione-o ao arranque automático do seu jailbreak para o manter ativo.",
"shadowMount.offTitle": "O ShadowMount não está em execução",
"sort.label": "Ordenar",
"sort.newest": "Novos lançamentos",
"sort.rating": "Mais bem classificados",
"sort.relevance": "Mais populares",
"sort.size": "Instalações maiores",
"sort.title": "A\u2013Z",
"sources.browserNote": "Cada parte abre a página de um anfitrião e, em seguida, introduz o link do ficheiro com o comando.",
"sources.cdnFastest": "Mais rápido",
"sources.kind.archive": "Arquivo",
"sources.kind.image": "Imagem de jogo",
"sources.kind.package": "Pacote",
"sources.kind.patch": "Apenas ficheiros de backport, não o jogo completo",
"sources.status.dlc": "DLC: precisa do jogo base",
"sources.status.package": "Os PKGs falsos da PS5 só se instalam no firmware 11.60 ou anterior",
"sources.status.patch": "Precisa do jogo base, não se instala sozinho",
"sources.noDetails": "Sem detalhes",
"sources.parts": "{count, plural, one {# parte} other {# partes}}",
"sources.recommended": "Recomendado",
"sources.section.browser": "Abrir num browser web",
"sources.section.direct": "Descarregar no Overdrive",
"sources.section.dlc": "Conteúdos adicionais (DLC)",
"sources.section.dlcNote": "Conteúdo extra para o jogo, não o jogo em si.",
"sources.section.page": "A partir de páginas de anfitriões",
"sources.section.pageNote": "O Overdrive lê o link do ficheiro a partir da página. Se o anfitrião pedir uma verificação, envia-o para lá.",
"sources.section.versions": "Versão",
"sources.showBrowser": "{count, plural, one {Mostrar # fonte de browser web} other {Mostrar # fontes de browser web}}",
"sources.speed.archive": "Muitas ligações em simultâneo",
"sources.speed.mediafire": "Limitado a cerca de 3 MB/s",
"sources.title": "Fonte de transferência",
"time.hoursMinutes": "{hours} h {minutes} min",
"time.minutes": "{n} min",
"time.seconds": "{n} s",
"toast.openDownloads": "Abrir Transferências",
"toast.viewGame": "Ver jogo",
"tips.eyebrow": "Transferência",
"tips.title": "Antes de descarregar",
"tips.downloading": "Está a descarregar",
"tips.about": "Acerca desta fonte",
"tips.toggle": "Dicas para escolher uma fonte",
"tips.continue": "Continuar",
"tips.changeSource": "Mudar de fonte",
"tips.note.dlc": "Isto é um DLC, não o jogo. Instale primeiro o jogo base.",
"tips.note.patch": "Estes são apenas ficheiros de backport, não o jogo completo. Não se vão instalar sozinhos.",
"tips.note.package": "Este é um PKG falso da PS5. Só se instala no firmware 11.60 ou anterior.",
"tips.note.browserViking": "Esta fonte abre-se no browser da PS5. Conclua a verificação e aguarde. Não prima Download nem nenhum anúncio.",
"tips.note.browserOther": "Esta fonte abre-se no browser da PS5 e depois pede o link do ficheiro. Se puder, escolha uma fonte mais acima na lista.",
"tips.note.firmware": "Esta versão precisa do firmware {need} ou mais recente. Verifique o seu em Definições > Sistema > Software de Sistema.",
"tips.tip.firmware.title": "Verifique o firmware",
"tips.tip.firmware.body": "Base 8.xx precisa de 8.xx ou mais recente. O BackPort é para consolas mais antigas.",
"tips.tip.fullGame.title": "Obtenha o jogo completo",
"tips.tip.fullGame.body": "Base ou BackPort, não DLC nem ficheiros de backport.",
"tips.tip.top.title": "Comece pelo topo",
"tips.tip.top.body": "As primeiras fontes funcionam melhor.",
"tips.tip.retry.title": "Falhou? Tente outra",
"tips.tip.retry.body": "A maioria dos jogos tem várias fontes.",
"units.gigabytes": "{n} GB",
"units.megabytes": "{n} MB",
"units.millions": "{n}M",
"units.speed": "{n} MB/s",
"units.thousands": "{n}K",
"update.available": "ATUALIZAÇÃO DISPONÍVEL",
"update.close": "Fechar",
"update.detail": "Uma nova versão do Overdrive está pronta. Instala-se no mesmo local e reabre-se sozinha.",
"update.done": "ATUALIZADO",
"update.failed": "FALHA NA ATUALIZAÇÃO",
"update.install": "Atualizar agora",
"update.installing": "A instalar\u2026",
"update.later": "Mais tarde",
"update.quit": "Fechar o Overdrive",
"update.reopen": "A fechar. Abra o Overdrive novamente a partir do ecrã inicial",
"update.reopenInstalled": "O Overdrive foi atualizado para a v{version}. Feche-o e abra-o novamente a partir do ecrã inicial.",
"update.replaceByHand": "Descarregue update.zip em github.com/MyLuxy/overdrive, feche o Overdrive e copie o .exfat por cima do antigo.",
"update.replaceFailed": "Não foi possível substituir a imagem instalada enquanto o Overdrive está em execução.",
"update.restartConsole": "Se continuar a abrir a versão antiga, reinicie a consola.",
"update.restarting": "A reiniciar o Overdrive\u2026",
"update.retry": "Tentar novamente",
"update.updating": "A ATUALIZAR",
"urlDialog.cancel": "Cancelar",
"urlDialog.confirm": "Confirmar URL",
"urlDialog.controls": "Quadrado: eliminar \u00B7 R2: confirmar \u00B7 Círculo: cancelar",
"urlDialog.empty": "(vazio)",
"versions.name": "Versão para {platform}",
"versions.note.ps4": "Joga-se na PS5 através de retrocompatibilidade",
"versions.note.ps5": "Corre de forma nativa na PS5",
"versions.recommended": "Recomendado \u00B7 corre de forma nativa na PS5",
"versions.sources": "{count, plural, one {# fonte} other {# fontes}}",
"versions.thisPage": "Esta página",
"versions.title": "Escolher versão",
"capture.title": "Verificação Vikingfile",
"capture.preparing": "A preparar a verificação do ficheiro\u2026",
"capture.capturing": "Conclua a verificação e aguarde",
"capture.validating": "A verificar o ficheiro capturado\u2026",
"capture.detail": "Não prima Download nem nenhum anúncio: o link é capturado automaticamente e a transferência começa de forma autónoma. Tem 3 minutos.",
"capture.link.title": "Verificação de transferência",
"capture.link.detail": "Passe a verificação: o link é capturado automaticamente. Cancele qualquer transferência que o browser inicie. Tem 5 minutos.",
"capture.confirm.title": "Este é o ficheiro correto?",
"capture.confirm.detail": "O anfitrião não indica a que jogo pertence este ficheiro. Descarregue-o apenas se for o jogo que escolheu.",
"capture.confirm.noName": "Ficheiro sem nome",
"capture.confirm.accept": "Descarregar",
"capture.confirm.refuse": "Continuar a procurar"
}
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {Все части} other {Архив}} проверены и распакованы. Готово к установке.",
"downloads.extractedTo": "Распаковано в {path}",
"downloads.extractedNothing": "Распаковано в {path}, но в архиве нет игры для установки (ни PKG, ни папки игры). Удалите эту загрузку и выберите другой источник.",
"downloads.filesExtracted": "Распаковано файлов",
"downloads.installFailed": "Консоль отклонила пакет. Нажмите «Установить», чтобы повторить.",
"downloads.installHint": "Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
"downloads.installRequested": "Установка запрошена. Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
@@ -155,6 +156,7 @@
"drives.thisDevice": "Это устройство",
"drives.usb": "USB диск {number}",
"errors.action.checkConnection": "Проверьте интернет-соединение консоли, затем повторите.",
"errors.action.closeAppsRetry": "Закройте другие приложения и игры, затем повторите.",
"errors.action.freeOrOtherDrive": "Освободить место или выбрать другой диск под Сохранить на.",
"errors.action.freeThenResume": "Освободить место на диске, затем возобновить.",
"errors.action.freeThenRetry": "Освободить место на диске, затем повторить, или отменить и сохранить в другом месте.",
@@ -169,11 +171,14 @@
"errors.action.retryLater": "Повторить через несколько минут.",
"errors.action.retryOrOtherSource": "Повторить или выбрать другой источник.",
"errors.action.retryPart": "Повторите, чтобы скачать эту часть заново.",
"errors.action.startLoaderRetry": "Запустите payload loader (etaHEN или elfldr), затем повторите.",
"errors.archiveNoLoader": "Для распаковки этого архива нужен payload loader на порту 9021",
"errors.archivePassword": "Пароль архива не подходит",
"errors.archivePasswordMissing": "Архиву нужен пароль, которого нет в источнике",
"errors.archivePasswordUnknown": "Ни один из известных паролей не открывает этот архив",
"errors.action.passwordKeepFiles": "Загруженные файлы сохраняются: выберите другой источник или нажмите «Повторить» после обновления Overdrive.",
"errors.archiveTooLarge": "Этот архив не может быть распакован на консоли",
"errors.consoleMemoryBusy": "Сейчас у консоли мало свободной памяти",
"errors.damaged": "Файл пришёл повреждённым",
"errors.driveDisconnected": "Диск отключён",
"errors.fileChanged": "Файл изменился на сервере",
@@ -258,6 +263,7 @@
"game.addToFavorites": "Добавить в избранное",
"game.back.browse": "Библиотека",
"game.back.downloads": "Загрузки",
"game.back.homebrew": "Хоумбрю",
"game.back.search": "Поиск",
"game.details": "Детали",
"game.facts.downloadSize": "Размер загрузки",
@@ -275,17 +281,23 @@
"game.showMore": "Показать больше",
"genre.action": "Экшен",
"genre.adventure": "Приключение",
"genre.ai": "ИИ",
"genre.arcade": "Аркада",
"genre.boardGame": "Настольная игра",
"genre.brainTraining": "Тренировка ума",
"genre.casual": "Casual",
"genre.consolePorts": "Порты с консолей",
"genre.educational": "Образовательная",
"genre.emulators": "Эмуляторы",
"genre.family": "Семейные",
"genre.fighting": "Файтинг",
"genre.homebrewGames": "Хоумбрю-игры",
"genre.horror": "Ужас",
"genre.indie": "Инди",
"genre.music": "Музыка",
"genre.musicRhythm": "Музыка/Ритм",
"genre.party": "Вечеринка",
"genre.pcPorts": "Порты с ПК",
"genre.puzzle": "Головоломка",
"genre.quiz": "Викторина",
"genre.racing": "Гонки",
@@ -295,6 +307,10 @@
"genre.sport": "Спорт",
"genre.sports": "Спорты",
"genre.strategy": "Стратегия",
"genre.streaming": "Стриминг",
"genre.tools": "Инструменты",
"genre.utilities": "Утилиты",
"genre.video": "Видео",
"hints.back": "Назад",
"hints.clear": "Очистить",
"hints.close": "Закрыть",
@@ -316,6 +332,19 @@
"hints.top": "Вверх",
"hints.type": "Ввести",
"hints.view": "Просмотр",
"homebrew.about": "Нативные приложения и игры для PS5 из каталога сообщества {source}. Каждое скачивается из релиза разработчика на GitHub и проверяется перед установкой.",
"homebrew.appFailed": "Сейчас не удаётся прочитать сведения об этом приложении, или его больше нет в каталоге. Попробуйте чуть позже.",
"homebrew.count": "{count, plural, one {# приложение} few {# приложения} many {# приложений} other {# приложения}}",
"homebrew.failed": "Каталог хоумбрю сейчас недоступен. Проверьте подключение консоли и попробуйте снова.",
"homebrew.kind.all": "Все",
"homebrew.kind.app": "Приложения",
"homebrew.kind.game": "Игры",
"homebrew.kind.tool": "Инструменты",
"homebrew.releaseNotes": "Что нового в {version}",
"homebrew.retry": "Повторить",
"homebrew.sandbox.leaves": "Полный доступ",
"homebrew.sandbox.stays": "В песочнице",
"homebrew.sandbox.unclear": "Неясно",
"install.added": "Добавлено в загрузки",
"install.complete": "Загрузка завершена",
"install.manage": "Управлять в Загрузках",
@@ -338,9 +367,11 @@
"language.name.ja": "Японский",
"language.name.nl": "Нидерландский",
"language.name.pt-BR": "Португальский (Бразилия)",
"language.name.pt-PT": "Португальский (Португалия)",
"language.name.ru": "Русский",
"language.name.ur": "Урду",
"language.name.zh-Hans": "Китайский (упрощённый)",
"language.name.zh-Hant": "Китайский (традиционный)",
"language.subtitle": "Названия игр и описания остаются такими, как в каталоге.",
"language.system": "Язык системы",
"language.title": "Язык",
@@ -348,7 +379,7 @@
"nav.browse": "Каталог",
"nav.discover": "Обзор",
"nav.files": "Файлы",
"nav.new": "Новинки",
"nav.homebrew": "Хоумбрю",
"nav.search": "Поиск",
"nav.top": "Топ",
"search.noMatch": "Нет игр, совпадающих с \"{query}\"",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {تمام حصے} other {آرکائیو}} تصدیق شدہ اور نکالے گئے۔ انسٹال کے لیے تیار۔",
"downloads.extractedTo": "{path} میں نکالا گیا",
"downloads.extractedNothing": "{path} میں نکالا گیا، لیکن آرکائیو میں انسٹال کرنے کے لیے کوئی گیم نہیں (نہ PKG نہ گیم فولڈر)۔ یہ ڈاؤن لوڈ ہٹائیں اور کوئی اور ماخذ چنیں۔",
"downloads.filesExtracted": "ایکسٹریکٹ کی گئی فائلیں",
"downloads.installFailed": "کنسول نے پیکج مسترد کر دیا۔ دوبارہ کوشش کے لیے انسٹال دبائیں۔",
"downloads.installHint": "ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
"downloads.installRequested": "انسٹال درخواست دی گئی۔ ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
@@ -154,6 +155,7 @@
"drives.thisDevice": "یہ ڈیوائس",
"drives.usb": "USB ڈرائیو {number}",
"errors.action.checkConnection": "کنسول کے انٹرنیٹ کنکشن کی جانچ کریں، پھر دوبارہ کوشش کریں۔",
"errors.action.closeAppsRetry": "دوسری ایپس اور گیمز بند کریں، پھر دوبارہ کوشش کریں۔",
"errors.action.freeOrOtherDrive": "جگہ خالی کریں، یا محفوظ کریں تحت میں کوئی دوسری ڈرائیو منتخب کریں۔",
"errors.action.freeThenResume": "ڈرائیو پر جگہ خالی کریں، پھر دوبارہ شروع کریں۔",
"errors.action.freeThenRetry": "ڈرائیو پر جگہ خالی کریں، پھر دوبارہ کوشش کریں، یا منسوخ کریں اور کہیں اور محفوظ کریں۔",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "کچھ منٹوں میں دوبارہ کوشش کریں۔",
"errors.action.retryOrOtherSource": "دوبارہ کوشش کریں، یا کوئی دوسرا ذریعہ منتخب کریں۔",
"errors.action.retryPart": "وہ حصہ دوبارہ ڈاؤن لوڈ کرنے کے لیے دوبارہ کوشش کریں۔",
"errors.action.startLoaderRetry": "payload loader (etaHEN یا کوئی elfldr) چلائیں، پھر دوبارہ کوشش کریں۔",
"errors.archiveNoLoader": "اس آرکائیو کو نکالنے کے لیے پورٹ 9021 پر payload loader چاہیے",
"errors.archivePassword": "آرکائیو کا پاس ورڈ کام نہیں کر رہا",
"errors.archivePasswordMissing": "اس آرکائیو کو ایسا پاس ورڈ چاہیے جو ماخذ میں نہیں دیا گیا",
"errors.archivePasswordUnknown": "کوئی بھی معلوم پاس ورڈ یہ آرکائیو نہیں کھولتا",
"errors.action.passwordKeepFiles": "ڈاؤن لوڈ کی گئی فائلیں محفوظ رہتی ہیں: کوئی اور ماخذ چنیں، یا Overdrive اپ ڈیٹ کرنے کے بعد دوبارہ کوشش کریں۔",
"errors.archiveTooLarge": "یہ آرکائیو کنسول پر نہیں نکالی جا سکتی",
"errors.consoleMemoryBusy": "اس وقت کنسول کی خالی میموری کم ہے",
"errors.damaged": "فائل خراب آئی",
"errors.driveDisconnected": "ڈرائیو منقطع ہے",
"errors.fileChanged": "فائل سرور پر بدل گئی",
@@ -257,6 +262,7 @@
"game.addToFavorites": "پسندیدہ میں شامل کریں",
"game.back.browse": "لائبریری",
"game.back.downloads": "ڈاؤن لوڈز",
"game.back.homebrew": "ہوم برو",
"game.back.search": "تلاش",
"game.details": "تفصیلات",
"game.facts.downloadSize": "ڈاؤن لوڈ سائز",
@@ -274,17 +280,23 @@
"game.showMore": "مزید دیکھیں",
"genre.action": "ایکشن",
"genre.adventure": "مہم جوئی",
"genre.ai": "اے آئی",
"genre.arcade": "Arcade",
"genre.boardGame": "بورڈ گیم",
"genre.brainTraining": "دماغ کی تربیت",
"genre.casual": "عارضی",
"genre.consolePorts": "کنسول پورٹس",
"genre.educational": "تعلیمی",
"genre.emulators": "ایمولیٹرز",
"genre.family": "خاندان",
"genre.fighting": "لڑائی",
"genre.homebrewGames": "ہوم برو گیمز",
"genre.horror": "خوف ناک",
"genre.indie": "Indie",
"genre.music": "موسیقی",
"genre.musicRhythm": "موسیقی/تال",
"genre.party": "پارٹی",
"genre.pcPorts": "پی سی پورٹس",
"genre.puzzle": "پزل",
"genre.quiz": "کوئز",
"genre.racing": "ریسنگ",
@@ -294,6 +306,10 @@
"genre.sport": "کھیل",
"genre.sports": "کھیل",
"genre.strategy": "حکمت عملی",
"genre.streaming": "اسٹریمنگ",
"genre.tools": "ٹولز",
"genre.utilities": "یوٹیلٹیز",
"genre.video": "ویڈیو",
"hints.back": "پیچھے",
"hints.clear": "صاف کریں",
"hints.close": "بند کریں",
@@ -315,6 +331,19 @@
"hints.top": "اوپر",
"hints.type": "قسم",
"hints.view": "دیکھیں",
"homebrew.about": "{source} کے کمیونٹی کیٹلاگ سے PS5 کی نیٹو ایپس اور گیمز۔ ہر ایک اپنے ڈویلپر کی GitHub ریلیز سے ڈاؤن لوڈ ہوتی ہے اور انسٹال سے پہلے جانچی جاتی ہے۔",
"homebrew.appFailed": "ابھی اس ایپ کی تفصیلات نہیں پڑھی جا سکتیں، یا یہ اب کیٹلاگ میں نہیں ہے۔ تھوڑی دیر بعد دوبارہ کوشش کریں۔",
"homebrew.count": "{count, plural, one {# ایپ} other {# ایپس}}",
"homebrew.failed": "ابھی ہوم برو کیٹلاگ تک رسائی نہیں ہو رہی۔ کنسول کا کنکشن چیک کریں اور دوبارہ کوشش کریں۔",
"homebrew.kind.all": "سب",
"homebrew.kind.app": "ایپس",
"homebrew.kind.game": "گیمز",
"homebrew.kind.tool": "ٹولز",
"homebrew.releaseNotes": "{version} میں نیا کیا ہے",
"homebrew.retry": "دوبارہ کوشش کریں",
"homebrew.sandbox.leaves": "مکمل رسائی",
"homebrew.sandbox.stays": "سینڈ باکس کے اندر",
"homebrew.sandbox.unclear": "غیر واضح",
"install.added": "ڈاؤن لوڈز میں شامل کیا گیا",
"install.complete": "ڈاؤن لوڈ مکمل ہو گیا",
"install.manage": "ڈاؤن لوڈز میں اسے منظم کریں",
@@ -337,9 +366,11 @@
"language.name.ja": "جاپانی",
"language.name.nl": "ڈچ",
"language.name.pt-BR": "پرتگالی (برازیل)",
"language.name.pt-PT": "پرتگالی (پرتگال)",
"language.name.ru": "روسی",
"language.name.ur": "اردو",
"language.name.zh-Hans": "چینی (سادہ)",
"language.name.zh-Hant": "چینی (روایتی)",
"language.subtitle": "گیم کے عناوین اور تفصیلات اسی طرح رہتی ہیں جیسے سٹور انہیں درج کرتا ہے۔",
"language.system": "سسٹم زبان",
"language.title": "زبان",
@@ -347,7 +378,7 @@
"nav.browse": "براؤز کریں",
"nav.discover": "دریافت کریں",
"nav.files": "فائلیں",
"nav.new": "نئی ریلیز",
"nav.homebrew": "ہوم برو",
"nav.search": "تلاش",
"nav.top": "بہترین",
"search.noMatch": "\"{query}\" سے کوئی گیم مماثل نہیں",
+32 -1
View File
@@ -110,6 +110,7 @@
"downloads.extractedReady": "{parts, select, all {所有分卷} other {压缩包}}已验证并解压。可以安装。",
"downloads.extractedTo": "已解压到 {path}",
"downloads.extractedNothing": "已解压到 {path},但压缩包中没有可安装的游戏(没有 PKG,也没有游戏文件夹)。请移除此下载并选择其他来源。",
"downloads.filesExtracted": "已解压文件",
"downloads.installFailed": "主机拒绝了此安装包。按“安装”重试。",
"downloads.installHint": "请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
"downloads.installRequested": "已请求安装。请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
@@ -154,6 +155,7 @@
"drives.thisDevice": "本设备",
"drives.usb": "USB 存储设备 {number}",
"errors.action.checkConnection": "请检查主机的网络连接,然后重试。",
"errors.action.closeAppsRetry": "请关闭其他应用和游戏后重试。",
"errors.action.freeOrOtherDrive": "请释放空间,或在“保存到”中选择其他存储设备。",
"errors.action.freeThenResume": "请释放存储设备上的空间,然后继续。",
"errors.action.freeThenRetry": "请释放存储设备上的空间后重试,或取消并保存到其他位置。",
@@ -168,11 +170,14 @@
"errors.action.retryLater": "请几分钟后重试。",
"errors.action.retryOrOtherSource": "请重试,或选择其他来源。",
"errors.action.retryPart": "重试即可重新下载该部分。",
"errors.action.startLoaderRetry": "请启动 payload loader(etaHEN 或 elfldr)后重试。",
"errors.archiveNoLoader": "解压此压缩包需要端口 9021 上的 payload loader",
"errors.archivePassword": "压缩包密码无效",
"errors.archivePasswordMissing": "此压缩包需要密码,但来源未提供",
"errors.archivePasswordUnknown": "已知密码都无法打开此压缩包",
"errors.action.passwordKeepFiles": "已下载的文件会保留:请选择其他来源,或在更新 Overdrive 后重试。",
"errors.archiveTooLarge": "此压缩包无法在主机上解压",
"errors.consoleMemoryBusy": "主机当前可用内存不足",
"errors.damaged": "收到的文件已损坏",
"errors.driveDisconnected": "存储设备已断开",
"errors.fileChanged": "服务器上的文件已更改",
@@ -257,6 +262,7 @@
"game.addToFavorites": "添加到收藏",
"game.back.browse": "游戏库",
"game.back.downloads": "下载",
"game.back.homebrew": "自制软件",
"game.back.search": "搜索",
"game.details": "详情",
"game.facts.downloadSize": "下载大小",
@@ -274,17 +280,23 @@
"game.showMore": "展开",
"genre.action": "动作",
"genre.adventure": "冒险",
"genre.ai": "AI",
"genre.arcade": "街机",
"genre.boardGame": "桌游",
"genre.brainTraining": "脑力训练",
"genre.casual": "休闲",
"genre.consolePorts": "主机移植",
"genre.educational": "教育",
"genre.emulators": "模拟器",
"genre.family": "家庭",
"genre.fighting": "格斗",
"genre.homebrewGames": "自制游戏",
"genre.horror": "恐怖",
"genre.indie": "独立",
"genre.music": "音乐",
"genre.musicRhythm": "音乐/节奏",
"genre.party": "聚会",
"genre.pcPorts": "PC 移植",
"genre.puzzle": "益智",
"genre.quiz": "问答",
"genre.racing": "赛车",
@@ -294,6 +306,10 @@
"genre.sport": "体育",
"genre.sports": "运动",
"genre.strategy": "策略",
"genre.streaming": "流媒体",
"genre.tools": "工具",
"genre.utilities": "实用工具",
"genre.video": "视频",
"hints.back": "返回",
"hints.clear": "清除",
"hints.close": "关闭",
@@ -315,6 +331,19 @@
"hints.top": "顶部",
"hints.type": "输入",
"hints.view": "查看",
"homebrew.about": "来自 {source} 社区目录的 PS5 原生应用和游戏。每一个都从开发者的 GitHub 发布版下载,并在安装前进行校验。",
"homebrew.appFailed": "暂时无法读取此应用的详情,或者它已不在目录中。请稍后重试。",
"homebrew.count": "{count, plural, other {# 款应用}}",
"homebrew.failed": "暂时无法访问自制软件目录。请检查主机的网络连接后重试。",
"homebrew.kind.all": "全部",
"homebrew.kind.app": "应用",
"homebrew.kind.game": "游戏",
"homebrew.kind.tool": "工具",
"homebrew.releaseNotes": "{version} 新内容",
"homebrew.retry": "重试",
"homebrew.sandbox.leaves": "完全访问主机",
"homebrew.sandbox.stays": "留在沙盒内",
"homebrew.sandbox.unclear": "不确定",
"install.added": "已添加到下载",
"install.complete": "下载完成",
"install.manage": "在“下载”中管理",
@@ -337,9 +366,11 @@
"language.name.ja": "日语",
"language.name.nl": "荷兰语",
"language.name.pt-BR": "葡萄牙语(巴西)",
"language.name.pt-PT": "葡萄牙语(葡萄牙)",
"language.name.ru": "俄语",
"language.name.ur": "乌尔都语",
"language.name.zh-Hans": "简体中文",
"language.name.zh-Hant": "繁体中文",
"language.subtitle": "游戏标题和简介保持商店中的原样。",
"language.system": "系统语言",
"language.title": "语言",
@@ -347,7 +378,7 @@
"nav.browse": "浏览",
"nav.discover": "发现",
"nav.files": "文件",
"nav.new": "新品",
"nav.homebrew": "自制软件",
"nav.search": "搜索",
"nav.top": "评分最高",
"search.noMatch": "没有符合“{query}”的游戏",
+514
View File
@@ -0,0 +1,514 @@
{
"browse.addToFavorites": "加入收藏",
"browse.continueDownloading": "繼續下載",
"browse.continueFailed": "失敗 · 開啟以重試",
"browse.favorited": "已收藏",
"browse.folderInstall": "資料夾安裝即將停止接收更新。請手動安裝單一檔案版本",
"browse.footer": "Spectrum 遊戲庫",
"browse.popularGenres": "熱門類型",
"browse.popularNow": "目前熱門",
"browse.forYou": "你可能會喜歡",
"browse.recentlyViewed": "最近瀏覽",
"browse.reviews": "{count} 則評價",
"browse.viewGame": "查看遊戲",
"catalog.empty.browseAll": "瀏覽完整遊戲庫",
"catalog.empty.filtersHint": "移除上方的篩選條件,或開啟「篩選」進行變更。",
"catalog.empty.filtersTitle": "沒有符合這些篩選條件的遊戲",
"catalog.empty.queryHint": "請檢查拼字、移除篩選條件,或嘗試其他遊戲名稱。",
"catalog.empty.queryTitle": "找不到與「{query}」相關的內容",
"catalog.empty.wishlistHint": "開啟遊戲並選擇「加入收藏」,即可儲存在這裡。",
"catalog.empty.wishlistTitle": "還沒有收藏",
"catalog.favorites": "我的收藏",
"catalog.gameCount": "{count, plural, other {# 款遊戲}}",
"catalog.library": "瀏覽遊戲庫",
"catalog.resultsFor": "「{query}」的搜尋結果",
"common.seeAll": "查看全部",
"download.browser.action": "開啟此頁面並完成驗證,然後在下方輸入檔案連結。",
"download.browser.partTitle": "第 {part} 部分(共 {parts} 部分)需要先開啟其下載網站頁面",
"download.browser.title": "此下載網站需要先開啟網頁",
"download.button.awaiting-browser": "開啟網站頁面",
"download.button.downloaded": "使用 ShadowMount 掃描",
"download.button.extracted": "檔案已解壓縮",
"download.button.extracting": "解壓縮中",
"download.button.extraction-required": "壓縮檔已下載",
"download.button.failed": "重試",
"download.button.file-downloaded": "檔案已下載",
"download.button.finishing": "即將完成",
"download.button.idle": "下載",
"download.button.installed": "已安裝",
"download.button.installer-required": "安裝 PKG",
"download.button.installing": "正在安裝",
"download.button.no-space": "空間不足",
"download.button.pause": "暫停",
"download.button.paused": "繼續",
"download.button.pausing": "正在暫停",
"download.button.queued": "暫停佇列中的下載",
"download.button.removing": "正在刪除檔案",
"download.button.stale": "重新下載",
"download.button.unavailable": "無下載來源",
"download.button.verifying": "驗證中",
"download.enterLink": "輸入檔案連結",
"download.enterPartLink": "輸入第 {part} 部分(共 {parts} 部分)的檔案連結",
"download.fileLink": "檔案連結",
"download.fileLinkDetail": "請輸入檔案本身的連結,也就是完成網站驗證後實際開始下載的連結,而不是網頁網址。",
"download.free": "可用 {size}",
"download.moreOptions": "更多選項",
"download.noFit": "此儲存裝置空間不足",
"download.openQueue": "開啟下載項目",
"download.optionsTitle": "下載選項",
"download.otherSource.detail": "選擇其他下載來源或版本。",
"download.otherSource.label": "選擇其他來源",
"download.partFileLink": "第 {part} 部分(共 {parts} 部分):檔案連結",
"download.remove.detail": "停止下載,並保留儲存裝置上已下載的檔案。",
"download.remove.label": "從佇列移除",
"download.restart.detail": "重新開始下載。",
"download.restart.detailSaved": "刪除已下載的 {size},並重新開始。",
"download.restart.label": "從頭重新下載",
"download.saveTo": "儲存至 {drive}",
"download.soFar": "已下載 {size}",
"download.source": "來源:{source}",
"download.space.free": "請先在 {drive} 釋出 {size} 空間,然後重試。",
"download.space.otherDrive": "{drive} 有 {size} 可用空間:請在「儲存至」中選擇它。",
"download.status.awaitingLink": "等待檔案連結",
"download.status.needs": "需要 {size}",
"download.status.noSpace": "此儲存裝置空間不足",
"download.status.progress": "{done} / {total}",
"download.status.ready": "可以下載",
"download.status.unavailable": "此項目沒有可用的檔案 URL",
"downloadState.awaiting-browser": "等待瀏覽器驗證",
"downloadState.connecting": "連線中…",
"downloadState.downloaded": "可以安裝",
"downloadState.downloading": "下載中",
"downloadState.extracted": "檔案已解壓縮",
"downloadState.extracting": "正在解壓縮已下載的檔案…",
"downloadState.extraction-required": "已下載;請解壓縮壓縮檔",
"downloadState.failed": "失敗",
"downloadState.file-downloaded": "檔案已下載;請檢查檔案格式",
"downloadState.finishing": "正在完成…",
"downloadState.installed": "已安裝",
"downloadState.installer-required": "已下載,可以安裝",
"downloadState.installing": "正在安裝",
"downloadState.paused": "已暫停",
"downloadState.pausing": "正在暫停…",
"downloadState.queued": "佇列中",
"downloadState.removing": "正在刪除檔案…",
"downloadState.resolving": "連線中…",
"downloadState.verifying": "正在驗證檔案雜湊值…",
"downloads.action.continueOnPage": "繼續",
"downloads.action.gamePage": "遊戲頁面",
"downloads.action.install": "安裝",
"downloads.action.pause": "暫停",
"downloads.action.pausing": "正在暫停",
"downloads.action.restart": "重新開始",
"downloads.action.resume": "繼續",
"downloads.action.retry": "重試",
"downloads.afterVerification": "完成驗證後,請返回此處並輸入最終的檔案 URL。",
"downloads.archiveNotDeleted": "無法刪除已下載的壓縮檔:{error}",
"downloads.browseGames": "瀏覽遊戲",
"downloads.empty": "沒有下載內容",
"downloads.emptyDetail": "你下載的遊戲與進度會顯示在這裡。",
"downloads.extractedReady": "{parts, select, all {所有分割檔} other {壓縮檔}}已驗證並解壓縮,可以安裝。",
"downloads.extractedTo": "已解壓縮至 {path}",
"downloads.extractedNothing": "已解壓縮至 {path},但壓縮檔中沒有可安裝的遊戲(找不到 PKG 或遊戲資料夾)。請移除此下載項目並選擇其他來源。",
"downloads.extractedLeft": "其他資料夾仍保留在 {path}。",
"downloads.filesExtracted": "檔案已解壓縮",
"downloads.installFailed": "主機拒絕此安裝檔。按「安裝」重試。",
"downloads.installHint": "請關閉 Overdrive 以完成安裝:沒有遊戲執行時,ShadowMount 才會開始安裝。",
"downloads.titleMismatch": "檔案是 {found},遊戲是 {expected}:可能是其他遊戲,或不同地區版本?",
"downloads.installRequested": "已送出安裝要求。請關閉 Overdrive 以完成安裝:沒有遊戲執行時,ShadowMount 才會開始安裝。",
"downloads.installed": "已安裝,可在主畫面找到。",
"downloads.installedFreed": "已安裝。PKG 已刪除,已釋放 {size}。",
"downloads.notice.captured": "已擷取連結,請返回 Overdrive",
"downloads.notice.complete": "下載完成",
"downloads.notice.failed": "下載失敗",
"downloads.notice.installed": "遊戲已安裝",
"downloads.packagesExtracted": "安裝檔已解壓縮至 {path}。需要 PKG 安裝程式。",
"downloads.panel.activity": "活動",
"downloads.panel.average": "平均",
"downloads.panel.awake": "下載完成前,主機會保持喚醒狀態。",
"downloads.panel.connections": "連線數",
"downloads.panel.downloading": "下載中",
"downloads.panel.idle": "閒置",
"downloads.panel.installNote": "已完成的遊戲會透過 ShadowMount 安裝;它會等到沒有遊戲執行時才開始。請關閉 Overdrive 讓安裝完成。",
"downloads.panel.installing": "安裝中",
"downloads.panel.low": "偏低",
"downloads.panel.none": "無",
"downloads.panel.peak": "峰值",
"downloads.panel.storage": "儲存空間",
"downloads.panel.timeLeft": "剩餘時間",
"downloads.panel.unavailable": "不可用",
"downloads.panel.used": "已使用 {used} / {total}",
"downloads.part": "第 {part} 部分(共 {parts} 部分)",
"downloads.problem": "{title}。{action}",
"downloads.section.active": "進行中",
"downloads.section.completed": "已完成",
"downloads.section.stopped": "已停止",
"downloads.stats.download": "下載",
"downloads.stats.write": "寫入",
"downloads.summary.completed": "{count} 個已完成",
"downloads.summary.failed": "{count} 個失敗",
"downloads.summary.inProgress": "{count} 個進行中",
"downloads.summary.paused": "{count} 個已暫停",
"downloads.timeLeft": "剩餘 {time}",
"downloads.title": "下載",
"downloads.unavailable": "下載功能不可用",
"downloads.writingToDisk": "正在寫入磁碟…",
"drives.extended": "擴充儲存空間 {number}",
"drives.internal": "主機儲存空間",
"drives.thisDevice": "此裝置",
"drives.usb": "USB 儲存裝置 {number}",
"errors.action.checkConnection": "請檢查主機的網路連線,然後重試。",
"errors.action.closeAppsRetry": "請關閉其他應用程式和遊戲,然後重試。",
"errors.action.freeOrOtherDrive": "請釋出空間,或在「儲存至」中選擇其他儲存裝置。",
"errors.action.freeThenResume": "請先釋出儲存裝置空間,然後繼續。",
"errors.action.freeThenRetry": "請先釋出儲存裝置空間後重試,或取消後改存到其他位置。",
"errors.action.openSource": "請開啟來源頁面,然後輸入其最終的檔案 URL。",
"errors.action.otherSource": "請取消並選擇其他來源。",
"errors.action.installByHand": "請使用 etaHEN 或 onionHEN,從 downloads/packages 手動安裝。",
"errors.action.reconnect": "請重新連接原本的儲存裝置,然後繼續。",
"errors.action.reopenApp": "請關閉並重新開啟 Overdrive,然後重試。",
"errors.action.restart": "請重新開始以再次下載。",
"errors.action.restartFromZero": "請從頭重新開始。",
"errors.action.resume": "請選擇繼續。",
"errors.action.retryLater": "請幾分鐘後重試。",
"errors.action.retryOrOtherSource": "請重試,或選擇其他來源。",
"errors.action.retryPart": "重試即可重新下載該部分。",
"errors.action.startLoaderRetry": "請啟動 Payload Loader(etaHEN 或 elfldr),然後重試。",
"errors.archiveNoLoader": "此壓縮檔需要連接埠 9021 上的 Payload Loader 才能解壓縮",
"errors.archivePassword": "壓縮檔密碼無效",
"errors.archivePasswordMissing": "此壓縮檔需要密碼,但來源未提供密碼。",
"errors.archivePasswordUnknown": "已知的密碼都無法開啟此壓縮檔。",
"errors.action.passwordKeepFiles": "已下載的檔案會保留:請選擇其他來源,或更新 Overdrive 後再重試。",
"errors.archiveTooLarge": "此壓縮檔無法在主機上解壓縮",
"errors.consoleMemoryBusy": "主機目前的可用記憶體不足",
"errors.damaged": "收到的檔案已損毀",
"errors.driveDisconnected": "儲存裝置已中斷連線",
"errors.fileChanged": "伺服器上的檔案已變更",
"errors.interrupted": "應用程式關閉時已停止",
"errors.linkGone": "下載連結已失效",
"errors.notGameFile": "來源網站傳回的是網頁,而不是遊戲檔案",
"errors.pkgFirmware": "PS5 假 PKG 僅能在 11.60 或更舊的韌體上安裝",
"errors.pkgNoLoader": "PKG 已準備完成,但無法自動安裝:連接埠 9021 上沒有 Payload Loader。",
"errors.missingParts": "此來源缺少部分檔案",
"errors.needsBrowser": "此來源需要網頁瀏覽器",
"errors.noConnection": "無法連線至伺服器",
"errors.noMemory": "記憶體不足,無法解壓縮",
"errors.noSpace": "可用空間不足",
"errors.noSpaceExtract": "可用空間不足,無法解壓縮此遊戲。",
"errors.action.freeThenRetryExtract": "請先釋放儲存裝置空間,再選擇「重試」。已下載的檔案會保留。",
"errors.noSpaceOn": "{root} 空間不足:需要 {need},可用 {free}{queued, select, yes {(佇列中的下載完成後)} other {}}",
"errors.partIncomplete": "第 {part} 部分似乎不完整",
"errors.partialDeleted": "已下載的檔案已被刪除",
"errors.serverBusy": "伺服器忙碌中",
"errors.staleProgress": "已儲存的進度無法繼續",
"feature.cloudSaves": "雲端存檔",
"feature.coOp": "合作",
"feature.crossPlay": "跨平台遊玩",
"feature.dualsenseFeatures": "DualSense 功能",
"feature.onlineMultiplayer": "線上多人遊玩",
"feature.singlePlayer": "單人遊玩",
"files.byShadowMount": "由 ShadowMount 安裝",
"files.confirmDelete": "再按一次即可刪除 · {size}",
"files.deleteFailed": "無法刪除此檔案,請重試。",
"files.deleted": "已刪除 {name}",
"files.drives": "儲存裝置",
"files.empty": "這裡還沒有內容",
"files.emptyDetail": "下載內容會存放在這些資料夾中,透過 FTP 複製的檔案也會顯示在這裡。",
"files.free": "總容量 {total},可用 {free}",
"files.installDone": "已安裝,可在主畫面找到。",
"files.installFailed": "主機拒絕此安裝檔。請確認 kstuff 已載入,然後重試。",
"files.installFailedReason": "安裝失敗:{reason}",
"files.installed": "已安裝",
"files.installing": "正在安裝 · {percent}",
"files.kind.all": "所有檔案",
"files.kind.archives": "壓縮檔",
"files.kind.extracted": "已解壓縮",
"files.kind.images": "遊戲映像檔",
"files.kind.other": "其他檔案",
"files.kind.packages": "安裝檔",
"files.kinds": "顯示",
"files.notInstalled": "未安裝",
"files.otherData": "遊戲與系統",
"files.shadowMountOff": "ShadowMount 未執行",
"files.shadowMountOn": "ShadowMount 正在執行",
"files.summary": "{count, plural, other {# 個檔案}} · {size}",
"files.title": "檔案",
"files.type.archive": "壓縮檔",
"files.type.file": "檔案",
"files.type.folder": "資料夾",
"files.type.image": "遊戲映像檔",
"files.type.package": "PKG",
"files.waitingShadowMount": "等待 ShadowMount",
"filters.access.direct": "直接下載",
"filters.clearAll": "全部清除",
"filters.clearFacet": "清除",
"filters.facet.access": "下載",
"filters.facet.feature": "功能",
"filters.facet.genre": "類型",
"filters.facet.platform": "平台",
"filters.facet.provider": "下載網站",
"filters.facet.release": "發行時間",
"filters.facet.size": "大小",
"filters.favorites": "收藏",
"filters.more": "另有 {count} 項",
"filters.queryChip": "「{query}」",
"filters.release.current": "2020–2023",
"filters.release.older": "2020 年以前",
"filters.release.recent": "2024 年至今",
"filters.selected": "已選 {count} 項",
"filters.show": "顯示 {count, plural, other {# 款遊戲}}",
"filters.size.large": "超過 50 GB",
"filters.size.medium": "10–50 GB",
"filters.size.small": "少於 10 GB",
"filters.title": "篩選",
"game.about": "關於此遊戲",
"game.addToFavorites": "加入收藏",
"game.back.browse": "遊戲庫",
"game.back.downloads": "下載",
"game.back.homebrew": "自製軟體",
"game.back.search": "搜尋",
"game.details": "詳情",
"game.facts.developer": "開發商",
"game.facts.downloadSize": "下載容量",
"game.facts.firmware": "最低韌體版本",
"game.facts.installSize": "安裝容量",
"game.facts.license": "授權",
"game.facts.platform": "平台",
"game.facts.publisher": "發行商",
"game.facts.releaseDate": "發行日期",
"game.facts.sandbox": "沙盒",
"game.facts.titleId": "Title ID",
"game.facts.version": "版本",
"game.favorite": "收藏",
"game.features": "功能",
"game.metacritic": "Metacritic 評分",
"game.moreLikeThis": "類似遊戲",
"game.released": "{year} 年發行",
"game.shotPosition": "{index} / {total}",
"game.showLess": "收起",
"game.showMore": "展開",
"genre.action": "動作",
"genre.adventure": "冒險",
"genre.ai": "AI",
"genre.arcade": "街機",
"genre.boardGame": "桌上遊戲",
"genre.brainTraining": "腦力訓練",
"genre.casual": "休閒",
"genre.consolePorts": "主機移植",
"genre.educational": "教育",
"genre.emulators": "模擬器",
"genre.family": "家庭",
"genre.fighting": "格鬥",
"genre.homebrewGames": "自製遊戲",
"genre.horror": "恐怖",
"genre.indie": "獨立",
"genre.music": "音樂",
"genre.musicRhythm": "音樂/節奏",
"genre.party": "派對",
"genre.pcPorts": "PC 移植",
"genre.puzzle": "益智",
"genre.quiz": "問答",
"genre.racing": "賽車",
"genre.rpg": "角色扮演",
"genre.shooter": "射擊",
"genre.simulation": "模擬",
"genre.sport": "體育",
"genre.sports": "運動",
"genre.strategy": "策略",
"genre.streaming": "串流",
"genre.tools": "工具",
"genre.utilities": "公用程式",
"genre.video": "影片",
"hints.back": "返回",
"hints.clear": "清除",
"hints.close": "關閉",
"hints.confirm": "確認",
"hints.confirmDelete": "永久刪除",
"hints.delete": "刪除",
"hints.favorite": "收藏",
"hints.filters": "篩選",
"hints.gamePage": "遊戲頁面",
"hints.install": "安裝",
"hints.pauseResume": "暫停 / 繼續",
"hints.previousNext": "上一個 / 下一個",
"hints.remove": "移除",
"hints.search": "搜尋",
"hints.sections": "區段",
"hints.select": "選擇",
"hints.space": "空格",
"hints.toggle": "切換",
"hints.top": "最上方",
"hints.type": "輸入",
"hints.view": "查看",
"homebrew.about": "來自 {source} 社群目錄的 PS5 原生應用程式與遊戲。每一個都從開發者的 GitHub 發行版本下載,並在安裝前經過檢查。",
"homebrew.appFailed": "目前無法讀取此應用程式的詳細資料,或它已不在目錄中。請稍後再試。",
"homebrew.count": "{count, plural, other {# 個應用程式}}",
"homebrew.failed": "目前無法連線至自製軟體目錄。請檢查主機的網路連線,然後再試一次。",
"homebrew.kind.all": "全部",
"homebrew.kind.app": "應用程式",
"homebrew.kind.game": "遊戲",
"homebrew.kind.tool": "工具",
"homebrew.releaseNotes": "{version} 的新功能",
"homebrew.retry": "再試一次",
"homebrew.sandbox.leaves": "完整主機存取權",
"homebrew.sandbox.stays": "保持在沙盒中",
"homebrew.sandbox.unclear": "不確定",
"install.added": "已加入下載",
"install.complete": "下載完成",
"install.manage": "在「下載」中管理",
"keyboard.clear": "清除",
"keyboard.delete": "刪除",
"keyboard.done": "完成",
"keyboard.lowercase": "小寫",
"keyboard.space": "空格",
"keyboard.uppercase": "大寫",
"language.name.ar": "阿拉伯語",
"language.name.bn": "孟加拉語",
"language.name.de": "德語",
"language.name.en": "英語",
"language.name.es": "西班牙語",
"language.name.fa": "波斯語",
"language.name.fr": "法語",
"language.name.hi": "印地語",
"language.name.id": "印尼語",
"language.name.it": "義大利語",
"language.name.ja": "日語",
"language.name.nl": "荷蘭文",
"language.name.pt-BR": "葡萄牙語(巴西)",
"language.name.pt-PT": "葡萄牙文(葡萄牙)",
"language.name.ru": "俄語",
"language.name.ur": "烏爾都語",
"language.name.zh-Hans": "簡體中文",
"language.name.zh-Hant": "繁體中文",
"language.subtitle": "遊戲標題與簡介會維持商店原始內容。",
"language.system": "系統語言",
"language.title": "語言",
"nav.allGames": "全部遊戲",
"nav.browse": "瀏覽",
"nav.discover": "探索",
"nav.files": "檔案",
"nav.homebrew": "自製軟體",
"nav.search": "搜尋",
"nav.top": "評分最高",
"search.noMatch": "沒有符合「{query}」的遊戲",
"search.placeholder": "搜尋遊戲庫",
"search.popular": "熱門",
"search.recent": "最近搜尋",
"search.resultsFor": "「{query}」的搜尋結果",
"search.tryGenre": "請檢查拼字,或試試其他類型:",
"settings.controller": "控制器",
"settings.deletePackages": "安裝後刪除 PKG",
"settings.deletePackagesDetail": "主機安裝遊戲後會釋放空間。若要保留檔案,請關閉此選項。",
"settings.on": "開啟",
"settings.off": "關閉",
"settings.downloads": "下載",
"settings.speedLimit": "限速",
"settings.speedLimitDetail": "為其他裝置保留頻寬。應用程式更新不受此限速影響。",
"settings.speedUnlimited": "不限速",
"settings.title": "設定",
"settings.vibration": "瀏覽時震動",
"settings.vibrationDetail": "移動、選取及返回時會有輕微震動",
"settings.vibrationOff": "關閉",
"shadowMount.offAction": "載入 ShadowMount 後遊戲才會出現。將它加入越獄環境的自動載入項目即可保持執行。",
"shadowMount.offTitle": "ShadowMount 未執行",
"sort.label": "排序",
"sort.newest": "新品",
"sort.rating": "評分最高",
"sort.relevance": "最受歡迎",
"sort.size": "安裝最大",
"sort.title": "A–Z",
"sources.browserNote": "每個部分都會開啟一個下載網站頁面,然後用控制器輸入其檔案連結。",
"sources.cdnFastest": "最快",
"sources.kind.archive": "壓縮檔",
"sources.kind.image": "遊戲映像檔",
"sources.kind.package": "安裝檔",
"sources.kind.patch": "僅有降版移植補丁檔案,不是完整遊戲",
"sources.status.dlc": "DLC:需要遊戲本體",
"sources.status.package": "PS5 假 PKG 僅能在 11.60 或更舊的韌體上安裝",
"sources.status.patch": "需要遊戲本體,無法單獨安裝",
"sources.noDetails": "無詳情",
"sources.parts": "{count, plural, other {# 個部分}}",
"sources.recommended": "推薦",
"sources.section.browser": "在網頁瀏覽器中開啟",
"sources.section.direct": "在 Overdrive 中下載",
"sources.section.dlc": "追加內容(DLC)",
"sources.section.dlcNote": "遊戲的額外內容,不包含遊戲本體。",
"sources.section.page": "來自下載網站頁面",
"sources.section.pageNote": "Overdrive 會從頁面讀取檔案連結。如果網站要求驗證,會帶你前往完成驗證。",
"sources.section.versions": "版本",
"sources.showBrowser": "{count, plural, other {顯示 # 個網頁瀏覽器來源}}",
"sources.speed.archive": "支援多連線同時下載",
"sources.speed.mediafire": "速度上限約 3 MB/s",
"sources.title": "下載來源",
"time.hoursMinutes": "{hours} 小時 {minutes} 分鐘",
"time.minutes": "{n} 分鐘",
"time.seconds": "{n} 秒",
"toast.openDownloads": "開啟下載項目",
"toast.viewGame": "查看遊戲",
"tips.eyebrow": "下載",
"tips.title": "下載之前",
"tips.downloading": "你正在下載",
"tips.about": "關於此來源",
"tips.toggle": "選擇來源時顯示提示",
"tips.continue": "繼續",
"tips.changeSource": "更換來源",
"tips.note.dlc": "這是 DLC,不是遊戲本體。請先安裝遊戲本體。",
"tips.note.patch": "這些只是降版移植補丁檔案,不是完整遊戲,無法單獨安裝。",
"tips.note.package": "這是 PS5 假 PKG,只能安裝在 11.60 或更舊的韌體上。",
"tips.note.browserViking": "此來源會在 PS5 瀏覽器中開啟。完成驗證後請等待,不要按下 Download,也不要點選任何廣告。",
"tips.note.browserOther": "此來源會在 PS5 瀏覽器中開啟,之後會要求輸入檔案連結。若可以,請優先選擇清單中較上方的來源。",
"tips.note.firmware": "此版本需要 {need} 或更新的韌體。請在「設定 > 系統 > 系統軟體」查看目前版本。",
"tips.tip.firmware.title": "檢查韌體",
"tips.tip.firmware.body": "Base 8.xx 需要 8.xx 或更新版本。BackPort 適用於較舊韌體的主機。",
"tips.tip.fullGame.title": "選擇完整遊戲",
"tips.tip.fullGame.body": "選擇 Base 或 BackPort,不要選 DLC 或降版移植補丁檔案。",
"tips.tip.top.title": "從最上方開始",
"tips.tip.top.body": "越上方的來源通常越可靠。",
"tips.tip.retry.title": "失敗了?換個來源",
"tips.tip.retry.body": "大多數遊戲都有多個來源。",
"units.gigabytes": "{n} GB",
"units.megabytes": "{n} MB",
"units.millions": "{n}M",
"units.speed": "{n} MB/s",
"units.thousands": "{n}K",
"update.available": "有可用更新",
"update.close": "關閉",
"update.detail": "新版 Overdrive 已準備完成。更新會直接覆蓋目前版本,完成後會自動重新開啟。",
"update.done": "更新完成",
"update.failed": "更新失敗",
"update.install": "立即更新",
"update.installing": "正在安裝…",
"update.later": "稍後",
"update.quit": "關閉 Overdrive",
"update.reopen": "正在關閉。請從主畫面重新開啟 Overdrive",
"update.reopenInstalled": "Overdrive 已更新至 v{version}。請關閉它,然後從主畫面重新開啟。",
"update.replaceByHand": "請從 github.com/MyLuxy/overdrive 下載 update.zip,關閉 Overdrive,然後用 .exfat 檔案覆蓋舊檔案。",
"update.replaceFailed": "Overdrive 執行時無法替換已安裝的映像檔。",
"update.restartConsole": "如果仍然開啟舊版本,請重新啟動主機。",
"update.restarting": "正在重新啟動 Overdrive…",
"update.retry": "再試一次",
"update.updating": "正在更新",
"urlDialog.cancel": "取消",
"urlDialog.confirm": "確認 URL",
"urlDialog.controls": "方塊鍵:刪除 · R2:確認 · 圓圈鍵:取消",
"urlDialog.empty": "(空)",
"versions.name": "{platform} 版本",
"versions.note.ps4": "透過向下相容在 PS5 上執行",
"versions.note.ps5": "在 PS5 上原生執行",
"versions.recommended": "推薦 · 在 PS5 上原生執行",
"versions.sources": "{count, plural, other {# 個來源}}",
"versions.thisPage": "本頁",
"versions.title": "選擇版本",
"capture.title": "Vikingfile 驗證",
"capture.preparing": "正在準備檔案驗證…",
"capture.capturing": "完成驗證後,選擇「下載」",
"capture.validating": "正在檢查擷取到的檔案…",
"capture.detail": "不要按「下載」或任何廣告;連結會自動擷取並開始下載。你有 3 分鐘。",
"capture.link.title": "下載驗證",
"capture.link.detail": "完成驗證後,連結會自動擷取。請取消瀏覽器自行開始的任何下載。你有 5 分鐘。",
"capture.confirm.title": "這是正確的檔案嗎?",
"capture.confirm.detail": "此檔案來源沒有標示這是哪一款遊戲。只有在確定是你選擇的遊戲時才下載。",
"capture.confirm.noName": "未命名檔案",
"capture.confirm.accept": "下載",
"capture.confirm.refuse": "繼續尋找"
}
+4 -1
View File
@@ -1,6 +1,6 @@
// ../pub-game-store/native/i18n/plural.js
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: Italian plural rule.
// MODIFIED: Italian and European Portuguese plural rules.
function operands(n) {
@@ -20,6 +20,8 @@
it: (o) => o.i === 1 && o.v === 0 ? "one" : millions(o) ? "many" : "other",
fr: (o) => o.i === 0 || o.i === 1 ? "one" : millions(o) ? "many" : "other",
"pt-BR": (o) => o.i === 0 || o.i === 1 ? "one" : millions(o) ? "many" : "other",
// Unlike Brazil's, Portugal's "one" is 1 alone: "0 jogos".
"pt-PT": (o) => o.i === 1 && o.v === 0 ? "one" : millions(o) ? "many" : "other",
ru: ({ i, v }) => {
if (v !== 0) return "other";
const mod10 = i % 10, mod100 = i % 100;
@@ -37,6 +39,7 @@
return "other";
},
"zh-Hans": () => "other",
"zh-Hant": () => "other",
ja: () => "other",
id: () => "other"
};
+27 -11
View File
@@ -3,15 +3,16 @@
import { jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useState } from 'react';
import { AppRegistry, FocusScope, View } from '@ps5-react/core';
import { AppRegistry, FocusScope, ReleaseImages, View } from '@ps5-react/core';
import { BrowsePage } from './browse/BrowsePage.jsx';
import { DownloadsScreen } from './downloads/DownloadsScreen.jsx';
import { FilesScreen } from './files/FilesScreen.jsx';
import { GameScreen } from './game/index.jsx';
import { HomebrewGame, HomebrewScreen } from './homebrew/HomebrewScreen.jsx';
import { LocaleProvider, useLocale } from './i18n/index.js';
import { SearchScreen } from './search/index.jsx';
import { getGame, warmCovers } from './state/catalog.js';
import { refreshHomeArt } from './state/home-art.js';
import { NO_FILTERS, getGame, warmCovers } from './state/catalog.js';
import { isHomebrewId } from './state/homebrew.js';
import { NavProvider, useIsImmersive, useNavActions, useNavState } from './state/nav.jsx';
import { StoreFreeze, StoreProvider } from './state/store.jsx';
import { Header } from './ui/Header.jsx';
@@ -20,32 +21,46 @@ import { Splash } from './ui/Splash.jsx';
import { UpdateDialog } from './ui/UpdateDialog.jsx';
import { useInterfaceHaptics } from './ui/haptics.js';
import { useInterfaceSounds } from './ui/sounds.js';
import { useShowLibrary } from './ui/useSections.js';
import { VERSION } from './version.js';
function Screen({ screen: screen2 }) {
const { openGame, pop } = useNavActions();
// "Browse games" on an empty Downloads opens the store front as Discover does, whatever section
// (Favorites, Top rated) the store was left on.
const showLibrary = useShowLibrary();
const openCard = (game) => openGame(game.id, `card:${game.id}`);
// A homebrew app's page reads its file from the catalog first, and says so when the app is gone.
if (screen2.name === "game" && isHomebrewId(screen2.id)) return /* @__PURE__ */ (0, jsx)(HomebrewGame, { game: getGame(screen2.id), download: screen2.download, onBack: pop, onOpenGame: openCard });
if (screen2.name === "game") return /* @__PURE__ */ (0, jsx)(GameScreen, { game: getGame(screen2.id), download: screen2.download, onBack: pop, onOpenGame: openCard });
if (screen2.name === "downloads") return /* @__PURE__ */ (0, jsx)(DownloadsScreen, { onBack: pop, onOpenGame: openGame });
if (screen2.name === "downloads") return /* @__PURE__ */ (0, jsx)(DownloadsScreen, { onBack: pop, onBrowse: () => showLibrary(NO_FILTERS, "newest", "spotlight:cta", "discover"), onOpenGame: openGame });
if (screen2.name === "files") return /* @__PURE__ */ (0, jsx)(FilesScreen, { onBack: pop });
return /* @__PURE__ */ (0, jsx)(SearchScreen, { onBack: pop, onOpenGame: openCard });
}
// The store front stays mounted under every other screen; while hidden it lets its images go (the
// decoded cache keeps them until another screen needs the room), so a game page or the Homebrew page
// is not left fighting a full cache for its own.
function BrowseLayer({ shown }) {
return /* @__PURE__ */ (0, jsx)(View, { style: shown ? { position: "absolute", left: 0, right: 0, top: 96, bottom: 60 } : { position: "absolute", left: 0, right: 0, top: 96, bottom: 60, display: "none" }, children: /* @__PURE__ */ (0, jsx)(StoreFreeze, { frozen: !shown, children: /* @__PURE__ */ (0, jsx)(FocusScope, { focusKey: "page", autoFocus: true, inert: !shown, children: /* @__PURE__ */ (0, jsx)(BrowsePage, {}) }) }) });
return /* @__PURE__ */ (0, jsx)(View, { style: shown ? { position: "absolute", left: 0, right: 0, top: 96, bottom: 60 } : { position: "absolute", left: 0, right: 0, top: 96, bottom: 60, display: "none" }, children: /* @__PURE__ */ (0, jsx)(ReleaseImages, { when: !shown, children: /* @__PURE__ */ (0, jsx)(StoreFreeze, { frozen: !shown, children: /* @__PURE__ */ (0, jsx)(FocusScope, { focusKey: "page", autoFocus: true, inert: !shown, children: /* @__PURE__ */ (0, jsx)(BrowsePage, {}) }) }) }) });
}
// The Homebrew page stays mounted while an app opened from it is on top, hidden like the store front,
// so Back finds it as it was left: its filter, its scroll and the card focus came from.
function HomebrewLayer({ shown }) {
const { pop } = useNavActions();
return /* @__PURE__ */ (0, jsx)(View, { style: shown ? { position: "absolute", left: 0, right: 0, top: 96, bottom: 60 } : { position: "absolute", left: 0, right: 0, top: 96, bottom: 60, display: "none" }, children: /* @__PURE__ */ (0, jsx)(ReleaseImages, { when: !shown, children: /* @__PURE__ */ (0, jsx)(FocusScope, { focusKey: "homebrew-page", inert: !shown, children: /* @__PURE__ */ (0, jsx)(HomebrewScreen, { onBack: pop }) }) }) });
}
var WARM_DELAY_MS = 3e3;
function App() {
useInterfaceSounds();
useInterfaceHaptics();
(0, useEffect)(() => {
const id = setTimeout(() => {
warmCovers();
refreshHomeArt();
}, WARM_DELAY_MS);
const id = setTimeout(warmCovers, WARM_DELAY_MS);
return () => clearTimeout(id);
}, []);
const { top } = useNavState();
const { stack, top } = useNavState();
const onBrowse = top.name === "browse";
const onHomebrew = top.name === "homebrew";
const keepHomebrew = stack.some((screen2) => screen2.name === "homebrew");
const key = top.name === "game" ? `game:${top.id}` : top.name;
const immersive = useIsImmersive();
const { rtl } = useLocale();
@@ -54,7 +69,8 @@ import { VERSION } from './version.js';
/* @__PURE__ */ (0, jsxs)(View, { style: [{ width: 1920, height: 1080, backgroundColor: "#000000" }, { direction: rtl ? "rtl" : "ltr" }], children: [
/* @__PURE__ */ (0, jsxs)(Header, { children: [
/* @__PURE__ */ (0, jsx)(BrowseLayer, { shown: onBrowse }),
onBrowse ? null : (
keepHomebrew ? /* @__PURE__ */ (0, jsx)(HomebrewLayer, { shown: onHomebrew }) : null,
onBrowse || onHomebrew ? null : (
// No background of its own: the root's canvas shows through, so a screen change does not
// fill the whole screen twice. `screen` lets the header's Down return to where it was left.
/* @__PURE__ */ (0, jsx)(View, { style: immersive ? { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } : top.name === "downloads" || top.name === "files" ? { position: "absolute", left: 0, right: 0, top: 96, bottom: 0 } : { position: "absolute", left: 0, right: 0, top: 96, bottom: 60 }, children: /* @__PURE__ */ (0, jsx)(FocusScope, { focusKey: "screen", children: /* @__PURE__ */ (0, jsx)(Screen, { screen: top }) }) }, key)
+14 -4
View File
@@ -3,6 +3,7 @@
import { features, games, twins } from '../data/catalog.js';
import { formatDate2, formatNumber2, t } from '../i18n/core.js';
import { getHomebrew, homebrewApps, relatedHomebrew } from './homebrew.js';
import { createPageCache } from './page-cache.js';
import { pausedFilter } from './paused-hosts.js';
import { createReleaseCache } from './release-cache.js';
@@ -18,7 +19,8 @@ import { FileSystem, Image } from '@ps5-react/core';
"Role Playing Games": "RPG",
"Role-playing": "RPG",
Simulator: "Simulation",
"Driving/Racing": "Racing"
"Driving/Racing": "Racing",
Sports: "Sport"
};
var dataFile = (dir2, id) => `data/${dir2}/${encodeURIComponent(id)}.json`;
var pageOf = (game) => {
@@ -78,14 +80,19 @@ import { FileSystem, Image } from '@ps5-react/core';
}
var genres = [...new Set(games.flatMap((game) => game.genres))].sort();
var byId = new Map(entries.map((game) => [game.id, game]));
var getGame = (id) => byId.get(id);
// A homebrew app's page, download and favorite go through the same lookups as a game's.
var getGame = (id) => byId.get(id) ?? getHomebrew(id);
function listedId(id) {
const game = byId.get(id);
if (!game || listed.has(game)) return id;
return editionsOf(game).find((edition2) => listed.has(edition2) && edition2.platform === game.platform)?.id ?? id;
}
// Downloads art to the disk cache ahead of the screens that show it. The homebrew icons go first: the
// Homebrew page shows about twenty at once, from a host that answers in about 400 ms. One call: the
// engine keeps a single warm list, which each call replaces.
function warmCovers() {
Image.warm(sortGames(games, "relevance").filter((game) => game.art.cover).map((game) => game.art.cover));
const icons = homebrewApps().map((app) => app.art.cover).filter(Boolean);
Image.warm([...icons, ...sortGames(games, "relevance").filter((game) => game.art.cover).map((game) => game.art.cover)]);
}
var SORTS = [
// New releases first: the library's sort menu opens on it.
@@ -197,7 +204,9 @@ import { FileSystem, Image } from '@ps5-react/core';
var passes = (game, facet, picked) => !picked.length || (facet.any ? picked.some((value) => facet.has(game, value)) : picked.every((value) => facet.has(game, value)));
function matching({ filters, query, wishlist }, skip) {
const q = query.trim().toLowerCase();
return games.filter((game) => (!q || `${game.title} ${game.developer} ${game.publisher}`.toLowerCase().includes(q)) && FACETS.every((facet) => facet === skip || passes(game, facet, filters[facet.key])) && (!filters.wishlist || wishlist.includes(game.id)));
// Favorites can hold homebrew apps too; every other list is the game catalog's.
const pool = filters.wishlist ? [...games, ...homebrewApps()] : games;
return pool.filter((game) => (!q || `${game.title} ${game.developer} ${game.publisher}`.toLowerCase().includes(q)) && FACETS.every((facet) => facet === skip || passes(game, facet, filters[facet.key])) && (!filters.wishlist || wishlist.includes(game.id)));
}
var filterGames = (state) => matching(state);
function facetCounts(state, facet) {
@@ -206,6 +215,7 @@ import { FileSystem, Image } from '@ps5-react/core';
}
var summaryOf = (game) => game.about.find((text) => text.length > 80 && text !== text.toUpperCase()) ?? game.shortDescription;
function relatedGames(game, limit = 6) {
if (game.homebrew) return relatedHomebrew(game, limit);
const shared = (other) => other.genres.filter((genre) => game.genres.includes(genre)).length;
return games.filter((other) => other.id !== game.id && shared(other) > 0).sort((a, b) => shared(b) - shared(a) || b.recommendations - a.recommendations).slice(0, limit);
}
+110 -26
View File
@@ -209,7 +209,8 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
const before = job2.artifact;
try {
if (!fs.exists(job2.downloadDestination)) continue;
route(job2);
// At launch nothing is downloading yet, so the synchronous read does not wait on the drive.
route(job2, archives?.inspect?.([job2.downloadDestination]));
if (!["shadowmount", "installer-required"].includes(job2.artifact.nextAction)) {
job2.artifact = before;
continue;
@@ -252,6 +253,45 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
destination: `${root2}/downloads/extracted/${release.filename}`,
nextAction: "extracted"
} : formats.artifact(release, root2);
// Datanodes' dlproxy.uk gateway (FileKeeper's files too) can answer every range HTTP 503 with
// Retry-After: 60 for a while. The engine honours it, with its connections down to a few, and the
// download sits at 0 MB/s for minutes; a new transfer of the same link, which Pause and Resume
// start, goes on at full speed at once (issue #77). So a download from it that gets nothing for
// STALL_MS starts over by itself, from what it has written. A third start in a row with nothing
// received asks the source for a new link; past STALL_RESTARTS the engine's own retries decide.
var STALL_MS = 9e4;
var STALL_RESTARTS = 10;
function restartsAfterStall(job2) {
try {
return /(?:^|\.)dlproxy\.uk$/.test(webURL(job2.options?.url).host);
} catch {
return false;
}
}
function stallWatch(job2, task) {
let moved = Date.now(), seen = -1;
return (progress) => {
const bytes = (progress.received || 0) + (progress.completedBytes ?? progress.written ?? 0);
if (bytes !== seen) {
if (seen >= 0 && job2.stalls) {
console.log(`[download-queue] ${job2.id}: receiving again after ${job2.stalls} restart(s)`);
delete job2.stalls;
}
seen = bytes;
moved = Date.now();
return;
}
if (active2?.restarting || Date.now() - moved < STALL_MS || !["connecting", "downloading"].includes(progress.state) || !restartsAfterStall(job2)) return;
if ((job2.stalls || 0) >= STALL_RESTARTS) return;
job2.stalls = (job2.stalls || 0) + 1;
// Only FileKeeper's link comes from a form the app posts again; DataNodes' needs the browser.
const relink = job2.stalls % 3 === 0 && sourceKind(job2.release) === "filekeeper";
console.log(`[download-queue] ${job2.id}: nothing received for ${Math.round((Date.now() - moved) / 1e3)} s (connections ${progress.connections || 0}, retries ${progress.retries || 0}); restart ${job2.stalls} of ${STALL_RESTARTS}${relink ? ", with a new link from the source" : ""}`);
if (relink) delete job2.options;
active2.restarting = true;
task.cancel();
};
}
function reresolve(job2) {
const source2 = job2.parts ? job2.currentSource : job2.release;
if ((job2.reresolved || 0) >= 2 || ["direct", "browser", "datanodes", "buzzheavier", "manifest", "unavailable"].includes(sourceKind(source2))) return false;
@@ -392,9 +432,11 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
}
task = downloads.enqueue({ ...job2.options, rejectHtml: true, mirrors: void 0, connections: 64, adaptive: true, rangeBytes: job2.options.rangeBytes, destination: job2.downloadDestination, storageRoot: job2.root, recoverCompleted: true });
active2.task = task;
const stalled = stallWatch(job2, task);
unsubscribe = task.subscribe((progress) => {
if (job2.state === "pausing") return;
if (ACTIVE.includes(progress.state)) job2.state = progress.state;
stalled(progress);
const completedBytes = progress.completedBytes ?? progress.written ?? 0;
if (completedBytes > 0 || !job2.written) job2.written = completedBytes;
job2.total = progress.total ?? job2.total;
@@ -415,7 +457,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
emit2();
});
await task.done;
if (job2.artifact.nextAction === "downloaded") route(job2);
if (job2.artifact.nextAction === "downloaded") route(job2, archives?.inspect && await headers([job2.downloadDestination], "downloaded"));
job2.state = job2.artifact.nextAction === "shadowmount" ? "downloaded" : job2.artifact.nextAction === "downloaded" ? "file-downloaded" : job2.artifact.nextAction;
job2.written = job2.total || job2.written;
job2.bytesPerSecond = 0;
@@ -425,6 +467,12 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
if (job2.state === "installer-required" && canInstall()) await install(job2);
} catch (error2) {
console.log("[download-queue] transfer stopped:", error2.message);
// Stopped by stallWatch: it starts over at once, as Pause and Resume would.
if (active2.restarting && job2.state !== "pausing" && !job2.cancelling) {
job2.state = "queued";
job2.error = "";
return;
}
// The engine words every 401/403 as a possible browser check; from Archive.org it is a
// restricted or removed item, so it reads as a dead link.
const refused = archiveRefusal(job2.parts ? job2.currentSource : job2.release, cause(error2.message));
@@ -453,6 +501,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
unsubscribe?.();
if (job2.state === "pausing") job2.state = "paused";
if (COMPLETE.includes(job2.state)) delete job2.launches;
const restarted = active2.restarting && job2.state === "queued";
active2 = null;
// A cancelled download's files are deleted after the queue moves on: deleting tens of GB from a
// USB drive can take long, and the next download sat "queued" at 0 MB the whole time.
@@ -463,28 +512,46 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
job2.error = `Queue save failed: ${error2.message}`;
emit2();
}
pump();
pump(restarted ? job2 : void 0);
}
}
// Headers are read on a native worker thread (issue #71): a download's growing file can make the
// read wait for the drive until the download ends, and on the render thread that froze the app.
async function headers(sources, why) {
const started = Date.now();
let found;
try {
found = archives.inspectAsync ? await archives.inspectAsync(sources) : archives.inspect(sources);
} catch (error2) {
console.log(`[download-queue] header check (${why}) off the render thread failed, reading it here: ${error2.message}`);
found = archives.inspect(sources);
}
console.log(`[download-queue] header check (${why}): ${found.kind || "no known format"}${found.refusal ? `, refused: ${found.refusal}` : ""} in ${Date.now() - started} ms (waited ${found.waitedMs ?? 0} ms, read ${found.tookMs ?? 0} ms)`);
return found;
}
const inspections = /* @__PURE__ */ new WeakMap();
const checking = /* @__PURE__ */ new WeakSet();
// While the file downloads, so a refused archive stops early; the download goes on meanwhile. A
// result that comes once the transfer has ended is left to the check after it.
function checkHeaders(job2, path, task) {
const tries = inspections.get(job2) || 0;
if (job2.inspected || tries >= 3 || !archives?.inspect || !fs.exists(path)) return;
if (job2.inspected || checking.has(job2) || tries >= 3 || !archives?.inspect || !fs.exists(path)) return;
inspections.set(job2, tries + 1);
const started = Date.now();
const { kind, refusal, title } = archives.inspect([path]);
if (Date.now() - started > 250) console.log(`[download-queue] header check took ${Date.now() - started} ms`);
if (!kind) return;
job2.inspected = kind;
noteTitle(job2, title);
if (refusal) {
job2.error = refusal;
task.cancel();
}
checking.add(job2);
headers([path], "while downloading").then(({ kind, refusal, title }) => {
if (!kind || ["completed", "failed", "cancelled"].includes(task.snapshot.state)) return;
job2.inspected = kind;
noteTitle(job2, title);
if (refusal) {
job2.error = refusal;
task.cancel();
}
}, (error2) => console.log(`[download-queue] header check failed: ${error2.message}`)).finally(() => checking.delete(job2));
}
function route(job2) {
if (!archives?.inspect) return;
const { kind, refusal, title } = archives.inspect([job2.downloadDestination]);
// `found`: the finished file's headers, or nothing without the archives module.
function route(job2, found) {
if (!found) return;
const { kind, refusal, title } = found;
if (refusal) throw new Error(refusal);
noteTitle(job2, title);
// A source listed without a format is kept only for what its headers turn out to be: when they
@@ -547,11 +614,16 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
delete job2.routing;
change();
}
// gameFolder follows a lone folder down and gives up beside a second one, as a dump with its
// patch, DLC or backport folder next to it. Only when it finds nothing, every folder holding a
// game is listed, and one is taken only when exactly one is plainly the game: not named a patch
// or an update, with the chosen game's title id when several are left. Else nothing moves.
// A dump can hold its game beside a patch, an update or an optional backport, each a folder with
// its own eboot.bin (issue #76). gameFolder follows a lone folder down and takes it whatever its
// name: a backported game's folder is often named so, and a merged dump "(Base + Update)". When
// it gives up beside a second folder, every folder holding a game is listed. One is taken as it
// was before (unless named a patch or an update); among several, patches, updates and backports
// are left out by the names below the folder that holds them all (it may name a backport for
// every one of them), then the chosen game's title id decides. Only one left is taken, else
// nothing moves.
var PATCH_FOLDER = /(?:^|[-_ .(\[])(?:patch|update|upd)(?:$|[-_ .)\]\d])/i;
var BACKPORT_FOLDER = /(?:^|[-_ .(\[])backport(?:$|[-_ .)\]\d])/i;
function gameFolders(output) {
const found = [];
const queue = [{ path: output, depth: 0 }];
@@ -579,7 +651,14 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
const shown = (path) => path.slice(output.length) || "/";
if (found.length) console.log(`[download-queue] game folders in ${output}: ${found.map(shown).join(", ")}`);
const name = (path) => path.slice(path.lastIndexOf("/") + 1);
let games = found.filter((path) => !PATCH_FOLDER.test(name(path)));
let games;
if (found.length > 1) {
const parts = found.map((path) => path.split("/"));
let shared = 0;
while (parts.every((list) => shared < list.length - 1 && list[shared] === parts[0][shared])) shared++;
const supplemental = (list) => list.slice(shared).some((segment) => PATCH_FOLDER.test(segment) || BACKPORT_FOLDER.test(segment));
games = found.filter((path, index) => !supplemental(parts[index]));
} else games = found.filter((path) => !PATCH_FOLDER.test(name(path)));
const titled = games.filter((path) => String(titleIn(path) || "").toUpperCase() === String(job2.titleId || "").toUpperCase());
if (titled.length) games = titled;
if (games.length !== 1) {
@@ -669,6 +748,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
// what is written, with no total, instead of holding at 99%.
let guessed = true;
job2.state = "extracting";
job2.extractedFiles = 0;
job2.written = 0;
job2.total = estimate;
job2.bytesPerSecond = 0;
@@ -686,6 +766,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
emit2();
};
const follow = (progress) => {
job2.extractedFiles = progress.entries || 0;
// A total at the extraction's cap is the space free, not the files' size: they are bigger.
if (progress.total && progress.total !== estimate && progress.total < options.maxBytes) {
job2.total = estimate = progress.total;
@@ -716,6 +797,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
if (outcome.error) {
console.log("[download-queue] extraction during download stopped, extracting again:", outcome.error.message);
job2.written = 0;
job2.extractedFiles = 0;
delete job2.phase;
sample = { at: Date.now(), bytes: 0 };
emit2();
@@ -738,6 +820,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
if (last || job2.state === "pausing" || job2.cancelling || !PASSWORD_FAILURE.test(cause(error2.message))) throw error2;
console.log(`[download-queue] archive password ${index + 1} of ${passwords.length} did not open it, trying the next`);
job2.written = 0;
job2.extractedFiles = 0;
sample = { at: Date.now(), bytes: 0 };
emit2();
} finally {
@@ -750,6 +833,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
throw error2;
} finally {
Object.assign(job2, downloaded, { bytesPerSecond: 0 });
delete job2.extractedFiles;
delete job2.phase;
}
const images = (result.artifacts || []).filter((path) => /\.(ffpfsc|ffpfs|ffpkg|exfat)$/i.test(path));
@@ -812,9 +896,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
job2.message = `${job2.message} ${t("downloads.archiveNotDeleted", { error: error2.message })}`;
}
}
function streamSet(job2) {
async function streamSet(job2) {
const first = job2.parts[0].destination;
if (!archives?.streams || job2.parts.length < 2 || archives.inspect([first]).kind !== "rar") return null;
if (!archives?.streams || job2.parts.length < 2 || (await headers([first], "streamed set")).kind !== "rar") return null;
let task;
try {
const options = extraction(job2, job2.parts.map((part) => part.destination));
@@ -875,7 +959,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
let streamed = null;
try {
for (let index = 0; index < job2.parts.length; index++) {
if (!streamed && index > 0 && job2.parts[0].completed && fs.exists(job2.parts[0].destination)) streamed = active2.streamed = streamSet(job2);
if (!streamed && index > 0 && job2.parts[0].completed && fs.exists(job2.parts[0].destination)) streamed = active2.streamed = await streamSet(job2);
job2.partIndex = index;
const part = job2.parts[index];
if (job2.state === "pausing") return;
@@ -952,7 +1036,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
job2.written = job2.parts.reduce((sum, item) => sum + item.written, 0);
part.completed = true;
change();
const refusal = index === 0 && archives?.inspect([part.destination]).refusal;
const refusal = index === 0 && archives?.inspect && (await headers([part.destination], "first part")).refusal;
if (refusal) throw new Error(refusal);
}
} finally {
+143
View File
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// findGame and the helpers it calls, taken from the queue's source: they use only fs and titleIn.
const source = readFileSync(new URL("./download-queue.js", import.meta.url), "utf8");
const start = source.indexOf(" var PATCH_FOLDER =");
const end = source.indexOf(" // The extractor writes files", start);
assert.ok(start !== -1 && end > start);
const selectGame = new Function("fs", "titleIn", "console", `${source.slice(start, end)}
return findGame;`);
function fixture(paths, titles = {}, otherDirectories = []) {
const root = "/data/downloads/extracted/release";
const directories = new Set([root]);
const entries = new Set();
const addDirectories = (relative) => {
let parent = root;
for (const segment of relative.split("/")) {
parent += `/${segment}`;
directories.add(parent);
}
return parent;
};
for (const relative of paths) {
const parent = addDirectories(relative);
entries.add(`${parent}/eboot.bin`);
entries.add(`${parent}/sce_sys/param.json`);
}
for (const relative of otherDirectories) addDirectories(relative);
const fs = {
exists: (path) => entries.has(path),
readDir: (path) => {
if (!directories.has(path)) throw new Error("Directory does not exist");
return [...directories].filter((child) => child.startsWith(`${path}/`) && !child.slice(path.length + 1).includes("/")).map((child) => ({
name: child.slice(path.length + 1),
isDirectory: true
}));
}
};
const titleIn = (path) => titles[path.slice(root.length + 1)] ?? "PPSA28000";
const findGame = selectGame(fs, titleIn, { log() {} });
const result = findGame({ titleId: "PPSA28000" }, root);
return { folder: result.folder?.slice(root.length + 1) ?? null, beside: result.beside };
}
test("base game next to the reported optional backport", () => {
assert.deepEqual(fixture(["PPSA28000-app", "backport (backport 4.xx to 11.xx)"]), {
folder: "PPSA28000-app", beside: true
});
});
test("nested backport is not selected as a game", () => {
assert.deepEqual(fixture(["PPSA28000-app", "backport (4.xx to 11.xx)/content/game"]), {
folder: "PPSA28000-app", beside: true
});
});
test("a lone game folder is taken whatever its name, as before", () => {
for (const lone of ["PPSA28000 Backport 4.xx", "Ghost of Yotei v1.010 (Base + Update)", "backport (4.xx to 11.xx)"]) {
assert.deepEqual(fixture([lone]), { folder: lone, beside: false });
}
});
test("a lone game folder inside a wrapper named a backport is taken", () => {
assert.deepEqual(fixture(["Dawnwalker 4.xx Backport/PPSA28000-app"]), {
folder: "Dawnwalker 4.xx Backport/PPSA28000-app", beside: false
});
});
test("a wrapper named a backport does not hide the game from its update", () => {
assert.deepEqual(fixture(["Dawnwalker 4.xx Backport/PPSA28000-app", "Dawnwalker 4.xx Backport/update 01.005"]), {
folder: "Dawnwalker 4.xx Backport/PPSA28000-app", beside: true
});
});
test("existing patch filtering continues to select the base game", () => {
assert.deepEqual(fixture(["PPSA28000-app", "patch v01.005"]), {
folder: "PPSA28000-app", beside: true
});
});
test("single unambiguous base game follows the fast path", () => {
assert.deepEqual(fixture(["PPSA28000-app"]), { folder: "PPSA28000-app", beside: false });
});
test("two unclassified game folders stay untouched", () => {
assert.deepEqual(fixture(["PPSA28000-app", "PPSA28000-other"]), { folder: null, beside: false });
});
test("matching title ID wins when two normal games exist", () => {
assert.deepEqual(fixture(["PPSA28000-app", "PPSA99999-app"], {
"PPSA99999-app": "PPSA99999"
}), { folder: "PPSA28000-app", beside: true });
});
test("unrelated names containing the substring backport are not filtered", () => {
assert.deepEqual(fixture(["PPSA28000-backporter", "PPSA99999-app"], {
"PPSA99999-app": "PPSA99999"
}), { folder: "PPSA28000-backporter", beside: true });
});
test("case-insensitive backport variants are ignored", () => {
for (const extra of ["BACKPORT_4.xx", "(backport 5.05)", "11.xx-backport", "Backport4.00"]) {
assert.deepEqual(fixture(["PPSA28000-app", extra]), {
folder: "PPSA28000-app", beside: true
});
}
});
test("more than one potential base game remains ambiguous even with backport", () => {
assert.deepEqual(fixture(["PPSA28000-app", "PPSA28000-alternate", "backport"]), {
folder: null, beside: false
});
});
test("a game inside a backport folder beside the base game is left out", () => {
assert.deepEqual(fixture(["PPSA28000-app", "extras/backport 4.xx/PPSA28000-app"]), {
folder: "PPSA28000-app", beside: true
});
});
test("single base game next to nested patch and backport", () => {
assert.deepEqual(fixture(["collection/PPSA28000-app", "collection/update/version", "collection/backport/version"]), {
folder: "collection/PPSA28000-app", beside: true
});
});
test("DLC-only extraction without game markers is not selected as a base game", () => {
assert.deepEqual(fixture([], {}, ["DLC/content"]), { folder: null, beside: false });
});
test("base game next to DLC content and backport is selected", () => {
assert.deepEqual(fixture(["PPSA28000-app", "backport (4.xx to 11.xx)"], {}, ["DLC/content"]), {
folder: "PPSA28000-app", beside: true
});
});
test("game-like DLC with the same title ID stays ambiguous beside base and backport", () => {
assert.deepEqual(fixture(["PPSA28000-app", "DLC/extra-game", "backport (4.xx to 11.xx)"]), {
folder: null, beside: false
});
});
-50
View File
@@ -1,50 +0,0 @@
// The home screen's pictures for this app. ShadowMount+ copies a title's sce_sys only when it first
// registers the title, so an update's new pictures would never show. Once per build (its sce_sys files'
// names, sizes and times) the app brings those copies up to date itself, as ProsperoEden's updater does:
// every file of its sce_sys into the registered folders that exist, and the icon beside the registered app.
import { FileSystem, Platform } from '@ps5-react/core';
var TITLE = "PPSA99058";
var REGISTERED_APP = `/user/app/${TITLE}`;
var FOLDERS = [`/user/appmeta/${TITLE}`, `${REGISTERED_APP}/sce_sys`];
var MARKER = `/data/ps5-react/${TITLE}/home-art.json`;
function refreshHomeArt() {
if (Platform.OS !== "ps5") return;
const source = `${FileSystem.appDir}/sce_sys`;
let files = [];
try {
files = FileSystem.readDir(source).filter((entry) => entry.isFile);
} catch (error) {
console.log(`[home-art] ${source} not read: ${error.message}`);
return;
}
const build = files.map((entry) => `${entry.name}:${entry.size}:${entry.modified}`).sort().join("|");
try {
if (JSON.parse(FileSystem.readFile(MARKER)).build === build) return;
} catch {
}
const names = files.map((entry) => entry.name);
const copies = [];
for (const folder of FOLDERS)
if (FileSystem.stat(folder)?.isDirectory) for (const name of names) copies.push([`${source}/${name}`, `${folder}/${name}`]);
if (FileSystem.stat(`${REGISTERED_APP}/icon0.png`)?.isFile) copies.push([`${source}/icon0.png`, `${REGISTERED_APP}/icon0.png`]);
let copied = 0;
for (const [from, to] of copies) {
try {
FileSystem.copyFile(from, to);
copied++;
} catch (error) {
console.log(`[home-art] ${error.message}`);
}
}
console.log(`[home-art] ${copied} of ${copies.length} files brought up to date for this build`);
// Written even when a copy failed: one try per build, not one per launch.
try {
FileSystem.writeFile(MARKER, JSON.stringify({ build, copied }));
} catch (error) {
console.log(`[home-art] ${MARKER} not written: ${error.message}`);
}
}
export { refreshHomeArt };
+226
View File
@@ -0,0 +1,226 @@
// The Homebrew section: native PS5 apps and games from the community catalog behind prosperostore.net
// (github.com/darkxex/ps5-homebrew-catalog). The game catalog ships inside the app; this one is read
// at run time from the catalog's store API (docs/api.md there), so a new app or release shows without
// a new Overdrive. The list, index.json, is kept on disk for the next launch; an app's own file, with
// its download and SHA-256, is read when its page opens. Every file comes from its developer's GitHub
// release, and the queue checks its bytes against that SHA-256 before anything is installed.
// The catalog also signs its manifest (Ed25519), but Http hands JavaScript text and the signature is
// 64 raw bytes, so HTTPS vouches for the lists here.
//
// An app becomes an entry shaped like a catalog game, so its page, card, download and install are the
// game's own; its id is "hb-" and the catalog's id, apart from any game's title id.
import { useEffect, useState } from 'react';
import { t } from '../i18n/core.js';
import { isRecord, readJSON } from './read-json.js';
import { FileSystem, Http } from '@ps5-react/core';
var API = "https://prosperostore.net/api/v1";
var DIR = `${FileSystem.dataDir}/overdrive`;
var CACHE = `${DIR}/homebrew.json`;
var PREFIX = "hb-";
var MAX_BYTES = 4 * 1024 * 1024;
// What the queue installs: a zip of the app folder (every listing since the catalog made it the only
// format), or an image ShadowMount+ mounts as it is.
var INSTALLABLE = /* @__PURE__ */ new Set(["zip", "ffpfsc", "ffpfs", "ffpkg", "exfat"]);
// The catalog's categories under the names the genre labels translate (genre.<slug> in the locales).
var CATEGORIES = {
"console-ports": "Console Ports",
emulators: "Emulators",
video: "Video",
"pc-ports": "PC Ports",
utilities: "Utilities",
streaming: "Streaming",
music: "Music",
tools: "Tools",
ai: "AI",
"homebrew-games": "Homebrew Games"
};
var KINDS = ["app", "game", "tool"];
var TITLE_ID = /^[A-Z]{4}\d{5}$/;
var APP_ID = /^[A-Za-z0-9_-]{1,80}$/;
var SHA256 = /^[0-9a-f]{64}$/i;
var text = (value, max) => typeof value === "string" && value.length <= max ? value : "";
var https = (value) => typeof value === "string" && value.length <= 2048 && /^https:\/\/[a-z0-9.-]+\//i.test(value) && !/[\s\\]/.test(value) ? value : null;
var bytes = (value) => Number.isSafeInteger(value) && value > 0 ? value : null;
var safe = (value) => String(value).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 60);
function categoryName(category) {
if (CATEGORIES[category]) return CATEGORIES[category];
return text(category, 40).split(/[-_ ]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
}
var details = /* @__PURE__ */ new Map();
var Entry = {
get releases() {
const app = details.get(this.id);
if (!app || !this.hasDownloads) return [];
const version = safe(app.version || "release");
return [{
id: `homebrew-${version}`,
provider: "GitHub",
label: "",
version: app.version || void 0,
url: app.artifact_url,
format: app.format,
filename: `${this.titleId}-${version}.${app.format}`,
sizeBytes: app.size || this.homebrew.size || null,
sha256: app.sha256.toLowerCase(),
source: "homebrew"
}];
},
get about() {
const app = details.get(this.id);
if (!app) return [];
const notes = (app.release_notes || "").split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean);
// An upper-case line is a heading to About.
const heading = t("homebrew.releaseNotes", { version: app.version || "" }).toUpperCase();
return [app.description, ...notes.length ? [heading, ...notes] : []].filter(Boolean);
},
get shots() {
return [];
},
get shortDescription() {
return details.get(this.id)?.description ?? "";
}
};
function toEntry(item) {
if (!isRecord(item) || item.status !== "available" || !APP_ID.test(item.id ?? "") || !TITLE_ID.test(item.titleid ?? "")) return null;
const title = text(item.name, 200).trim();
if (!title) return null;
const format = text(item.format, 12).toLowerCase();
const released = text(item.released, 40) || text(item.updated, 40);
const size = bytes(item.size);
const entry2 = {
id: `${PREFIX}${item.id}`,
titleId: item.titleid,
title,
platform: "ps5",
platforms: ["ps5"],
providers: ["github"],
direct: true,
developer: text(item.author, 120),
publisher: text(item.author, 120),
releaseDate: released,
year: /^\d{4}/.test(released) ? Number(released.slice(0, 4)) : null,
genres: item.category ? [categoryName(item.category)] : [],
features: [],
recommendations: 0,
metacritic: null,
sizeGb: size ? size / 1e9 : null,
art: { cover: https(item.icon_small), square: true },
hasDownloads: INSTALLABLE.has(format),
homebrew: {
apiId: item.id,
kind: KINDS.includes(item.kind) ? item.kind : "app",
category: text(item.category, 40),
version: text(item.version, 60),
contentVersion: text(item.content_version, 20),
format,
size,
sandbox: ["stays", "leaves", "unclear"].includes(item.sandbox) ? item.sandbox : null
}
};
return Object.setPrototypeOf(entry2, Entry);
}
// Newest release first: what changed lately is what a visitor has not seen yet.
var byRelease = (a, b) => b.releaseDate.localeCompare(a.releaseDate) || a.title.localeCompare(b.title);
var apps = [];
var byId = /* @__PURE__ */ new Map();
var status = "idle";
var listeners = /* @__PURE__ */ new Set();
var notify = () => listeners.forEach((listener) => listener());
function adopt(index) {
const seen = /* @__PURE__ */ new Set();
const list = (Array.isArray(index?.apps) ? index.apps : []).map(toEntry).filter((entry2) => entry2 && !seen.has(entry2.id) && seen.add(entry2.id));
apps = list.sort(byRelease);
byId = new Map(apps.map((entry2) => [entry2.id, entry2]));
for (const id of details.keys()) if (!byId.has(id)) details.delete(id);
return apps.length;
}
// The last list read, so the section shows at once on the next launch, offline too.
var cached = readJSON(FileSystem, CACHE, null, isRecord, (message) => console.log(`[homebrew] cache: ${message}`));
if (cached) adopt(cached);
if (apps.length) status = "ready";
var request = null;
async function fetchJSON(path) {
const response = await Http.request(`${API}/${path}`, { headers: { Accept: "application/json", "User-Agent": "Overdrive" }, maxBytes: MAX_BYTES });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
// Reads the list once a launch, or again after a failure when asked; a second call while one is
// running waits for it.
function refresh(force = false) {
if (request) return request;
if (status === "fresh" && !force) return Promise.resolve();
if (!apps.length) {
status = "loading";
notify();
}
request = fetchJSON("index.json").then((index) => {
if (!adopt(index)) throw new Error("the catalog listed no app");
status = "fresh";
try {
FileSystem.mkdir(DIR, { recursive: true });
FileSystem.writeFile(`${CACHE}.tmp`, JSON.stringify({ apps: index.apps }));
FileSystem.rename(`${CACHE}.tmp`, CACHE);
} catch (error2) {
console.log(`[homebrew] list not kept: ${error2.message}`);
}
console.log(`[homebrew] ${apps.length} apps listed`);
}).catch((error2) => {
console.log(`[homebrew] list not read: ${error2.message}`);
status = apps.length ? "ready" : "failed";
}).finally(() => {
request = null;
notify();
});
return request;
}
var loading = /* @__PURE__ */ new Map();
// An app's own file: its description, release notes, download and SHA-256. Checked against the list
// (same title id, an installable format, a GitHub release file), else the page offers no download.
function loadDetails(entry2) {
if (details.has(entry2.id)) return Promise.resolve(entry2);
if (loading.has(entry2.id)) return loading.get(entry2.id);
const pending = fetchJSON(`apps/${encodeURIComponent(entry2.homebrew.apiId)}.json`).then((app) => {
const artifact = https(app?.artifact_url);
if (!isRecord(app) || app.titleid !== entry2.titleId || app.status !== "available") throw new Error("the app's file does not match the list");
if (!artifact || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//i.test(artifact) || !SHA256.test(app.sha256 ?? "")) throw new Error("the app's download is not a pinned GitHub release file");
const format = text(app.format, 12).toLowerCase();
details.set(entry2.id, {
description: text(app.description, 4e3).trim(),
release_notes: text(app.release_notes, 8e3),
version: text(app.version, 60),
artifact_url: artifact,
sha256: app.sha256,
format: INSTALLABLE.has(format) ? format : entry2.homebrew.format,
size: bytes(app.size),
license: text(app.license, 60),
source_repo: https(app.source_repo)
});
entry2.hasDownloads = INSTALLABLE.has(format);
return entry2;
}).finally(() => loading.delete(entry2.id));
loading.set(entry2.id, pending);
return pending;
}
var detailsOf = (entry2) => details.get(entry2.id) ?? null;
var isHomebrewId = (id) => typeof id === "string" && id.startsWith(PREFIX);
var getHomebrew = (id) => byId.get(id);
var homebrewApps = () => apps;
// Other homebrew only, never a catalog game: the same category first, then the same kind.
function relatedHomebrew(entry2, limit = 6) {
const score = (other) => (other.homebrew.category === entry2.homebrew.category ? 2 : 0) + (other.homebrew.kind === entry2.homebrew.kind ? 1 : 0);
return apps.filter((other) => other.id !== entry2.id).map((other, index) => ({ other, index, score: score(other) })).sort((a, b) => b.score - a.score || a.index - b.index).slice(0, limit).map(({ other }) => other);
}
function useHomebrew() {
const [, setVersion] = (0, useState)(0);
(0, useEffect)(() => {
const listener = () => setVersion((count) => count + 1);
listeners.add(listener);
void refresh();
return () => listeners.delete(listener);
}, []);
return { apps, status };
}
export { KINDS, detailsOf, getHomebrew, homebrewApps, isHomebrewId, loadDetails, refresh, relatedHomebrew, useHomebrew };
+48 -7
View File
@@ -2,8 +2,8 @@
// reconstructed from the esbuild bundle in eboot.bin
import '../assets/Inter-Regular.ttf';
import { jsx } from 'react/jsx-runtime';
import { useEffect, useState } from 'react';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useRef, useState } from 'react';
import { Image, Text, View } from '@ps5-react/core';
var SKELETON = { backgroundColor: "#212125" };
@@ -33,16 +33,57 @@ import { Image, Text, View } from '@ps5-react/core';
height: "100%",
borderRadius: Object.assign({}, ...[style].flat(Infinity).filter(Boolean)).borderRadius
});
function Cover({ game, style, label = true }) {
// A full engine image registry fails a load for a moment, not for this image: the cover tries again
// a little later, a few times, instead of falling back to its title for as long as it stays mounted
// (on the store front, for the whole session).
var TRANSIENT = /registry is full/;
var RETRY_MS = 1500;
var RETRIES = 6;
function Cover({ game, style, label = true, defer = false }) {
const [failed, setFailed] = (0, useState)([]);
const [loaded, setLoaded] = (0, useState)(null);
const [attempt, setAttempt] = (0, useState)(0);
const retry = (0, useRef)({ count: 0, timer: 0 });
(0, useEffect)(() => () => clearTimeout(retry.current.timer), []);
const failedLoad = (uri2, event) => {
if (TRANSIENT.test(event?.nativeEvent?.error ?? "") && retry.current.count < RETRIES) {
retry.current.count += 1;
clearTimeout(retry.current.timer);
retry.current.timer = setTimeout(() => setAttempt((count) => count + 1), RETRY_MS);
return;
}
setFailed((previous) => [...previous, uri2]);
};
const copies = [game.art.cover, ...game.art.coverFallbacks || []].filter((url) => url && !failed.includes(url));
const uri = copies[0];
const last = copies.length < 2;
(0, useEffect)(() => {
if (!uri || loaded === uri || last) return void 0;
// A deferred card loads nothing yet, so it has no stall to watch.
if (defer || !uri || loaded === uri || last) return void 0;
return watchStall(() => setFailed((previous) => [...previous, uri]));
}, [uri, loaded, last]);
}, [uri, loaded, last, defer]);
// `defer`: a card a row has not reached yet draws the placeholder tile and loads nothing. An image
// in use is never evicted, so a row of forty cards pinned forty covers (1.0.8 thrashed the cache).
if (defer) return /* @__PURE__ */ (0, jsx)(View, { style: [skeleton(game.art.tint), style] });
// A homebrew app's art is its square icon: shown whole in the middle of the tile rather than cropped
// to a cover's shape. One image: a dimmed copy behind it doubled every icon's decoded memory.
if (uri && game.art.square) {
const { width = 0, height = 0, borderRadius } = Object.assign({}, ...[style].flat(Infinity).filter(Boolean));
const side = Math.round(Math.min(width, height) * 0.72);
return /* @__PURE__ */ (0, jsxs)(View, { style: [{ overflow: "hidden", alignItems: "center", justifyContent: "center", backgroundColor: "#17171a" }, style], children: [
/* @__PURE__ */ (0, jsx)(
Image,
{
source: { uri },
resizeMode: "contain",
style: { width: side, height: side, borderRadius: borderRadius ? Math.round(borderRadius * 1.5) : 0 },
onLoad: () => setLoaded(uri),
onError: (event) => failedLoad(uri, event)
},
`${uri}#${attempt}`
)
] });
}
if (uri) {
return /* @__PURE__ */ (0, jsx)(View, { style: loaded === uri ? style : [skeleton(game.art.tint), style], children: /* @__PURE__ */ (0, jsx)(
Image,
@@ -51,9 +92,9 @@ import { Image, Text, View } from '@ps5-react/core';
resizeMode: "cover",
style: fill(style),
onLoad: () => setLoaded(uri),
onError: () => setFailed((previous) => [...previous, uri])
onError: (event) => failedLoad(uri, event)
},
uri
`${uri}#${attempt}`
) });
}
return /* @__PURE__ */ (0, jsx)(View, { style: [{ overflow: "hidden" }, style], children: /* @__PURE__ */ (0, jsx)(View, { style: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#212125", padding: 12 }, children: label && /* @__PURE__ */ (0, jsx)(Text, { style: { width: "100%", textAlign: "center", fontFamily: "Inter-Regular", fontSize: 21, color: "#a3a3ab", numberOfLines: 3, ellipsizeMode: "tail", lineHeight: 30 }, children: game.title }) }) });
+4 -3
View File
@@ -79,7 +79,7 @@ import { Text, View, motion, transitions, useIsFocused } from '@ps5-react/core';
] })
] });
}
function Body({ game, width, caption, detailed }) {
function Body({ game, width, caption, detailed, deferArt }) {
const { rtl } = useLocale();
const focused = useIsFocused();
const box = boxOf(width, detailed);
@@ -92,7 +92,7 @@ import { Text, View, motion, transitions, useIsFocused } from '@ps5-react/core';
animate: focused ? lift : rest,
transition: focusMotion,
children: [
/* @__PURE__ */ (0, jsx)(Cover, { game, style: box.cover }),
/* @__PURE__ */ (0, jsx)(Cover, { game, style: box.cover, defer: deferArt }),
/* @__PURE__ */ (0, jsx)(WishBadge, { id: game.id }),
onCdn(game) ? /* @__PURE__ */ (0, jsx)(CdnMark, {}) : null,
/* @__PURE__ */ (0, jsx)(CardFocus, { focused, width, height: box.height, platforms: game.platforms })
@@ -116,6 +116,7 @@ import { Text, View, motion, transitions, useIsFocused } from '@ps5-react/core';
caption,
detailed,
onFocus,
deferArt,
...rest2
}) {
const { rtl } = useLocale();
@@ -160,7 +161,7 @@ import { Text, View, motion, transitions, useIsFocused } from '@ps5-react/core';
transition: focusMotion,
...entrance,
style: box.card,
children: /* @__PURE__ */ (0, jsx)(Body, { game, width, caption, detailed })
children: /* @__PURE__ */ (0, jsx)(Body, { game, width, caption, detailed, deferArt })
}
);
}
+6 -6
View File
@@ -44,7 +44,7 @@ import { FocusScope, Text, View } from '@ps5-react/core';
const immersive = useIsImmersive();
const [layouts, track] = useNavLayouts();
const onlyGenre = filters.genre.length === 1 && activeCount(filters) === 1 && filters.genre[0];
const down = top.name === "browse" ? "page" : "screen";
const down = top.name === "browse" ? "page" : top.name === "homebrew" ? "homebrew-page" : "screen";
const toastAction = (0, useRef)(null);
const onAction = (action) => {
if (action === "r2" && toastAction.current) {
@@ -161,11 +161,11 @@ import { FocusScope, Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(
NavItem,
{
focusKey: keyOf("new"),
label: t2("nav.new"),
current: shown === "new",
onPress: on.new,
onLayout: track("new"),
focusKey: keyOf("homebrew"),
label: t2("nav.homebrew"),
current: shown === "homebrew",
onPress: on.homebrew,
onLayout: track("homebrew"),
nextFocusDown: down
}
)
+3 -1
View File
@@ -1,6 +1,6 @@
// ../pub-game-store/native/ui/Icon.jsx
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: settings and languages icons.
// MODIFIED: settings, languages and info icons.
import { jsx } from 'react/jsx-runtime';
import { Path, Svg } from '@ps5-react/core';
@@ -28,6 +28,8 @@ import { px } from './scale.js';
],
circleCheck: [ring(12, 12, 10), "m9 12 2 2 4-4"],
alert: [ring(12, 12, 10), "M12 8v4", "M12 16h.01"],
// lucide 0.462 info, for the download row's game page.
info: [ring(12, 12, 10), "M12 16v-4", "M12 8h.01"],
restart: ["M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8", "M3 3v5h5"],
trash: ["M3 6h18", "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"],
list: ["M8 6h13", "M8 12h13", "M8 18h13", "M3 6h.01", "M3 12h.01", "M3 18h.01"],
+1
View File
@@ -18,6 +18,7 @@ import { View } from '@ps5-react/core';
var HINTS = {
browse: [HINT.select, HINT.top, HINT.sections],
game: [HINT.select, HINT.back, HINT.favorite, HINT.sections],
homebrew: [HINT.select, HINT.back, HINT.favorite, HINT.sections],
search: [HINT.select, HINT.favorite, HINT.clear, HINT.back, HINT.sections]
};
var registered2 = [];
+19
View File
@@ -0,0 +1,19 @@
// A loading spinner: a faint ring with a bright quarter that turns, slowly enough to stay a small
// repaint (its box is far under the engine's transform buffer).
import { jsx, jsxs } from 'react/jsx-runtime';
import { colors } from './colors.js';
import { px } from './scale.js';
import { Path, Svg, motion } from '@ps5-react/core';
var TURN = { rotate: 360 };
var START = { rotate: 0 };
var SPIN = { type: "tween", duration: 1.1, ease: "linear", repeat: Infinity };
function Spinner({ size = 40 }) {
return /* @__PURE__ */ (0, jsx)(motion.View, { initial: START, animate: TURN, transition: SPIN, style: { width: px(size), height: px(size) }, children: /* @__PURE__ */ (0, jsxs)(Svg, { width: px(size), height: px(size), viewBox: "0 0 24 24", children: [
/* @__PURE__ */ (0, jsx)(Path, { d: "M22 12a10 10 0 1 1-20 0a10 10 0 1 1 20 0", fill: "none", stroke: "#2a2a2f", strokeWidth: 2.5 }),
/* @__PURE__ */ (0, jsx)(Path, { d: "M12 2a10 10 0 0 1 10 10", fill: "none", stroke: colors.fg, strokeWidth: 2.5, strokeLinecap: "round" })
] }) });
}
export { Spinner };
+10 -2
View File
@@ -2,7 +2,7 @@
// reconstructed from the esbuild bundle in eboot.bin
import { jsx, jsxs } from 'react/jsx-runtime';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Path, Svg, motion } from '@ps5-react/core';
import { colors } from './colors.js';
import { px } from './scale.js';
@@ -19,9 +19,18 @@ import { px } from './scale.js';
];
// `onDone` reports the end of the intro so the store can mount behind it: nothing of the
// store is rendered or loaded while the needle swings.
// The store's first render takes about 300 ms on the console. Started in the same update that
// removes the faded logo, it held the fade's last frame on screen all that time; so the black
// screen is shown first, and the store starts a few frames later.
var MOUNT_AFTER_MS = 50;
function Splash({ onDone }) {
const [step, setStep] = (0, useState)(0);
const [done, setDone] = (0, useState)(false);
(0, useEffect)(() => {
if (!done || !onDone) return;
const id = setTimeout(onDone, MOUNT_AFTER_MS);
return () => clearTimeout(id);
}, [done]);
if (done) return null;
const leaving = step === STEPS.length;
return /* @__PURE__ */ (0, jsx)(
@@ -34,7 +43,6 @@ import { px } from './scale.js';
onAnimationComplete: () => {
if (!leaving) return;
setDone(true);
if (onDone) onDone();
},
children: /* @__PURE__ */ (0, jsxs)(
motion.View,
+7 -6
View File
@@ -11,13 +11,13 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
discover: "nav:discover",
catalog: "nav:browse",
top: "nav:top",
new: "nav:new",
homebrew: "nav:homebrew",
favorites: "nav:favorites",
downloads: "nav:downloads",
files: "nav:files"
};
// L1/R1 stop on Browse too: the item only takes focus there, and ✕ opens its menu.
var TABS = ["search", "discover", "catalog", "top", "new", "favorites", "downloads", "files"];
var TABS = ["search", "discover", "catalog", "top", "homebrew", "favorites", "downloads", "files"];
// The section a header item showed, with the filters and sort it set: it names the page while
// they stay as they are, since the library opens on New releases for Discover and Browse too and
// the sort alone can no longer tell them apart. A change made on the page itself falls back to
@@ -41,7 +41,8 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
if (!query && filters === pinned.filters && sort === pinned.sort) return pinned.section;
if (filters.wishlist) return "favorites";
if (sort === "rating") return "top";
if (sort === "newest") return "new";
// New releases was the whole library sorted the way Browse opens it; Homebrew took its place.
if (sort === "newest") return "catalog";
if (activeCount(filters) > 0 || query) return "catalog";
return "discover";
}
@@ -51,7 +52,7 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
const nav = useNavActions();
const { top } = useNavState();
const show = useShowLibrary();
const underneath = top.name === "downloads" || top.name === "files" ? top.name : sectionOf(store, queryActive);
const underneath = top.name === "downloads" || top.name === "files" || top.name === "homebrew" ? top.name : sectionOf(store, queryActive);
const section = top.name === "search" ? "search" : top.name === "game" ? null : underneath;
const topName = (0, useRef)(top.name);
topName.current = top.name;
@@ -63,7 +64,7 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
search: () => topName.current !== "search" && nav.push({ name: "search" }, "nav:search"),
discover: () => show(NO_FILTERS, "newest", "spotlight:cta", "discover"),
top: () => show(NO_FILTERS, "rating", void 0, "top"),
new: () => show(NO_FILTERS, "newest", void 0, "new"),
homebrew: () => topName.current !== "homebrew" && nav.push({ name: "homebrew" }, "nav:homebrew"),
favorites: () => show({ ...NO_FILTERS, wishlist: true }, "newest", void 0, "favorites"),
downloads: () => topName.current !== "downloads" && nav.push({ name: "downloads" }, "nav:downloads"),
files: () => topName.current !== "files" && nav.push({ name: "files" }, "nav:files")
@@ -78,7 +79,7 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
return;
}
setStopped(false);
if (next === "downloads" || next === "files") {
if (next === "downloads" || next === "files" || next === "homebrew") {
store.setQuery("");
nav.home();
}
+1 -1
View File
@@ -2,6 +2,6 @@
// reconstructed from the esbuild bundle in eboot.bin
var VERSION = "1.0.7";
var VERSION = "1.0.8";
export { VERSION };
+48
View File
@@ -1,5 +1,53 @@
# Changelog
## Unreleased — traditional Chinese text
- Add the `chinese-traditional` text font: Noto Sans TC 2.004, subset to Big5 level 1 (5,401 hanzi
and its symbols). Han characters take it first when the language is `zh-Hant` (or `zh` with region
TW, HK or MO); in other languages it comes after the simplified Chinese font and before the
Japanese one, so a character GB 2312 lacks no longer draws in Japanese forms when both are listed.
## Unreleased — header checks off the render thread
- Add `Archives.inspectAsync(sources)` (ABI v19): `inspect` on a native worker thread, one at a time,
through `archives.inspectAsync`, `inspections.poll` and `inspections.cancel`. A read of a download's
growing file waits behind its writes while they keep the drive at its write rate: on a console
downloading at ~100 MB/s to internal storage, `inspect` on the `.part` file held the render thread
until the download ended (122 s and 341 s in a tester's log, issue #71), and a probe writing that way
reproduced it (18 s at 115 MiB/s, a few ms at 100 MiB/s).
- Each async inspection logs when it is queued and when it is done, with the time it waited and took.
## Unreleased — image registry headroom
- The engine's image registry holds 512 images (was 256). It holds every decoded image, the decoded
cache's up to 256 and the app's baked ones, so a cache full of small images (an app's icons, well under
its 64 MiB) filled it, and each new image after that was discarded with "Image.load: the engine image
registry is full" and never drew. 512 slots cost 45 KB.
- When the registry is full all the same, the image about to be published makes room: cached images
nothing holds are evicted, least recently used first, until it fits; only when every cached image is
held is it discarded. The `images:` stats line adds `cached`, `pinned` (held) and `registry` counts.
## Unreleased — dependencies kept at MyLuxy/overdrive-deps
- ps5-homebrew-ui and the ps5-opengl SDK are no longer available from github.com/blackbearreloaded;
builds worked only from CI's cache. The pinned ps5-homebrew-ui history and the SDK archive are kept,
unchanged, at MyLuxy/overdrive-deps, and the lock file and the build fetch them there. The revision
and the SDK's SHA-256 are the same.
## Unreleased — large 7z archives in rar-extract
- A 7z whose decoder needs more than the title heap's 64 MiB, up to 288 MiB, is extracted by
`rar-extract` (a `7ZX1` request, `native/rar_worker/sevenzip.cpp`, libarchive) in its own process
instead of being refused: repacks set a 256 MiB LZMA2 dictionary (issue #57, Cyberpunk 2077). The
preflight reports the memory a 7z declares (`preflight(sources, seven_zip_memory)`); the engine sends
such a 7z to the worker over the RAR worker's protocol and checks its files the same way.
- The worker takes the decoder's memory and touches every page before decoding, and stops when the
console needs over a second to give it: a payload's `malloc` has no ceiling of its own, and a probe
that took 512 MiB and more froze a console's interface. The process exits after each archive, which
hands the memory back.
- What both extractors share (report lines, path rule, SHA-256, the writer thread) moved from
`rar_worker.cpp` to `worker_io.hpp`.
## Unreleased — home-screen backgrounds
- The build copies `.dds` files from an app's `sce_sys/` as well as `.png`: the home screen shows
+2 -2
View File
@@ -692,8 +692,8 @@ excluded from version control.
Created by **[half144](https://github.com/half144)** and PS5 React contributors.
Built on [Embedded React](https://github.com/TheMasterCoder007/embedded-react),
[ps5-homebrew-ui](https://github.com/blackbearreloaded/ps5-homebrew-ui),
[ps5-opengl](https://github.com/blackbearreloaded/ps5-opengl),
ps5-homebrew-ui and ps5-opengl by BlackBearReloaded (their repositories are gone; copies are kept at
[MyLuxy/overdrive-deps](https://github.com/MyLuxy/overdrive-deps)),
[PS5 Payload SDK](https://github.com/ps5-payload-dev/sdk),
[QuickJS-ng](https://github.com/quickjs-ng/quickjs) and React. The utility palette
and scales come from Tailwind CSS v3.4.17 under the MIT License.
+2 -1
View File
@@ -131,13 +131,14 @@
"items": {
"enum": [
"chinese",
"chinese-traditional",
"japanese",
"devanagari",
"bengali",
"arabic"
]
},
"description": "Scripts whose subset Noto font is packaged in fonts/ and shaped and rasterized at runtime when text needs it: chinese (GB 2312), japanese (JIS X 0208), devanagari (Hindi), bengali, arabic (Arabic and Urdu). See docs/TEXT.md."
"description": "Scripts whose subset Noto font is packaged in fonts/ and shaped and rasterized at runtime when text needs it: chinese (GB 2312), chinese-traditional (Big5 level 1), japanese (JIS X 0208), devanagari (Hindi), bengali, arabic (Arabic and Urdu). See docs/TEXT.md."
},
"video": {
"type": "boolean",
+5 -1
View File
@@ -45,7 +45,7 @@
]
},
"platform": {
"url": "https://github.com/blackbearreloaded/ps5-homebrew-ui.git",
"url": "https://github.com/MyLuxy/overdrive-deps.git",
"revision": "4bd942579dd981b3df9c740438489ca6a614ddc0",
"patches": [
"patches/platform-heap-size.patch",
@@ -114,6 +114,10 @@
"url": "https://raw.githubusercontent.com/notofonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf",
"sha256": "faa6c9df652116dde789d351359f3d7e5d2285a2b2a1f04a2d7244df706d5ea9"
},
"NotoSansTC-Regular.otf": {
"url": "https://raw.githubusercontent.com/notofonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/SubsetOTF/TC/NotoSansTC-Regular.otf",
"sha256": "5bab0cb3c1cf89dde07c4a95a4054b195afbcfe784d69d75c340780712237537"
},
"NotoSansJP-Regular.otf": {
"url": "https://raw.githubusercontent.com/notofonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/SubsetOTF/JP/NotoSansJP-Regular.otf",
"sha256": "dff723ba59d57d136764a04b9b2d03205544f7cd785a711442d6d2d085ac5073"
+6 -6
View File
@@ -7,20 +7,20 @@ No proprietary Sony modules are redistributed.
| Dependency | Purpose and license |
| --- | --- |
| Embedded React | C engine, QuickJS bridge, reconciler, software compositor. Apache-2.0; revision `cf5dfe4fae966fe21a265acc3dd3b054d245f981`. |
| ps5-homebrew-ui | EGL, controller, system, InputTracker, audio mixer and output, heap, CRT, shims, native tools. GPL-3.0-or-later; revision `4bd942579dd981b3df9c740438489ca6a614ddc0`. |
| ps5-homebrew-ui | EGL, controller, system, InputTracker, audio mixer and output, heap, CRT, shims, native tools. GPL-3.0-or-later; revision `4bd942579dd981b3df9c740438489ca6a614ddc0`. Its repository (github.com/blackbearreloaded/ps5-homebrew-ui) is gone; the history up to that revision is kept, unchanged, on the `ps5-homebrew-ui` branch of [MyLuxy/overdrive-deps](https://github.com/MyLuxy/overdrive-deps). |
| ProsperoStore elevation client | Optional cooperative filesystem-access client and pinned helper build. GPL-3.0-or-later; revision `22dca63607cf5f1f95006eda6ffb802607bed699`. |
| 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. Its repository (github.com/blackbearreloaded/ps5-opengl) is gone; the same archive, checked by its SHA-256, is kept at [MyLuxy/overdrive-deps](https://github.com/MyLuxy/overdrive-deps/releases/tag/ps5-opengl-v1.0.0). |
| 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`. |
| HarfBuzz | 14.6.0 (Old MIT license), SHA-256-pinned release archive (`harfbuzz` in the lock file). Linked into both hosts for runtime text: OpenType shaping and outline rasterization (`HB_TINY`, `hb-raster`); also built as the host-side font subsetter `tools/font_subset.cpp`. Builds copy `COPYING` to `notices/harfbuzz-COPYING`. |
| SheenBidi | 3.0.0 (Apache-2.0), SHA-256-pinned release archive (`sheenBidi`): the Unicode bidirectional algorithm for right-to-left text, compiled as one translation unit into both hosts. Builds copy its license to `notices/SheenBidi-LICENSE`. |
| Noto fonts | Noto Sans SC and JP 2.004, Devanagari 2.007, Bengali 3.011 and Arabic 2.013 (SIL OFL 1.1), pinned by commit and SHA-256 (`notoFonts`). Packaged only for the scripts an app lists in `textFonts`, subset by `tools/text_fonts.py`; builds then copy `licenses/Noto-fonts-LICENSE.txt` to `notices/`. |
| Noto fonts | Noto Sans SC, TC and JP 2.004, Devanagari 2.007, Bengali 3.011 and Arabic 2.013 (SIL OFL 1.1), pinned by commit and SHA-256 (`notoFonts`). Packaged only for the scripts an app lists in `textFonts`, subset by `tools/text_fonts.py`; builds then copy `licenses/Noto-fonts-LICENSE.txt` to `notices/`. |
| Material Design sound resources | The starter's interface sounds, (c) Google, CC BY 4.0: six files from the pack's `wav` set, mixed to mono, trimmed, faded and normalized by `tools/ui_sounds.mjs`. Attribution and changes: `licenses/material-sounds-NOTICE.txt`; builds copy it to `notices/material-sounds-NOTICE.txt`. Keep it with any app that ships these sounds. |
| UnRAR | RarLab unrarsrc 7.2.1, SHA-256-pinned (`unrarSource`), patched by `patches/unrar-worker.patch`. UnRAR license (free to use for extracting RAR archives; not GPL-compatible). Linked only into `rar-extract` (`native/rar_worker`, MIT), a separate program the engine starts for RAR sets (the payload loader on the console, a child process on the desktop); never linked into the engine. Builds copy its license to `notices/unRAR-license.txt`. |
| UnRAR | RarLab unrarsrc 7.2.1, SHA-256-pinned (`unrarSource`), patched by `patches/unrar-worker.patch`. UnRAR license (free to use for extracting RAR archives; not GPL-compatible). Linked only into `rar-extract` (`native/rar_worker`, MIT), a separate program the engine starts for RAR sets (the payload loader on the console, a child process on the desktop); never linked into the engine. `rar-extract` also links the archive ports' libarchive and its libraries, for 7z archives over the engine's heap budget. Builds copy its license to `notices/unRAR-license.txt`. |
| FFmpeg | 7.1.5, SHA-256-pinned release archive (`ffmpeg` in the lock file), built static by `tools/ffmpeg.py` with the same components for both hosts: libavformat, libavcodec, libavutil, libswresample and libswscale, configured `--enable-version3` without GPL parts, so LGPL-3.0-or-later. Linked only into apps with `"video": true`; the PS5 build uses the pinned OpenSSL for https. Builds copy `LICENSE.md` and `COPYING.LGPLv3` to `notices/`. The build's corresponding source is the pinned archive plus that configure line. |
| ArtCNN C4F16 | The mpv user shader `GLSL/ArtCNN_C4F16.glsl` of [ArtCNN](https://github.com/Artoriuz/ArtCNN) at commit `8789539dfae120f2877a89bb6218026d56809612`, unchanged, embedded by `native/shared/upscalers/ArtCNN_C4F16.glsl.inc` for `<Video upscale>`. MIT; builds copy `licenses/ArtCNN-LICENSE` to `notices/`. |
| Anime4K Upscale CNN x2 | The mpv user shaders `glsl/Upscale/Anime4K_Upscale_CNN_x2_{S,M,L,VL,UL}.glsl` of [Anime4K](https://github.com/bloc97/Anime4K) at commit `7684e9586f8dcc738af08a1cdceb024cc184f426`, unchanged, embedded by `native/shared/upscalers/` for `<Video upscale>`. MIT; builds copy `licenses/Anime4K-LICENSE` to `notices/`. |
@@ -29,8 +29,8 @@ No proprietary Sony modules are redistributed.
| Inter / LLVM / payload SDK | Inter: SIL OFL. LLVM: Apache-2.0 with exceptions. The public SDK contains separately licensed components. |
Sources: [Embedded React](https://github.com/TheMasterCoder007/embedded-react),
[ps5-homebrew-ui](https://github.com/blackbearreloaded/ps5-homebrew-ui),
[ps5-opengl 1.0.0](https://github.com/blackbearreloaded/ps5-opengl/releases/tag/v1.0.0),
[ps5-homebrew-ui](https://github.com/MyLuxy/overdrive-deps/tree/ps5-homebrew-ui),
[ps5-opengl 1.0.0](https://github.com/MyLuxy/overdrive-deps/releases/tag/ps5-opengl-v1.0.0),
[PS5 Payload SDK](https://github.com/ps5-payload-dev/sdk),
[QuickJS-ng](https://github.com/quickjs-ng/quickjs),
[stb](https://github.com/nothings/stb),
+1 -1
View File
@@ -68,7 +68,7 @@ them with no opt-in.
[NAVIGATION.md](NAVIGATION.md)): in a private 3840×2160 app, a hidden home
screen otherwise pinned about 60 MiB of decoded art while the next page loaded
its own, and the PS5 heap peaked at 238 of 256 MiB.
- **Lazy loading.** As a browser's `loading="lazy"`, an element's image goes to
- **Lazy loading.** As with a browser's `loading="lazy"`, an element's image goes to
the network only once it has been mounted for 120 ms, and no request starts
while a ScrollView moves or for 150 ms after it stops: a held key scrolls past
rows without fetching their art (holding Down through the Overdrive grid for
+17 -1
View File
@@ -586,7 +586,14 @@ read to refuse archives whose declared decoder windows (7z LZMA/LZMA2/PPMd/zstd
and BCJ2 buffers, RAR5 windows, RAR PPMd, ZIP LZMA/XZ/zstd/PPMd, and an outer
xz/zstd/lzip/lzma layer) exceed 64 MiB, since libarchive has no memory limit
and the title's heap is fixed. That budget assumes nothing else large runs during extraction: don't
extract while downloading. Self-extracting executables are refused. This API does not install PKGs or
extract while downloading. A 7z refused for that alone, whose largest folder declares at most 288 MiB
(the 256 MiB LZMA2 dictionary repacks use), is extracted by `rar-extract` instead, in a process of its
own started through the payload loader, with the console's memory rather than the title's heap. It first
takes that memory and writes to every page; when the console needs over a second to hand it over (it is
freeing memory from everything else: a probe froze a console from 512 MiB on), it fails with "The console
has too little free memory to extract this archive now. Close other apps and retry." before decoding.
Without a payload loader it fails with "This archive needs the payload loader to extract: none answered
on port 9021." Over 288 MiB the memory refusal stands. Self-extracting executables are refused. This API does not install PKGs or
register games. Desktop extraction and successful PS5 linking are not console
execution evidence.
@@ -608,6 +615,15 @@ error, so a first volume can be refused before the remaining volumes download.
An empty kind is not proof of anything; image kinds are recognized by superblock
magic only, not validated as mountable.
`Archives.inspectAsync(sources)` (ABI v19) does the same on a native worker
thread and resolves with `{kind, refusal, title, waitedMs, tookMs}`; inspections
run one at a time in the order asked, `waitedMs` behind the earlier ones. Use it
while the file is being written: a read of a download's growing file waits
behind the writes while they keep the drive at its write rate, and on the
console one waited until the download ended (minutes on the render thread with
`inspect`). Each inspection logs `archive inspect queued` and `archive inspect
done` with both times. On a host without it, it falls back to `inspect`.
## Browser download capture (ABI v7)
+1 -1
View File
@@ -19,7 +19,7 @@ elevation (resident Lapy or a localhost ELF loader, as described in
[NATIVE-API.md](NATIVE-API.md#hardware-status)). It uses the console trust store
at `/system/common/cert/CA_LIST.cer`, verified HTTPS, SceNetResolver DNS and
BSD sockets with native-title nonblocking compatibility. Failed elevation or
missing trust certificates leaves networking unavailable with an actionable
missing trust certificates leave networking unavailable with an actionable
error. Desktop networking uses macOS libcurl and its trust configuration;
it is available without the PS5 opt-in. Desktop headers and libraries require
libcurl 7.85 or newer; install a current curl development package if CMake reports
+1 -1
View File
@@ -5,7 +5,7 @@
The previous PS5 host dynamically resolved `statfs` and `getfsstat` from
`libkernel_sys.sprx`. Mount enumeration was reported as unavailable. An earlier
Files-tab crash had made `statvfs` a suspected cause, but no crash dump proved
that attribution. Neither failed exports nor successful compilation establish
that attribution. Neither failed exports nor successful compilation establishes
the behavior of the replacement on hardware.
System Explorer also called both storage functions during every render,
+1 -1
View File
@@ -316,7 +316,7 @@ Images drawn larger or smaller than their baked size are scaled bilinearly. The
engine profile (`native/ps5/CMakeLists.txt`, shared by both hosts) enables
`ERUI_BILINEAR_SCALE`, scales rows in chunks of up to 2560 pixels
(`ERUI_MAX_IMG_ROW_PIXELS`) so wider images still draw in full, and registers
up to 256 baked images
up to 512 images, baked and decoded together
(`ERUI_IMAGE_REGISTRY_MAX`); images past that limit never draw.
## State variants
+7 -4
View File
@@ -51,18 +51,19 @@ A codepoint the baked font lacks draws as `?`, unless a runtime font has it.
List the scripts an app shows in `app.json`:
```json
"textFonts": ["chinese", "japanese", "devanagari", "bengali", "arabic"]
"textFonts": ["chinese", "chinese-traditional", "japanese", "devanagari", "bengali", "arabic"]
```
| Script | Languages | Font (subset) | Characters | Package |
| --- | --- | --- | --- | --- |
| `chinese` | Chinese (Simplified) | Noto Sans SC | GB 2312: 6,763 hanzi, CJK punctuation, full-width forms | 1.6 MB |
| `chinese-traditional` | Chinese (Traditional) | Noto Sans TC | Big5 level 1: 5,401 hanzi and its symbols, CJK punctuation, full-width forms | about 1.4 MB (not measured) |
| `japanese` | Japanese | Noto Sans JP | JIS X 0208: kana, 6,355 kanji (levels 1 and 2), punctuation | 1.7 MB |
| `devanagari` | Hindi, Marathi, Nepali | Noto Sans Devanagari | Devanagari, Devanagari Extended, Vedic Extensions | 0.16 MB |
| `bengali` | Bengali, Assamese | Noto Sans Bengali | Bengali | 0.09 MB |
| `arabic` | Arabic, Urdu, Persian | Noto Sans Arabic | Arabic, Supplement, Extended-A, presentation forms | 0.11 MB |
All five add 3.7 MB to the package. Urdu is drawn in Noto Sans Arabic's Naskh
The five scripts other than `chinese-traditional` add 3.7 MB to the package. Urdu is drawn in Noto Sans Arabic's Naskh
style, not Nastaliq.
The build pins each font in `dependencies.lock.json`, subsets it with HarfBuzz
@@ -72,8 +73,10 @@ preview reads the same files from `.build/<app>/generated/fonts/`. Han
characters outside one font fall back to the other when both are listed, so a
Japanese UI that lists both also draws kanji beyond JIS X 0208.
Kana always uses the Japanese font. Han characters shared by both use the
Chinese forms unless the language is Japanese: the shaper starts with the
Kana always uses the Japanese font. Han characters use the simplified Chinese
forms, then traditional, then Japanese; the traditional forms come first when the
language is traditional Chinese (`zh-Hant`, or `zh` with region TW, HK or MO), and
the Japanese ones when it is Japanese: the shaper starts with the
system language (`DeviceInfo.get().language`) and `Fonts.setLanguage(tag)`
overrides it (see [Native API](NATIVE-API.md)).
+1 -1
View File
@@ -3,7 +3,7 @@ subset at build time by tools/text_fonts.py: only the characters listed there
are kept, and hinting is removed. These are Modified Versions under the license
below.
NotoSansSC-Regular.otf, NotoSansJP-Regular.otf (Noto Sans CJK 2.004)
NotoSansSC-Regular.otf, NotoSansTC-Regular.otf, NotoSansJP-Regular.otf (Noto Sans CJK 2.004)
Copyright © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font
Name 'Source'.
https://github.com/notofonts/noto-cjk
+6 -3
View File
@@ -33,11 +33,14 @@ set(ERUI_XFORM_CACHE_SLOTS 2 CACHE STRING "" FORCE)
set(ERUI_MAX_ANIM_VALUES 1024 CACHE STRING "" FORCE)
set(ERUI_MAX_VALUE_BINDINGS 8 CACHE STRING "" FORCE)
# Photos are baked at half size and scaled bilinearly. The scaler and gradient assemble rows of up to
# 2560 px (+7 KB per worker each over 800 px) and render wider ones in chunks. 256 image slots cost
# +11 KB at 88 B per slot.
# 2560 px (+7 KB per worker each over 800 px) and render wider ones in chunks. The registry holds every
# decoded image: the cache's 256 (image_loader.hpp kCacheEntries, in use or not) plus the app's baked
# ones. At 256 slots, a cache of 256 small images (an app's icons, under its 64 MiB) left none for the
# rest, and new images were discarded ("the engine image registry is full"). 512 slots cost 45 KB at
# 88 B per slot.
set(ERUI_BILINEAR_SCALE ON CACHE BOOL "" FORCE)
set(ERUI_MAX_IMG_ROW_PIXELS 2560 CACHE STRING "" FORCE)
set(ERUI_IMAGE_REGISTRY_MAX 256 CACHE STRING "" FORCE)
set(ERUI_IMAGE_REGISTRY_MAX 512 CACHE STRING "" FORCE)
# View backgroundGradient (linear and radial; also <Svg> gradient fills): about 5 KB of code, an 8 KB
# colour ramp and a 10 KB row per render worker; nodes without a gradient pay nothing per frame.
set(ERUI_GRADIENT ON CACHE BOOL "" FORCE)
+2 -1
View File
@@ -1,6 +1,7 @@
rar-extract
native/rar_worker/rar_worker.cpp is licensed under the MIT License:
native/rar_worker/rar_worker.cpp, sevenzip.cpp, sevenzip.hpp and worker_io.hpp are licensed under the MIT
License:
Copyright (C) 2026 half144
+11 -322
View File
@@ -4,7 +4,8 @@
// rar-extract: extracts one RAR set with RarLab's UnRAR library, in a process of its own. UnRAR's license
// does not let it be linked into the GPL engine, so the engine talks to this program over a stream
// (the PS5 payload loader's socket, or a pipe on the desktop) and never loads its code. Its own
// crashes and C++ exceptions stay in this process too.
// crashes and C++ exceptions stay in this process too. A request that starts with "7ZX1" is a 7z whose
// decoder needs more memory than the app's heap can give, extracted with libarchive (sevenzip.cpp).
//
// Request, one item per line: "RARX1", then "threads <n>" (0 picks from the cores), "window <bytes>",
// "limit <bytes>", "flatten <extension>"..., "reserved <text>", "password <hex>", "dest <directory>",
@@ -18,6 +19,8 @@
// decoding with "fail Incomplete RAR archive or missing volume.", one whose first is not the first with
// "fail Missing first RAR volume."; the app reads these two exactly.
#include "rar.hpp"
#include "sevenzip.hpp"
#include "worker_io.hpp"
#ifdef __APPLE__
#include <CommonCrypto/CommonDigest.h>
#else
@@ -37,12 +40,7 @@
#include <unistd.h>
namespace {
constexpr size_t max_path_length = 4096, max_line = 8192, buffer_bytes = 4 << 20, buffer_count = 4;
// Each queued file holds its descriptor; empty files take no buffer, so this bounds how far ahead they open.
constexpr size_t max_queued_files = 32;
constexpr unsigned max_depth = 128;
constexpr uint64 progress_ns = 250000000;
const char* too_much_memory = "This archive needs more memory to extract than the console app can use.";
using namespace worker;
const char* missing_first = "Missing first RAR volume.";
struct Options {
@@ -53,41 +51,7 @@ struct Options {
std::string reserved, password, destination;
};
std::mutex say_mutex;
bool cancelled = false; // Only the decoding thread reads and sets it.
bool output_lost = false;
void say(const std::string& line) {
std::lock_guard lock(say_mutex);
const std::string text = line + "\n";
size_t done = 0;
while (done < text.size() && !output_lost) {
const ssize_t count = write(STDOUT_FILENO, text.data() + done, text.size() - done);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) output_lost = true;
else done += count;
}
}
uint64 now_ns() {
timespec time;
clock_gettime(CLOCK_MONOTONIC, &time);
return uint64(time.tv_sec) * 1000000000 + time.tv_nsec;
}
// The request is read byte by byte so nothing past "end" (a later "cancel") is consumed early.
bool read_line(std::string& line) {
line.clear();
char c;
while (line.size() < max_line) {
const ssize_t count = read(STDIN_FILENO, &c, 1);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return false;
if (c == '\n') return true;
line += c;
}
return false;
}
bool from_hex(const std::string& hex, std::string& out) {
if (hex.size() % 2) return false;
@@ -103,9 +67,9 @@ bool from_hex(const std::string& hex, std::string& out) {
return true;
}
// The lines after "RARX1".
bool read_request(Options& options) {
std::string line;
if (!read_line(line) || line != "RARX1") return false;
while (read_line(line)) {
const auto space = line.find(' ');
const std::string key = line.substr(0, space), value = space == std::string::npos ? "" : line.substr(space + 1);
@@ -123,270 +87,6 @@ bool read_request(Options& options) {
return false;
}
// True once the engine asked to stop or went away; checked a few times a second.
bool stop_requested() {
pollfd input{STDIN_FILENO, POLLIN, 0};
if (poll(&input, 1, 0) > 0) {
std::string line;
if (!read_line(line) || line == "cancel") return true;
}
return output_lost;
}
// "a/./b" is "a/b", as the engine reads it.
std::string without_dots(const std::string& name) {
std::string out;
size_t start = 0;
while (start <= name.size()) {
size_t end = name.find('/', start);
if (end == std::string::npos) end = name.size();
const std::string part = name.substr(start, end - start);
if (part != ".") out += (out.empty() ? "" : "/") + part;
start = end + 1;
}
return out;
}
// The same rule as the engine: a relative path of plain components, no separators or controls that
// another system would read differently.
bool safe(const std::string& name) {
if (name.empty() || name.size() > max_path_length || name[0] == '/') return false;
for (unsigned char c : name)
if (c < 32 || c == 127 || c == '|' || c == '\\' || c == ':') return false;
size_t start = 0;
unsigned depth = 0;
while (start < name.size()) {
size_t end = name.find('/', start);
if (end == std::string::npos) end = name.size();
const std::string part = name.substr(start, end - start);
if (part.empty() || part == ".." || ++depth > max_depth) return false;
start = end + 1;
}
return true;
}
bool plain_directory(const std::string& path) {
struct stat st;
return !lstat(path.c_str(), &st) && S_ISDIR(st.st_mode);
}
// Creates the directories of `name` under `root`, none of them a link, and returns the full path.
bool make_parents(const std::string& root, const std::string& name, std::string& target) {
target = root;
size_t start = 0, end;
while ((end = name.find('/', start)) != std::string::npos) {
target += "/" + name.substr(start, end - start);
if (mkdir(target.c_str(), 0777) && errno != EEXIST) return false;
if (!plain_directory(target)) { errno = ENOTDIR; return false; }
start = end + 1;
}
target += "/" + name.substr(start);
return true;
}
// UnRAR's own SHA-256 is portable C and slower than decoding; these use the CPU's SHA instructions.
class Sha256 {
public:
#ifdef __APPLE__
void init() { CC_SHA256_Init(&context_); }
void update(const void* data, size_t size) { CC_SHA256_Update(&context_, data, CC_LONG(size)); }
void finish(byte* digest) { CC_SHA256_Final(digest, &context_); }
#else
Sha256() : context_(EVP_MD_CTX_new()) {}
~Sha256() { EVP_MD_CTX_free(context_); }
void init() { EVP_DigestInit_ex(context_, EVP_sha256(), nullptr); }
void update(const void* data, size_t size) { EVP_DigestUpdate(context_, data, size); }
void finish(byte* digest) { EVP_DigestFinal_ex(context_, digest, nullptr); }
#endif
private:
#ifdef __APPLE__
CC_SHA256_CTX context_;
#else
EVP_MD_CTX* context_;
#endif
};
std::string hex(const byte* data, size_t size) {
static const char digits[] = "0123456789abcdef";
std::string out;
for (size_t i = 0; i < size; ++i) { out += digits[data[i] >> 4]; out += digits[data[i] & 15]; }
return out;
}
// Writes, hashes, syncs and closes files on a thread of its own, so decoding never waits on the disk.
// Memory is the fixed set of buffers; the decoder waits for a free one when the disk falls behind.
class Writer {
public:
Writer() {
for (auto& buffer : buffers_) {
buffer.reset(new byte[buffer_bytes]);
free_.push_back(buffer.get());
}
thread_ = std::thread([this] { loop(); });
}
~Writer() {
{
std::lock_guard lock(mutex_);
quit_ = true;
}
changed_.notify_all();
thread_.join();
if (fd_ >= 0) close(fd_);
}
// Takes the descriptor; the file is reported as `name` once synced.
void begin(int fd, const std::string& name) {
current_ = nullptr;
{
const uint64 started = now_ns();
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return queued_files_ < max_queued_files; });
++queued_files_;
wait_ns += now_ns() - started;
}
push({Command::begin, fd, name, nullptr, 0});
}
bool append(const byte* data, size_t size) {
while (size) {
if (!current_ && !(current_ = take())) return false;
const size_t count = std::min(size, buffer_bytes - used_);
memcpy(current_ + used_, data, count);
used_ += count;
data += count;
size -= count;
if (used_ == buffer_bytes) flush();
}
return error().empty();
}
void end(uint64 size) {
if (current_) flush();
push({Command::end, -1, {}, nullptr, size});
}
// Waits until every queued file is closed; the first error, if any.
std::string finish() {
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return queue_.empty() && !busy_; });
return error_;
}
std::string error() {
std::lock_guard lock(mutex_);
return error_;
}
// Time in pwrite and fsync on the writer thread, and the decoder's waits for a buffer or a file slot.
// Read once finish() returned.
uint64 write_ns = 0, sync_ns = 0, wait_ns = 0;
unsigned writes = 0, syncs = 0;
private:
struct Command {
enum Type { begin, data, end } type;
int fd;
std::string name;
byte* buffer;
uint64 size;
};
byte* take() {
const uint64 started = now_ns();
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return !free_.empty() || !error_.empty(); });
wait_ns += now_ns() - started;
if (!error_.empty()) return nullptr;
byte* buffer = free_.front();
free_.pop_front();
used_ = 0;
return buffer;
}
void flush() {
push({Command::data, -1, {}, current_, used_});
current_ = nullptr;
}
void push(Command command) {
{
std::lock_guard lock(mutex_);
queue_.push_back(std::move(command));
}
changed_.notify_all();
}
void fail(const std::string& reason) {
std::lock_guard lock(mutex_);
if (error_.empty()) error_ = reason;
}
void run(Command& command) {
if (command.type == Command::begin) {
fd_ = command.fd;
name_ = command.name;
written_ = 0;
hash_.init();
} else if (command.type == Command::data) {
const uint64 started = now_ns();
size_t done = 0;
while (done < command.size && error().empty()) {
const ssize_t count = pwrite(fd_, command.buffer + done, command.size - done, written_ + done);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) fail(strerror(count < 0 ? errno : ENOSPC));
else done += count;
}
write_ns += now_ns() - started;
++writes;
hash_.update(command.buffer, command.size);
written_ += command.size;
} else {
if (written_ != command.size) {
fail("Extracted file size mismatch.");
} else {
const uint64 started = now_ns();
if (fsync(fd_)) fail(strerror(errno));
sync_ns += now_ns() - started;
++syncs;
}
const bool closed = !close(fd_);
fd_ = -1;
if (!closed) fail(strerror(errno));
if (error().empty()) {
byte digest[SHA256_DIGEST_SIZE];
hash_.finish(digest);
say("f " + hex(digest, sizeof digest) + " " + std::to_string(written_) + " " + name_);
}
}
}
void loop() {
std::unique_lock lock(mutex_);
while (true) {
changed_.wait(lock, [this] { return quit_ || !queue_.empty(); });
if (queue_.empty()) return;
Command command = std::move(queue_.front());
queue_.pop_front();
queued_files_ -= command.type == Command::begin;
busy_ = true;
lock.unlock();
// After an error, commands only give their buffers and descriptors back.
if (error().empty()) run(command);
else if (command.type == Command::begin) close(command.fd);
lock.lock();
busy_ = false;
if (command.buffer) free_.push_back(command.buffer);
changed_.notify_all();
}
}
std::unique_ptr<byte[]> buffers_[buffer_count];
std::deque<byte*> free_;
std::deque<Command> queue_;
std::mutex mutex_;
std::condition_variable changed_;
std::string error_;
bool quit_ = false, busy_ = false;
size_t queued_files_ = 0;
std::thread thread_;
byte* current_ = nullptr;
size_t used_ = 0;
// Writer thread only.
int fd_ = -1;
std::string name_;
uint64 written_ = 0;
Sha256 hash_;
};
struct Session {
explicit Session(const Options& options) : options(options) {}
const Options& options;
@@ -508,8 +208,6 @@ int dll_error(CommandData& command) {
}
}
long cores() { return sysconf(_SC_NPROCESSORS_ONLN); }
// The engine asks for fewer while downloads run. One core always stays with the app's interface; when the
// count is unknown, the cap alone applies.
unsigned pick_threads(unsigned requested) {
@@ -518,18 +216,6 @@ unsigned pick_threads(unsigned requested) {
return unsigned(std::min<long>(requested, known > 0 ? std::clamp(known - 1, 1L, 6L) : 6));
}
uint64 process_cpu_us() {
rusage usage;
if (getrusage(RUSAGE_SELF, &usage)) return 0;
return (uint64(usage.ru_utime.tv_sec) + usage.ru_stime.tv_sec) * 1000000 + usage.ru_utime.tv_usec + usage.ru_stime.tv_usec;
}
bool flattened(const Options& options, const std::string& name) {
for (const auto& extension : options.flatten)
if (name.size() >= extension.size() && !name.compare(name.size() - extension.size(), extension.size(), extension)) return true;
return false;
}
// Throws RAR_EXIT or std::bad_alloc from UnRAR; returns the reason extraction stopped, empty on success.
std::string extract(Session& session, CommandData& command, Archive& archive, CmdExtract& extractor) {
const Options& options = session.options;
@@ -564,7 +250,7 @@ std::string extract(Session& session, CommandData& command, Archive& archive, Cm
declared += header.UnpSize;
}
// ShadowMount's default scan reaches one subdirectory, so images are published at the set root.
if (!header.Dir && flattened(options, name)) name = name.substr(name.rfind('/') + 1);
if (!header.Dir && flattened(options.flatten, name)) name = name.substr(name.rfind('/') + 1);
std::string target;
if (!make_parents(options.destination, name, target)) return strerror(errno);
if (header.Dir) {
@@ -761,8 +447,11 @@ int main() {
signal(SIGPIPE, SIG_IGN);
// Directories and files stay usable by the app, which may run as another user than this program.
umask(0);
// "7ZX1" asks for a 7z (sevenzip.cpp); "RARX1" for a RAR set.
std::string magic;
if (read_line(magic) && magic == "7ZX1") return sevenzip::run();
Options options;
if (!read_request(options)) {
if (magic != "RARX1" || !read_request(options)) {
say("fail Invalid RAR worker request.");
return 1;
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: MIT
//
// rar-extract's 7z extractor. A 7z whose decoder needs more memory than the app's own heap can give
// (most often an LZMA2 dictionary over 64 MiB, which repacks set to 256 MiB) is extracted here with
// libarchive, in this process's memory, which goes back to the console when the process exits. The
// engine sends one only when the archive's headers declare no more than the budget it names.
//
// Request, after the "7ZX1" line: "memory <bytes>" (what the headers declare the decoder needs), "limit
// <bytes>", "flatten <extension>"..., "reserved <text>", "dest <directory>", "source <path>"... in volume
// order, then "end". The engine can then write "cancel" at any time. Answers as for a RAR set: "ready",
// "p <bytes written>", "k" every second, "f <sha256> <size> <path>" once a file is on disk and synced,
// "s" once every file is decoded, "t <key>=<value>...", then "ok", "cancelled" or "fail <reason>".
//
// A program's malloc on the console has no ceiling of its own: the system hands over memory it takes
// from everything else, slowly (a probe on a console took 8.5 s for 512 MiB and froze its interface
// beyond that), and keeps it with this process until it exits. So the decoder's memory is taken and
// every page touched before anything is decoded; when the console needs over a second to give it, the
// extraction stops there instead of running with the console short. The block is then freed into this
// process's heap, where libarchive's own allocation finds it.
#include "sevenzip.hpp"
#include "worker_io.hpp"
#include <archive.h>
#include <archive_entry.h>
#include <chrono>
#include <cstdlib>
#include <new>
namespace sevenzip {
namespace {
using namespace worker;
const char* memory_busy = "The console has too little free memory to extract this archive now. Close other apps and retry.";
const char* unsafe = "Archive contains an unsafe path, link or special file.";
const char* too_large = "Archive exceeds the extraction size limit.";
// What the extraction holds beside the decoder: the writer's buffers and libarchive's read blocks.
constexpr std::uint64_t overhead = 32ULL << 20;
constexpr std::uint64_t claim_ms = 1000;
constexpr std::uint64_t page = 4096;
constexpr size_t read_block = 1 << 20;
struct Options {
std::uint64_t memory = 0, limit = 0;
std::vector<std::string> flatten, sources;
std::string reserved, destination;
};
bool cancelled = false; // Only the decoding thread reads and sets it.
bool read_options(Options& options) {
std::string line;
while (read_line(line)) {
const auto space = line.find(' ');
const std::string key = line.substr(0, space), value = space == std::string::npos ? "" : line.substr(space + 1);
if (key == "end") return !options.destination.empty() && !options.sources.empty() && options.limit && options.memory;
if (key == "memory") options.memory = strtoull(value.c_str(), nullptr, 10);
else if (key == "limit") options.limit = strtoull(value.c_str(), nullptr, 10);
else if (key == "flatten") options.flatten.push_back(value);
else if (key == "reserved") options.reserved = value;
else if (key == "dest") options.destination = value;
else if (key == "source") options.sources.push_back(value);
}
return false;
}
// Takes `bytes` and writes a byte in every page so the console really hands them over, then frees them.
std::string claim(std::uint64_t bytes, std::uint64_t& took_ms) {
const auto started = now_ns();
auto* block = static_cast<volatile unsigned char*>(std::malloc(bytes));
if (!block) return too_much_memory;
for (std::uint64_t at = 0; at < bytes; at += page) block[at] = 1;
took_ms = (now_ns() - started) / 1000000;
std::free(const_cast<unsigned char*>(block));
return took_ms > claim_ms ? memory_busy : std::string();
}
struct Progress {
std::uint64_t written = 0, last_report = 0;
void beat() {
const auto now = now_ns();
if (now - last_report < progress_ns) return;
last_report = now;
say("p " + std::to_string(written));
if (stop_requested()) cancelled = true;
}
};
// libarchive names a failed allocation in its decoder only by liblzma's code; either way it is memory.
std::string archive_failure(archive* reader, const char* fallback) {
const char* text = archive_error_string(reader);
if (archive_errno(reader) == ENOMEM || (text && std::strstr(text, "memory"))) return too_much_memory;
return text && *text ? text : fallback;
}
std::string extract(const Options& options, Writer& writer, Progress& progress) {
archive* reader = archive_read_new();
if (!reader) return too_much_memory;
archive_read_support_format_7zip(reader);
std::vector<const char*> files;
for (const auto& path : options.sources) files.push_back(path.c_str());
files.push_back(nullptr);
std::string error;
if (archive_read_open_filenames(reader, files.data(), read_block) != ARCHIVE_OK) error = archive_failure(reader, "Unsupported or incomplete archive.");
archive_entry* entry = nullptr;
int result = ARCHIVE_OK;
std::uint64_t declared = 0;
// A warning is a header libarchive could only read in part, such as a name it could not convert.
while (error.empty() && !cancelled && ((result = archive_read_next_header(reader, &entry)) == ARCHIVE_OK || result == ARCHIVE_WARN)) {
progress.beat();
if (cancelled) break;
const char* raw = archive_entry_pathname_utf8(entry);
if (!raw) {
error = "Archive has a file name this console cannot read.";
break;
}
std::string name(raw);
while (!name.empty() && name.back() == '/') name.pop_back();
if (!name.empty() && name[0] != '/') name = without_dots(name);
const auto type = archive_entry_filetype(entry);
const bool link = archive_entry_symlink(entry) || archive_entry_hardlink(entry);
if (!safe(name) || link || (type != AE_IFREG && type != AE_IFDIR) ||
(!options.reserved.empty() && name.find(options.reserved) != std::string::npos)) {
error = unsafe;
break;
}
const auto size = archive_entry_size(entry);
if (type == AE_IFREG) {
if (size < 0 || std::uint64_t(size) > options.limit - declared) {
error = too_large;
break;
}
declared += std::uint64_t(size);
// ShadowMount's default scan reaches one subdirectory, so images are published at the set root.
if (flattened(options.flatten, name)) name = name.substr(name.rfind('/') + 1);
}
std::string target;
if (!make_parents(options.destination, name, target)) {
error = strerror(errno);
break;
}
if (type == AE_IFDIR) {
if (mkdir(target.c_str(), 0777) && (errno != EEXIST || !plain_directory(target))) error = strerror(errno);
continue;
}
const int fd = open(target.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0666);
if (fd < 0) {
error = strerror(errno);
break;
}
writer.begin(fd, name);
const void* data = nullptr;
size_t bytes = 0;
la_int64_t offset = 0;
std::uint64_t file_written = 0;
while (!cancelled && (result = archive_read_data_block(reader, &data, &bytes, &offset)) == ARCHIVE_OK) {
// 7z entries come in order; a gap would be a sparse file, which 7-Zip does not write.
if (offset < 0 || std::uint64_t(offset) != file_written) {
error = "Unsupported or damaged 7z archive.";
break;
}
if (bytes > options.limit - progress.written) {
error = too_large;
break;
}
if (!writer.append(static_cast<const unsigned char*>(data), bytes)) break;
file_written += bytes;
progress.written += bytes;
progress.beat();
}
if (error.empty() && !cancelled && result != ARCHIVE_EOF && result != ARCHIVE_OK) error = archive_failure(reader, "Unsupported or damaged 7z archive.");
if (error.empty()) error = writer.error();
if (!error.empty() || cancelled) break;
writer.end(std::uint64_t(size));
}
if (error.empty() && !cancelled && result != ARCHIVE_EOF) error = archive_failure(reader, "Incomplete archive headers.");
archive_read_free(reader);
return error;
}
}
int run() {
// Directories and files stay usable by the app, which may run as another user than this program.
umask(0);
Options options;
if (!read_options(options)) {
say("fail Invalid 7z worker request.");
return 1;
}
say("ready");
// "k" every second says this process is alive while the claim, a long fsync or a slow block holds it.
std::mutex alive_mutex;
std::condition_variable alive_wake;
bool done = false;
std::thread alive;
try {
alive = std::thread([&] {
std::unique_lock lock(alive_mutex);
while (!alive_wake.wait_for(lock, std::chrono::seconds(1), [&] { return done; })) say("k");
});
} catch (...) {
}
std::uint64_t claim_took = 0;
std::string error;
{
Progress progress;
std::unique_ptr<Writer> writer;
try {
error = claim(options.memory + overhead, claim_took);
if (error.empty()) {
writer = std::make_unique<Writer>();
error = extract(options, *writer, progress);
}
} catch (std::bad_alloc&) {
error = too_much_memory;
} catch (...) {
error = "7z extraction failed.";
}
if (writer) {
if (error.empty() && !cancelled) say("s");
const auto written = writer->finish();
if (error.empty()) error = written;
}
const auto ms = [](std::uint64_t ns) { return std::to_string(ns / 1000000) + "ms"; };
std::string timings = "t threads=1 cores=" + std::to_string(cores()) + " claim=" + std::to_string(claim_took) + "ms";
if (writer) {
timings += " write=" + ms(writer->write_ns) + "/" + std::to_string(writer->writes) + " fsync=" + ms(writer->sync_ns) + "/" +
std::to_string(writer->syncs) + " waited=" + ms(writer->wait_ns);
}
if (const std::uint64_t cpu = process_cpu_us()) timings += " cpu=" + ms(cpu * 1000);
say(timings);
}
{
std::lock_guard lock(alive_mutex);
done = true;
}
alive_wake.notify_all();
if (alive.joinable()) alive.join();
if (cancelled) say("cancelled");
else say(error.empty() ? "ok" : "fail " + error);
return error.empty() ? 0 : 1;
}
}
+9
View File
@@ -0,0 +1,9 @@
// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: MIT
#pragma once
namespace sevenzip {
// Reads the rest of a "7ZX1" request from standard input, extracts the 7z and reports on standard output.
// Returns the process exit code.
int run();
}
+352
View File
@@ -0,0 +1,352 @@
// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: MIT
//
// What rar-extract's extractors share, whatever the format: the request and report lines on standard
// input and output, the engine's path rule, SHA-256 and the writer thread that writes, hashes and syncs
// every file. Free of UnRAR's headers, so the 7z extractor, which uses libarchive, includes it too.
#pragma once
#ifdef __APPLE__
#include <CommonCrypto/CommonDigest.h>
#else
#include <openssl/evp.h>
#endif
#include <algorithm>
#include <cerrno>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <deque>
#include <fcntl.h>
#include <memory>
#include <mutex>
#include <poll.h>
#include <string>
#include <sys/resource.h>
#include <sys/stat.h>
#include <thread>
#include <time.h>
#include <unistd.h>
#include <vector>
namespace worker {
inline constexpr size_t max_path_length = 4096, max_line = 8192, buffer_bytes = 4 << 20, buffer_count = 4;
// Each queued file holds its descriptor; empty files take no buffer, so this bounds how far ahead they open.
inline constexpr size_t max_queued_files = 32;
inline constexpr unsigned max_depth = 128;
inline constexpr std::uint64_t progress_ns = 250000000;
inline const char* const too_much_memory = "This archive needs more memory to extract than the console app can use.";
inline constexpr size_t sha256_size = 32;
inline std::mutex say_mutex;
inline bool output_lost = false;
inline void say(const std::string& line) {
std::lock_guard lock(say_mutex);
const std::string text = line + "\n";
size_t done = 0;
while (done < text.size() && !output_lost) {
const ssize_t count = write(STDOUT_FILENO, text.data() + done, text.size() - done);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) output_lost = true;
else done += count;
}
}
inline std::uint64_t now_ns() {
timespec time;
clock_gettime(CLOCK_MONOTONIC, &time);
return std::uint64_t(time.tv_sec) * 1000000000 + time.tv_nsec;
}
// The request is read unsigned char by unsigned char so nothing past "end" (a later "cancel") is consumed early.
inline bool read_line(std::string& line) {
line.clear();
char c;
while (line.size() < max_line) {
const ssize_t count = read(STDIN_FILENO, &c, 1);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return false;
if (c == '\n') return true;
line += c;
}
return false;
}
// True once the engine asked to stop or went away; checked a few times a second.
inline bool stop_requested() {
pollfd input{STDIN_FILENO, POLLIN, 0};
if (poll(&input, 1, 0) > 0) {
std::string line;
if (!read_line(line) || line == "cancel") return true;
}
return output_lost;
}
// "a/./b" is "a/b", as the engine reads it.
inline std::string without_dots(const std::string& name) {
std::string out;
size_t start = 0;
while (start <= name.size()) {
size_t end = name.find('/', start);
if (end == std::string::npos) end = name.size();
const std::string part = name.substr(start, end - start);
if (part != ".") out += (out.empty() ? "" : "/") + part;
start = end + 1;
}
return out;
}
// The same rule as the engine: a relative path of plain components, no separators or controls that
// another system would read differently.
inline bool safe(const std::string& name) {
if (name.empty() || name.size() > max_path_length || name[0] == '/') return false;
for (unsigned char c : name)
if (c < 32 || c == 127 || c == '|' || c == '\\' || c == ':') return false;
size_t start = 0;
unsigned depth = 0;
while (start < name.size()) {
size_t end = name.find('/', start);
if (end == std::string::npos) end = name.size();
const std::string part = name.substr(start, end - start);
if (part.empty() || part == ".." || ++depth > max_depth) return false;
start = end + 1;
}
return true;
}
inline bool plain_directory(const std::string& path) {
struct stat st;
return !lstat(path.c_str(), &st) && S_ISDIR(st.st_mode);
}
// Creates the directories of `name` under `root`, none of them a link, and returns the full path.
inline bool make_parents(const std::string& root, const std::string& name, std::string& target) {
target = root;
size_t start = 0, end;
while ((end = name.find('/', start)) != std::string::npos) {
target += "/" + name.substr(start, end - start);
if (mkdir(target.c_str(), 0777) && errno != EEXIST) return false;
if (!plain_directory(target)) { errno = ENOTDIR; return false; }
start = end + 1;
}
target += "/" + name.substr(start);
return true;
}
// UnRAR's own SHA-256 is portable C and slower than decoding; these use the CPU's SHA instructions.
class Sha256 {
public:
#ifdef __APPLE__
void init() { CC_SHA256_Init(&context_); }
void update(const void* data, size_t size) { CC_SHA256_Update(&context_, data, CC_LONG(size)); }
void finish(unsigned char* digest) { CC_SHA256_Final(digest, &context_); }
#else
Sha256() : context_(EVP_MD_CTX_new()) {}
~Sha256() { EVP_MD_CTX_free(context_); }
void init() { EVP_DigestInit_ex(context_, EVP_sha256(), nullptr); }
void update(const void* data, size_t size) { EVP_DigestUpdate(context_, data, size); }
void finish(unsigned char* digest) { EVP_DigestFinal_ex(context_, digest, nullptr); }
#endif
private:
#ifdef __APPLE__
CC_SHA256_CTX context_;
#else
EVP_MD_CTX* context_;
#endif
};
inline std::string hex(const unsigned char* data, size_t size) {
static const char digits[] = "0123456789abcdef";
std::string out;
for (size_t i = 0; i < size; ++i) { out += digits[data[i] >> 4]; out += digits[data[i] & 15]; }
return out;
}
// Writes, hashes, syncs and closes files on a thread of its own, so decoding never waits on the disk.
// Memory is the fixed set of buffers; the decoder waits for a free one when the disk falls behind.
class Writer {
public:
Writer() {
for (auto& buffer : buffers_) {
buffer.reset(new unsigned char[buffer_bytes]);
free_.push_back(buffer.get());
}
thread_ = std::thread([this] { loop(); });
}
~Writer() {
{
std::lock_guard lock(mutex_);
quit_ = true;
}
changed_.notify_all();
thread_.join();
if (fd_ >= 0) close(fd_);
}
// Takes the descriptor; the file is reported as `name` once synced.
void begin(int fd, const std::string& name) {
current_ = nullptr;
{
const std::uint64_t started = now_ns();
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return queued_files_ < max_queued_files; });
++queued_files_;
wait_ns += now_ns() - started;
}
push({Command::begin, fd, name, nullptr, 0});
}
bool append(const unsigned char* data, size_t size) {
while (size) {
if (!current_ && !(current_ = take())) return false;
const size_t count = std::min(size, buffer_bytes - used_);
memcpy(current_ + used_, data, count);
used_ += count;
data += count;
size -= count;
if (used_ == buffer_bytes) flush();
}
return error().empty();
}
void end(std::uint64_t size) {
if (current_) flush();
push({Command::end, -1, {}, nullptr, size});
}
// Waits until every queued file is closed; the first error, if any.
std::string finish() {
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return queue_.empty() && !busy_; });
return error_;
}
std::string error() {
std::lock_guard lock(mutex_);
return error_;
}
// Time in pwrite and fsync on the writer thread, and the decoder's waits for a buffer or a file slot.
// Read once finish() returned.
std::uint64_t write_ns = 0, sync_ns = 0, wait_ns = 0;
unsigned writes = 0, syncs = 0;
private:
struct Command {
enum Type { begin, data, end } type;
int fd;
std::string name;
unsigned char* buffer;
std::uint64_t size;
};
unsigned char* take() {
const std::uint64_t started = now_ns();
std::unique_lock lock(mutex_);
changed_.wait(lock, [this] { return !free_.empty() || !error_.empty(); });
wait_ns += now_ns() - started;
if (!error_.empty()) return nullptr;
unsigned char* buffer = free_.front();
free_.pop_front();
used_ = 0;
return buffer;
}
void flush() {
push({Command::data, -1, {}, current_, used_});
current_ = nullptr;
}
void push(Command command) {
{
std::lock_guard lock(mutex_);
queue_.push_back(std::move(command));
}
changed_.notify_all();
}
void fail(const std::string& reason) {
std::lock_guard lock(mutex_);
if (error_.empty()) error_ = reason;
}
void run(Command& command) {
if (command.type == Command::begin) {
fd_ = command.fd;
name_ = command.name;
written_ = 0;
hash_.init();
} else if (command.type == Command::data) {
const std::uint64_t started = now_ns();
size_t done = 0;
while (done < command.size && error().empty()) {
const ssize_t count = pwrite(fd_, command.buffer + done, command.size - done, written_ + done);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) fail(strerror(count < 0 ? errno : ENOSPC));
else done += count;
}
write_ns += now_ns() - started;
++writes;
hash_.update(command.buffer, command.size);
written_ += command.size;
} else {
if (written_ != command.size) {
fail("Extracted file size mismatch.");
} else {
const std::uint64_t started = now_ns();
if (fsync(fd_)) fail(strerror(errno));
sync_ns += now_ns() - started;
++syncs;
}
const bool closed = !close(fd_);
fd_ = -1;
if (!closed) fail(strerror(errno));
if (error().empty()) {
unsigned char digest[sha256_size];
hash_.finish(digest);
say("f " + hex(digest, sizeof digest) + " " + std::to_string(written_) + " " + name_);
}
}
}
void loop() {
std::unique_lock lock(mutex_);
while (true) {
changed_.wait(lock, [this] { return quit_ || !queue_.empty(); });
if (queue_.empty()) return;
Command command = std::move(queue_.front());
queue_.pop_front();
queued_files_ -= command.type == Command::begin;
busy_ = true;
lock.unlock();
// After an error, commands only give their buffers and descriptors back.
if (error().empty()) run(command);
else if (command.type == Command::begin) close(command.fd);
lock.lock();
busy_ = false;
if (command.buffer) free_.push_back(command.buffer);
changed_.notify_all();
}
}
std::unique_ptr<unsigned char[]> buffers_[buffer_count];
std::deque<unsigned char*> free_;
std::deque<Command> queue_;
std::mutex mutex_;
std::condition_variable changed_;
std::string error_;
bool quit_ = false, busy_ = false;
size_t queued_files_ = 0;
std::thread thread_;
unsigned char* current_ = nullptr;
size_t used_ = 0;
// Writer thread only.
int fd_ = -1;
std::string name_;
std::uint64_t written_ = 0;
Sha256 hash_;
};
inline long cores() { return sysconf(_SC_NPROCESSORS_ONLN); }
inline std::uint64_t process_cpu_us() {
rusage usage;
if (getrusage(RUSAGE_SELF, &usage)) return 0;
return (std::uint64_t(usage.ru_utime.tv_sec) + usage.ru_stime.tv_sec) * 1000000 + usage.ru_utime.tv_usec + usage.ru_stime.tv_usec;
}
inline bool flattened(const std::vector<std::string>& extensions, const std::string& name) {
for (const auto& extension : extensions)
if (name.size() >= extension.size() && !name.compare(name.size() - extension.size(), extension.size(), extension)) return true;
return false;
}
}
+54 -12
View File
@@ -55,27 +55,62 @@ JSValue extract(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
if (!id) return JS_ThrowPlainError(ctx,"archives.extract: %s",error.c_str());
return JS_NewUint32(ctx,id);
}
// Reads only a few headers, so it runs on the render thread; a download in progress can be inspected.
JSValue inspect(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
if (argc < 1 || !JS_IsArray(argv[0])) return JS_ThrowTypeError(ctx,"archives.inspect: expected sources");
const JSValue length_value = JS_GetPropertyStr(ctx,argv[0],"length"); std::uint32_t length = 0;
// The ordered volumes `call` names, as real paths; false after throwing.
bool volumes(JSContext* ctx, JSValueConst value, const char* call, std::vector<std::string>& sources) {
if (!JS_IsArray(value)) { JS_ThrowTypeError(ctx,"%s: expected sources",call); return false; }
const JSValue length_value = JS_GetPropertyStr(ctx,value,"length"); std::uint32_t length = 0;
const int result = JS_ToUint32(ctx,&length,length_value); JS_FreeValue(ctx,length_value);
if (result || !length || length > 1024) return JS_ThrowTypeError(ctx,"archives.inspect: expected 1–1024 ordered volumes");
std::vector<std::string> sources;
if (result || !length || length > 1024) { JS_ThrowTypeError(ctx,"%s: expected 1–1024 ordered volumes",call); return false; }
for (std::uint32_t i = 0; i < length; ++i) {
const JSValue value = JS_GetPropertyUint32(ctx,argv[0],i); std::string file;
const bool ok = path(ctx,value,file); JS_FreeValue(ctx,value);
if (!ok) return JS_ThrowTypeError(ctx,"archives.inspect: source path is not allowed");
const JSValue item = JS_GetPropertyUint32(ctx,value,i); std::string file;
const bool ok = path(ctx,item,file); JS_FreeValue(ctx,item);
if (!ok) { JS_ThrowTypeError(ctx,"%s: source path is not allowed",call); return false; }
sources.push_back(std::move(file));
}
const host::CallScope scope("archives.inspect");
const auto inspection = archives::inspect(sources);
JSValue out = JS_NewObject(ctx);
return true;
}
void set_inspection(JSContext* ctx, JSValue out, const archives::Inspection& inspection) {
JS_SetPropertyStr(ctx,out,"kind",JS_NewString(ctx,inspection.kind.c_str()));
JS_SetPropertyStr(ctx,out,"refusal",JS_NewString(ctx,inspection.refusal.c_str()));
JS_SetPropertyStr(ctx,out,"title",JS_NewString(ctx,inspection.title.c_str()));
}
// Reads only a few headers on the render thread, but a download's growing file can make it wait for
// the drive: while one is written, inspectAsync is the one to use.
JSValue inspect(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
std::vector<std::string> sources;
if (argc < 1 || !volumes(ctx,argv[0],"archives.inspect",sources)) return argc < 1 ? JS_ThrowTypeError(ctx,"archives.inspect: expected sources") : JS_EXCEPTION;
const host::CallScope scope("archives.inspect");
const auto inspection = archives::inspect(sources);
JSValue out = JS_NewObject(ctx);
set_inspection(ctx,out,inspection);
return out;
}
JSValue inspect_async(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
std::vector<std::string> sources;
if (argc < 1 || !volumes(ctx,argv[0],"archives.inspectAsync",sources)) return argc < 1 ? JS_ThrowTypeError(ctx,"archives.inspectAsync: expected sources") : JS_EXCEPTION;
std::string error; const auto id = archives::inspect_start(std::move(sources),error);
if (!id) return JS_ThrowPlainError(ctx,"archives.inspectAsync: %s",error.c_str());
return JS_NewUint32(ctx,id);
}
JSValue inspections_poll(JSContext* ctx, JSValueConst, int, JSValueConst*) {
JSValue out = JS_NewArray(ctx); std::uint32_t index = 0;
for (const auto& snapshot : archives::inspect_poll()) {
JSValue item = JS_NewObject(ctx);
JS_SetPropertyStr(ctx,item,"id",JS_NewUint32(ctx,snapshot.id));
JS_SetPropertyStr(ctx,item,"state",JS_NewString(ctx,snapshot.state.c_str()));
JS_SetPropertyStr(ctx,item,"error",JS_NewString(ctx,snapshot.error.c_str()));
set_inspection(ctx,item,snapshot.inspection);
JS_SetPropertyStr(ctx,item,"waitedMs",JS_NewFloat64(ctx,static_cast<double>(snapshot.waited_ms)));
JS_SetPropertyStr(ctx,item,"tookMs",JS_NewFloat64(ctx,static_cast<double>(snapshot.took_ms)));
JS_SetPropertyUint32(ctx,out,index++,item);
}
return out;
}
JSValue inspections_cancel(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
std::uint32_t id = 0;
if (!argc || JS_ToUint32(ctx,&id,argv[0])) return JS_ThrowTypeError(ctx,"inspections.cancel: expected task id");
archives::inspect_cancel(id); return JS_UNDEFINED;
}
JSValue cancel(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
std::uint32_t id = 0;
if (!argc || JS_ToUint32(ctx,&id,argv[0])) return JS_ThrowTypeError(ctx,"archives.cancel: expected task id");
@@ -106,7 +141,14 @@ JSValue ps5_react_archive_api(JSContext* ctx) {
JSValue api = JS_NewObject(ctx);
JS_SetPropertyStr(ctx,api,"extract",JS_NewCFunction(ctx,extract,"extract",5));
JS_SetPropertyStr(ctx,api,"inspect",JS_NewCFunction(ctx,inspect,"inspect",1));
JS_SetPropertyStr(ctx,api,"inspectAsync",JS_NewCFunction(ctx,inspect_async,"inspectAsync",1));
JS_SetPropertyStr(ctx,api,"cancel",JS_NewCFunction(ctx,cancel,"cancel",1));
JS_SetPropertyStr(ctx,api,"poll",JS_NewCFunction(ctx,poll,"poll",0));
return api;
}
JSValue ps5_react_inspection_api(JSContext* ctx) {
JSValue api = JS_NewObject(ctx);
JS_SetPropertyStr(ctx,api,"poll",JS_NewCFunction(ctx,inspections_poll,"poll",0));
JS_SetPropertyStr(ctx,api,"cancel",JS_NewCFunction(ctx,inspections_cancel,"cancel",1));
return api;
}
+121 -3
View File
@@ -3,6 +3,7 @@
// Additional attribution term: see LICENSE-ATTRIBUTION.
#include "archive_preflight.hpp"
#include "archives.hpp"
#include "thread_name.hpp"
#include <lzma.h>
#include <algorithm>
#include <array>
@@ -11,7 +12,11 @@
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <condition_variable>
#include <cstdio>
#include <fcntl.h>
#include <mutex>
#include <pthread.h>
#include <string_view>
#include <sys/stat.h>
#include <unistd.h>
@@ -32,6 +37,7 @@ constexpr std::uint64_t max_decoder_memory = 64ULL * 1024 * 1024;
constexpr std::uint64_t mib = 1024 * 1024;
constexpr std::uint64_t unbounded = UINT64_MAX;
constexpr unsigned max_coders = 64;
constexpr std::uint64_t max_7z_header_bytes = 32ULL * 1024 * 1024;
const std::string too_much_memory = "This archive needs more memory to extract than the console app can use.";
const std::string malformed = "Unsupported or incomplete archive.";
const std::string too_many_entries = "Archive has too many entries.";
@@ -432,13 +438,13 @@ bool unpack_header(Volumes& in, Cursor& cursor, std::vector<unsigned char>& head
if (!(refusal = fits(folder_memory(item, streams, in))).empty()) return false;
const auto packed_size = streams.pack_sizes[0];
const auto unpacked_size = item.sizes[0];
if (packed_size > max_metadata_bytes || unpacked_size > max_metadata_bytes) return false;
if (packed_size > max_metadata_bytes || unpacked_size > max_7z_header_bytes) return false;
std::vector<unsigned char> packed(packed_size);
header.assign(unpacked_size, 0);
return in.read(signature_size + streams.pack_position, packed.data(), packed.size()) && decode_header(coder, packed, header);
}
std::string check(Volumes& in) {
std::string check(Volumes& in, std::uint64_t& declared) {
std::array<unsigned char, signature_size> signature{};
if (!in.read(0, signature.data(), signature.size())) return malformed;
const auto offset = little_endian(signature.data() + 12, 8);
@@ -476,6 +482,7 @@ std::string check(Volumes& in) {
if (!cursor.ok) return malformed;
std::uint64_t memory = 0;
for (const auto& item : main.folders) memory = std::max(memory, folder_memory(item, main, in));
declared = memory;
return fits(memory);
}
}
@@ -678,12 +685,18 @@ bool starts_with(const std::array<unsigned char, 8>& magic, std::string_view pre
}
std::string preflight(const std::vector<std::string>& sources) {
std::uint64_t seven_zip_memory = 0;
return preflight(sources, seven_zip_memory);
}
std::string preflight(const std::vector<std::string>& sources, std::uint64_t& seven_zip_memory) {
seven_zip_memory = 0;
Volumes in(sources);
std::array<unsigned char, 8> magic{};
in.read(0, magic.data(), static_cast<std::size_t>(std::min<std::uint64_t>(magic.size(), in.size())));
// libarchive searches executables for an embedded archive, which these checks do not follow.
if (starts_with(magic, "MZ") || starts_with(magic, "\x7F" "ELF")) return "Self-extracting archives are not supported.";
if (starts_with(magic, "7z\xBC\xAF\x27\x1C")) return seven_zip::check(in);
if (starts_with(magic, "7z\xBC\xAF\x27\x1C")) return seven_zip::check(in, seven_zip_memory);
if (starts_with(magic, rar5::signature)) return rar5::check(in, false);
if (starts_with(magic, rar4::signature)) return rar4::check(in, false);
// Compression around a TAR: only the outer layer is checked.
@@ -735,4 +748,109 @@ Inspection inspect(const std::vector<std::string>& sources) {
if (little_endian(ufs.data(), 4) == 0x19540119) return {"ffpkg", {}};
return {};
}
namespace {
struct InspectionJob {
InspectionSnapshot snapshot;
std::vector<std::string> sources;
std::chrono::steady_clock::time_point queued;
bool running = false;
};
// Never freed: the worker is detached, as an inspection waiting on a busy drive must not hold the
// title's exit, and it may still use these after static destructors run.
struct Inspections {
std::mutex guard;
std::condition_variable wake;
std::vector<InspectionJob> jobs;
std::uint32_t next_id = 1;
bool started = false;
};
Inspections& inspections() {
static auto* state = new Inspections;
return *state;
}
bool terminal(const std::string& state) { return state == "completed" || state == "cancelled"; }
std::int64_t ms_since(std::chrono::steady_clock::time_point from) {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - from).count();
}
void* inspect_worker(void*) {
name_thread("inspect");
auto& s = inspections();
std::unique_lock lock(s.guard);
for (;;) {
const auto queued = [&] { return std::find_if(s.jobs.begin(), s.jobs.end(), [](const InspectionJob& job) { return job.snapshot.state == "queued"; }); };
s.wake.wait(lock, [&] { return queued() != s.jobs.end(); });
const auto job = queued();
job->snapshot.state = "inspecting";
job->snapshot.waited_ms = ms_since(job->queued);
job->running = true;
const auto id = job->snapshot.id;
const auto sources = job->sources;
lock.unlock();
const auto started = std::chrono::steady_clock::now();
auto found = inspect(sources);
const auto took = ms_since(started);
lock.lock();
const auto done = std::find_if(s.jobs.begin(), s.jobs.end(), [id](const InspectionJob& item) { return item.snapshot.id == id; });
char line[256];
std::snprintf(line, sizeof line, "id=%u waited=%lldms took=%lldms kind=%s refusal=%s%s", id,
static_cast<long long>(done == s.jobs.end() ? 0 : done->snapshot.waited_ms), static_cast<long long>(took),
found.kind.empty() ? "none" : found.kind.c_str(), found.refusal.empty() ? "none" : found.refusal.c_str(),
done == s.jobs.end() || done->snapshot.state == "cancelled" ? " (dropped)" : "");
trace("inspect done", line);
if (done == s.jobs.end()) continue;
done->running = false;
if (done->snapshot.state == "cancelled") continue;
done->snapshot.state = "completed";
done->snapshot.took_ms = took;
done->snapshot.inspection = std::move(found);
}
return nullptr;
}
}
std::uint32_t inspect_start(std::vector<std::string> sources, std::string& error) {
auto& s = inspections();
std::lock_guard lock(s.guard);
if (!s.started) {
pthread_attr_t attributes;
pthread_t thread;
// The RAR and 7z header parsers want more than the console's small default stack.
s.started = !pthread_attr_init(&attributes) && !pthread_attr_setstacksize(&attributes, 1024 * 1024) &&
!pthread_attr_setdetachstate(&attributes, PTHREAD_CREATE_DETACHED) && !pthread_create(&thread, &attributes, inspect_worker, nullptr);
pthread_attr_destroy(&attributes);
if (!s.started) {
error = "Cannot start the inspection thread.";
return 0;
}
}
auto& job = s.jobs.emplace_back();
job.snapshot.id = s.next_id++;
job.queued = std::chrono::steady_clock::now();
char line[160];
std::snprintf(line, sizeof line, "id=%u volumes=%zu ahead=%zu %s", job.snapshot.id, sources.size(), s.jobs.size() - 1,
sources.empty() ? "" : sources.front().c_str());
trace("inspect queued", line);
job.sources = std::move(sources);
s.wake.notify_one();
return job.snapshot.id;
}
std::vector<InspectionSnapshot> inspect_poll() {
auto& s = inspections();
std::lock_guard lock(s.guard);
std::vector<InspectionSnapshot> out;
for (const auto& job : s.jobs) out.push_back(job.snapshot);
// One cancelled while under way stays until the worker drops its result.
std::erase_if(s.jobs, [](const InspectionJob& job) { return terminal(job.snapshot.state) && !job.running; });
return out;
}
void inspect_cancel(std::uint32_t id) {
auto& s = inspections();
std::lock_guard lock(s.guard);
for (auto& job : s.jobs) {
if (job.snapshot.id == id && !terminal(job.snapshot.state)) job.snapshot.state = "cancelled";
}
}
}
@@ -3,12 +3,17 @@
// Additional attribution term: see LICENSE-ATTRIBUTION.
#pragma once
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
namespace archives {
// Reads the archive headers before libarchive does and returns why the archive must be refused, or an
// empty string when its declared decoder windows fit the console's memory budget.
std::string preflight(const std::vector<std::string>& sources);
// The same, and for a 7z whose headers could be read, the decoder memory its folders declare (the largest,
// as they decode one at a time) in `seven_zip_memory`, 0 otherwise: a 7z refused for memory alone can go to
// rar-extract, which has the console's memory rather than the app's heap.
std::string preflight(const std::vector<std::string>& sources, std::uint64_t& seven_zip_memory);
// `title`: the title id a PKG's content id names (its header's "UP0001-CUSA01234_00-..." at 0x40),
// so the app can tell another game's file from the one chosen before it has all downloaded.
struct Inspection { std::string kind, refusal, title{}; };
@@ -19,4 +24,21 @@ struct Inspection { std::string kind, refusal, title{}; };
// `inspect_budget` (100 ms; tests change it).
Inspection inspect(const std::vector<std::string>& sources);
extern std::chrono::milliseconds inspect_budget;
// inspect() on a worker thread, one at a time in the order asked. A read of a download's growing file
// waits behind its writes while they keep the drive busy: on the console, at the drive's write rate,
// one waited until the download ended and held the render thread for minutes (issue #71).
struct InspectionSnapshot {
std::uint32_t id = 0;
std::string state = "queued", error;
Inspection inspection;
std::int64_t waited_ms = 0, took_ms = 0;
};
// Queues the inspection of the ordered volumes (real paths); 0 with `error` when the worker cannot start.
std::uint32_t inspect_start(std::vector<std::string> sources, std::string& error);
// Every inspection's state ("queued", "inspecting", "completed", "cancelled"); terminal ones are
// reported once.
std::vector<InspectionSnapshot> inspect_poll();
// A queued inspection ends "cancelled"; one under way finishes and its result is dropped.
void inspect_cancel(std::uint32_t id);
}
+42 -11
View File
@@ -646,6 +646,18 @@ std::string rar_request(const Request& request, const std::string& staging) {
for (const auto& path : request.sources) text += "source " + path + "\n";
return text + "end\n";
}
// A 7z the heap cannot decode goes to rar-extract when its decoder memory is no more than this. A probe on a
// console allocated up to 384 MiB in under 80 ms; from 512 MiB the system took seconds to free memory for it
// and the console froze. This stays under that with room for what the app and the worker hold besides,
// and admits the 256 MiB LZMA2 dictionary repacks use.
constexpr std::uint64_t seven_zip_worker_memory = 288ULL * 1024 * 1024;
std::string seven_zip_request(const Request& request, const std::string& staging, std::uint64_t memory) {
std::string text = "7ZX1\nmemory " + std::to_string(memory) + "\nlimit " + std::to_string(request.max_bytes) + "\n";
for (auto extension : image_extensions) text += "flatten " + std::string(extension) + "\n";
text += "reserved .ps5-react-\ndest " + staging + "\n";
for (const auto& path : request.sources) text += "source " + path + "\n";
return text + "end\n";
}
// One file the worker wrote and synced: "<sha256> <size> <path>". It is checked as libarchive's entries
// are, since the worker is a separate program.
std::string rar_file(Job& job, const std::string& staging, const std::string& report, std::uint64_t& total, Receipt& receipt) {
@@ -667,15 +679,18 @@ std::string rar_file(Job& job, const std::string& staging, const std::string& re
if (++job.snapshot.entries > max_entries) return "Archive has too many entries.";
return {};
}
// False when the worker could not be started, before anything was written: libarchive extracts instead.
bool extract_rar(Job& job, const std::string& staging, Receipt& receipt, std::string& error) {
// Runs rar-extract on `request` (a RAR set's or a 7z's, `kind` naming which in traces and errors). False
// when the worker could not be started, before anything was written.
bool extract_worker(Job& job, const std::string& staging, Receipt& receipt, std::string& error, const std::string& request,
const std::string& kind) {
const std::string name = (kind == "RAR" ? std::string("rar") : kind) + " worker";
std::string unavailable;
const int stream = start_rar_worker(rar_request(job.request, staging), unavailable);
const int stream = start_rar_worker(request, unavailable);
if (stream < 0) {
trace("rar worker unavailable", unavailable.c_str());
trace((name + " unavailable").c_str(), unavailable.c_str());
return false;
}
trace("rar worker started");
trace((name + " started").c_str());
const auto started = std::chrono::steady_clock::now();
std::string pending, timings;
std::uint64_t total = 0;
@@ -713,7 +728,7 @@ bool extract_rar(Job& job, const std::string& staging, Receipt& receipt, std::st
job.snapshot.total = std::min(declared, job.request.max_bytes);
} else if (line == "s" && !cancel_sent) {
// Every file is decoded; the worker is writing out and syncing what it still holds.
trace("rar worker finishing");
trace((name + " finishing").c_str());
enter(job, "finishing");
} else if (line.starts_with("t ")) {
timings = line.substr(1);
@@ -728,7 +743,7 @@ bool extract_rar(Job& job, const std::string& staging, Receipt& receipt, std::st
}
close_rar_worker(stream);
job.worker_unconfirmed = !finished && !exited;
trace("rar worker finished", error.c_str());
trace((name + " finished").c_str(), error.c_str());
{
std::lock_guard lock(job.mutex);
// The worker's own timings, as its "t" line names them; one from an older worker has none.
@@ -738,18 +753,23 @@ bool extract_rar(Job& job, const std::string& staging, Receipt& receipt, std::st
const auto from = at + std::strlen(key) + 2;
return timings.substr(from, timings.find(' ', from) - from);
};
const auto summary = "RAR worker " + std::to_string(total >> 20) + "MiB " + std::to_string(job.snapshot.entries)
const auto summary = kind + " worker " + std::to_string(total >> 20) + "MiB " + std::to_string(job.snapshot.entries)
+ " entries total=" + std::to_string(elapsed_us(started) / 1000) + "ms threads=" + field("threads") + " cores=" + field("cores")
+ " decode-cpu=" + field("cpu") + " write=" + field("write") + " fsync=" + field("fsync") + " waited=" + field("waited");
trace("throughput", summary.c_str());
}
if (error.empty() && !job.cancelled && !succeeded) error = finished ? "RAR extraction failed." : "The RAR worker stopped before finishing.";
if (error.empty() && !job.cancelled && !succeeded)
error = finished ? kind + " extraction failed." : "The " + kind + " worker stopped before finishing.";
if (error.empty() && !job.cancelled) {
std::lock_guard lock(job.mutex);
job.snapshot.written = total;
}
return true;
}
// False when the worker could not be started, before anything was written: libarchive extracts instead.
bool extract_rar(Job& job, const std::string& staging, Receipt& receipt, std::string& error) {
return extract_worker(job, staging, receipt, error, rar_request(job.request, staging), "RAR");
}
std::string staging_path(const std::string& destination, unsigned attempt) {
return destination + ".extracting" + (attempt ? "-" + std::to_string(attempt) : "");
}
@@ -810,12 +830,20 @@ void run(const std::shared_ptr<Job>& pointer) {
std::none_of(request.sources.begin(), request.sources.end(), [](const std::string& path) { return path.find('\n') != std::string::npos; });
return found.kind != "rar" || !lines ? std::string("Only a RAR set can be extracted while it downloads.") : found.refusal;
};
if (const auto refusal = request.stream ? streamed() : preflight(request.sources); !refusal.empty()) {
std::uint64_t seven_zip_memory = 0;
const auto refusal = request.stream ? streamed() : preflight(request.sources, seven_zip_memory);
// A 7z refused for the heap's memory alone, within rar-extract's budget, goes to the worker; its paths go
// to the worker's line protocol. Without a worker the refusal stands.
const bool lines = request.destination.find('\n') == std::string::npos &&
std::none_of(request.sources.begin(), request.sources.end(), [](const std::string& path) { return path.find('\n') != std::string::npos; });
const bool seven_zip_worker = !refusal.empty() && seven_zip_memory && seven_zip_memory <= seven_zip_worker_memory && lines;
if (!refusal.empty() && !seven_zip_worker) {
trace("preflight refused", refusal.c_str());
fail(job, refusal);
return;
}
trace("preflight ok");
if (seven_zip_worker) trace("preflight 7z for the worker, MiB", std::to_string(seven_zip_memory >> 20).c_str());
else trace("preflight ok");
if (!lstat(staging.c_str(), &st)) {
std::string owner;
// Empty staging with no ownership record is this task's own, left by a crash before it wrote one;
@@ -867,6 +895,9 @@ void run(const std::shared_ptr<Job>& pointer) {
// extracts it once every volume is there.
if (request.stream) {
if (!extract_rar(job, staging, receipt, error) && error.empty()) error = "Extracting while downloading needs the RAR worker.";
} else if (seven_zip_worker) {
if (!extract_worker(job, staging, receipt, error, seven_zip_request(request, staging, seven_zip_memory), "7z") && error.empty())
error = "This archive needs the payload loader to extract: none answered on port 9021.";
} else if (!rar_volumes(request) || !extract_rar(job, staging, receipt, error)) {
const MemoryHold memory;
std::unique_ptr<Rar5PasswordReader> decrypted;
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more