Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f90d88277 | ||
|
|
89e9b188cd | ||
|
|
b67337b094 | ||
|
|
f1ec9c94d5 | ||
|
|
8630f9fec2 | ||
|
|
a5dfc5bd4a | ||
|
|
9170f56597 | ||
|
|
5b4c89377d | ||
|
|
4d8388d38b | ||
|
|
cb4e695706 | ||
|
|
98d81a299f | ||
|
|
fcf210b765 | ||
|
|
b69f7e24c3 | ||
|
|
ff1c95197c | ||
|
|
16ef0575b6 | ||
|
|
7e905b98e1 | ||
|
|
07d0ebf1a4 | ||
|
|
684f31545b | ||
|
|
ef1d389301 | ||
|
|
9673ad66b8 | ||
|
|
afca004822 | ||
|
|
cdabb1bfb7 | ||
|
|
e284d41b3f | ||
|
|
adde740a4f | ||
|
|
0e7112e56f | ||
|
|
e8001492bf | ||
|
|
ed9798b7d7 | ||
|
|
dff3d99855 | ||
|
|
73bce2f73f | ||
|
|
eed910f3df | ||
|
|
53e93e95af | ||
|
|
a885231b7e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/` внутри
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 517 B |
|
After Width: | Height: | Size: 440 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 454 B |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 360 B |
|
After Width: | Height: | Size: 454 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 487 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 421 B |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 470 B |
|
After Width: | Height: | Size: 456 B |
|
After Width: | Height: | Size: 539 B |
|
After Width: | Height: | Size: 386 B |
@@ -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, {})
|
||||
] }) });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
] });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 }) }),
|
||||
|
||||
@@ -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 }] } } })
|
||||
] })
|
||||
|
||||
@@ -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);
|
||||
}) });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}) });
|
||||
});
|
||||
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
} })
|
||||
|
||||
@@ -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 };
|
||||
@@ -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") : " " })
|
||||
] }) });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }] } } }),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}' এর সাথে মেলে না",
|
||||
|
||||
@@ -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} »",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}» مطابقت ندارد",
|
||||
|
||||
@@ -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} »",
|
||||
|
||||
@@ -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}' से मेल नहीं खाते",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}」に一致するゲームはありません",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\" سے کوئی گیم مماثل نہیں",
|
||||
|
||||
@@ -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}”的游戏",
|
||||
|
||||
@@ -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": "繼續尋找"
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 }) }) });
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
// reconstructed from the esbuild bundle in eboot.bin
|
||||
|
||||
|
||||
var VERSION = "1.0.7";
|
||||
var VERSION = "1.0.8";
|
||||
|
||||
export { VERSION };
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||