26 Commits
Author SHA1 Message Date
TheRealRetro 6bca99ff6e 0.7.5: the cheat search, your own cheats and the PS5 keyboard (release notes, version) 2026-10-17 21:27:02 -04:00
TheRealRetro 129d055f99 Cheat search, your own cheats and the PS5 keyboard
Cheats in the pause menu gets a cheat search (8, 16 or 32-bit values,
narrowed down step by step, with undo) whose results become cheats or are
set once, and codes typed in. The player's own cheats come first, can be
renamed, changed or deleted (Square; on a list cheat, an own copy), and are
kept in cheats/<ROM file>.custom.txt in the cheat list's format.

Names and codes are typed on the system keyboard (libSceImeDialog). Its
module is loaded before the app leaves the sandbox: afterwards the system
looks for it inside the sandbox and doesn't find it (80020063).

The touchpad in any page the pause menu opened goes back to the game.
2026-10-17 21:25:23 -04:00
TheRealRetro 89786fd668 Background from the full-size original (5504x3072), sharper at 4K
The same picture as before, cleaned the same way, scaled down to 3840x2160
instead of up from a 2000-pixel copy.
2026-10-17 00:47:22 -04:00
TheRealRetro f97346904d New home screen icon and background
The icon and background are pictures now (app/art/icon.png and
background.png), a retro controller in the menus' violet; the background's
top left, where the console draws the app's name and buttons, is plain.
tools/make_art.py --images fits them to icon0.png (512x512) and
background-source.png (3840x2160) and writes the BC7 pic0/pic1.dds.
2026-10-17 00:45:04 -04:00
TheRealRetro 0ec5920b37 0.7.4 release notes: the new menus, quick load and save 2026-10-17 00:26:18 -04:00
TheRealRetro 0ca7ea5721 Menus in the new look; touchpad-only pause menu, L3/R3 quick load/save
The pause menu, Settings on the game list and the Controller Layout and
Cheats pages are one panel over the paused game or the game list: a header
with the title, rows with an icon, a value box, a switch or a slider, and a
footer of buttons (the user's mockup). frontend/fe_draw.cpp draws the
anti-aliased rounded panels, glows, gradients, switches and a few icons on
the CPU; fe_text gains pixel sizes and a bold. The static parts are drawn
once per menu and copied each frame.

Pause menu: the touchpad resumes, Square resets the game after a question,
Triangle goes back to the game list; Save State, Load State and Quit take
Cross. The game list's own settings stay in Settings there.

In a game the touchpad alone opens the pause menu; R3 saves in the current
state slot and L3 loads it, so they are out of the controller layout.
2026-10-17 00:26:06 -04:00
TheRealRetro 240baec05e Cheats: the core's GameShark code list in the pause menu
frontend/fe_cheats.cpp reads mupen64plus-core's data/mupencheat.txt (558
game versions, 9238 cheats), built into eboot.bin zlib-compressed (235 KB),
and finds a game by its ROM header's CRCs and country. The pause menu's
Cheats page switches each cheat off or on, or picks one of its choices,
with the database's note beside it. The core applies them every VI
(CoreAddCheat, CoreCheatEnabled); the ones left on are on again next time
(cheats/<ROM file>.txt). Codes that need the GameShark's button are left
out: the DualSense has none.
2026-10-16 22:46:08 -04:00
TheRealRetro 7b2a21208e Releases tagged 0.7.4 instead of 00.007.004
The tag on GitHub is the version as people write it. The contentVersion
stays NN.NNN.NNN (the console needs it), and every app since 0.6.0 reads both
tag styles as numbers (ParseVersion), so updates still compare correctly.
release.ps1 refuses a version already released under either tag style.
2026-10-16 22:03:16 -04:00
TheRealRetro 9e3b6c1596 0.7.4: controller layouts per game, volume setting 2026-10-16 21:58:06 -04:00
TheRealRetro bca88bf154 Controller layouts per game, volume setting
Controller layout (Settings): each DualSense control -- Cross, Circle,
Square, Triangle, L1, R1, L2 and R2 separately, L3, R3, OPTIONS, the D-pad
and the right stick's four directions -- presses the N64 button the layout
gives it, or nothing; the left stick stays the analog stick. From the game
list it is every game's layout (layout= in mupen64plus-ps5.ini); from the
pause menu, the running game's own (layouts/<ROM file>.txt, gone again when
it matches every game's). The default is the old fixed mapping.

Volume (Settings): the game's sound at 0-100%, live from the pause menu.

Asked for on GBAtemp: L2 and R2 both sent Z, so a game's own control setup
couldn't tell them apart.
2026-10-16 21:57:58 -04:00
TheRealRetro a2147c5dbe 0.7.3: continue where you left off, CPU overclock, big ROM hacks, shipped shaders 2026-10-16 00:02:10 -04:00
TheRealRetro 7f4949f830 Shipped shaders as a file in the app folder; eboot.bin under 32 MiB
The pipeline cache built into eboot.bin made it 39.8 MB, and the in-app
updater of 0.6.3 to 0.7.2 refuses a file over 32 MiB: no installed copy
could have updated. The cache is now assets/parallel-rdp-pipelines.bin in
the app folder, found in /app0 or the app folders the updater uses
(OrbisAppFolders, moved from fe_update.cpp), and eboot.bin is 30.8 MB again.

The eboot.bin rule stops the build above 32 MiB (EBOOT_MAX), since anyone
on 0.7.2 or older keeps that limit. The updater itself now takes files up
to 64 MiB.
2026-10-16 00:02:09 -04:00
TheRealRetro ab6a937908 Continue where you left off, CPU overclock, shaders shipped with the app
Continue where you left off (Settings, Ask by default): leaving a game from
the pause menu saves its state in states/<ROM file>.resume, at the next VI;
the game stops once the core reports it written. Starting the game again
asks "Continue where you left off?" (Cross continue, Square start over,
Circle back), and the state loads at the first VI.

CPU overclock * (Off, 2x, 4x): the core's CountPerOpDenomPot, so games that
slowed down on the console run smoother.

Shaders shipped with the app: n64/plugins/assets/parallel-rdp-pipelines.bin,
a console's own pipeline cache, is built into eboot.bin and merged once per
shipped file into the player's cache (cache/parallel-rdp-pipelines.seed),
then saved: every PS5 has the same GPU and RADV, so new players skip the
first-time compiles. A cache from another driver is told apart by its UUID
and left out. Granite gains Device::merge_pipeline_cache.
2026-10-15 23:54:09 -04:00
TheRealRetro 6c09976c62 ROMs up to 252 MB: ROM hacks bigger than a cartridge
mupen64plus-core 2.6.0 refused ROMs over 64 MiB, the biggest cartridge, and
some ROM hacks are bigger (ECW Born to be Wired: 67.8 MB). The core now takes
up to 252 MiB, all of the cartridge address space, which the full mem base
already maps. Cartridge addresses still wrap at 64 MiB for ROMs that size or
smaller, so other games behave as before. Upstream lifted the limit after
2.6.0 too.

The game-start error gives the file's size ("the file is empty", "Too big
for an N64 ROM (300.0 MB, the most is 252 MB)"), and boot.log logs it.

The core's objects now depend on its headers: a header change rebuilt only
the files that changed, leaving the rest with the old limit and struct layout.
2026-10-15 20:46:49 -04:00
TheRealRetro fbda6fd21e 0.7.2: game list views, stick inversion, more covers
Version 0.7.2 (contentVersion 00.007.002) and its release notes.
2026-10-15 00:55:08 -04:00
TheRealRetro 96a850b3b4 Covers for new games: the app starts again by itself
On the console covers can only be downloaded before the app asks for
/data, so for new games it closed itself to fetch them at the next start;
sceSystemServiceLoadExec(its own eboot.bin) only closed it, and players
had to open it again. Now it writes a relaunch job, starts a fresh helper
through the ELF loader and closes; the helper waits for it to exit and
launches the title again (sceLncUtilLaunchApp / sceSystemServiceLaunchApp
as the foreground or a logged-in user, as PS5_Vulkan's ps5vkctl does,
checking which title came up). If no launch call works, a notification
says to open the app again; if the helper can't even be started, the app
stays open and says the covers come next time.
2026-10-15 00:48:06 -04:00
TheRealRetro 5b98886d51 Covers: game names as libretro-thumbnails spells them
The game-name table comes from mupen64plus.ini's GoodN64 names, which have
no language tags and spell some titles differently, so many covers were
asked for under a name the server doesn't have: Diddy Kong Racing (USA)
(Rev 1) is "... (USA) (En,Fr) (Rev 1)" there, F-ZERO X is F-Zero X,
Mission Impossible is Mission - Impossible. make_n64db.py now matches each
name against libretro-thumbnails' box art list (tools/libretro-n64-
boxarts.txt, refreshed with --fetch-boxarts), ignoring language tags,
case and punctuation, and takes its spelling: 287 names, and the names
with a cover on the server go from 624 to 790 of 1338.
2026-10-15 00:21:59 -04:00
TheRealRetro 5715de56a5 Game list: grid and list views
Besides the 3D shelf, the game list can show a grid of covers (6 across,
3 rows, the D-pad moves in all four directions, L1/R1 a page) or a list
of titles with small covers and the selected game's box art large beside
it (Up/Down, L1/R1 a page, a scroll bar). The touchpad cycles the views,
also Settings -> Game list view; the choice is remembered. Covers load
around the middle of what's on screen; the blurred backdrop follows the
selection in every view, its floor and horizon only behind the shelf.
2026-10-14 23:50:43 -04:00
TheRealRetro cfd4bbfe95 Settings: Invert right stick (issue #3)
The right stick's C buttons can be turned round too (Off, X, Y, X and Y);
Triangle / Circle stay C-up / C-down. "Invert stick" is now "Invert left
stick".
2026-10-14 18:00:33 -04:00
TheRealRetro dc42d378c5 Settings: Invert stick (issue #3)
Turns the analog stick's X (left/right), Y (up/down) or both round: Off,
X, Y, X and Y. Like the dead zone it applies as soon as the menu closes.
2026-10-14 17:45:30 -04:00
TheRealRetro caafe1f412 0.7.1: steady interlaced pictures on the GPU renderer
Version 0.7.1 (contentVersion 00.007.001) and its release notes.
2026-10-14 16:34:49 -04:00
TheRealRetro bd40c5092a GPU renderer: steady interlaced pictures (issue #2)
Interlaced pictures (Indiana Jones and the Infernal Machine, hi-res
modes) combed everything that moved: the plugin used paraLLEl-RDP's weave
deinterlacing, which it keeps for its test suite. It now uses bob, each
field scaled up on its own, as RetroArch does. paraLLEl-RDP then only
offset each field by its quarter line after a downscale pass, so without
downscaling the whole picture jumped half a line every frame and its top
and bottom edges flickered; that pass now runs whenever bob applies.
Confirmed on the console: Indiana Jones' title screen is steady.

The shelf's tagline said "angrylion RDP, cxd4 RSP" and ran behind the
middle cover: now "Nintendo 64 - mupen64plus". boot.log's build date is
refreshed whenever anything in eboot.bin changes (an incremental build
kept the old one), and the GPU plugin logs when the picture switches
between progressive and interlaced.
2026-10-14 16:26:43 -04:00
TheRealRetro f00c397657 Update README.md 2026-10-09 16:08:23 -04:00
TheRealRetro ee516620b0 Update README.md 2026-10-09 16:05:31 -04:00
TheRealRetro 5d7ccaae19 Update README.md 2026-10-09 13:17:01 -04:00
TheRealRetro 7257726071 README: link the forum thread and raaproductions.ca 2026-10-08 22:08:21 -04:00
61 changed files with 5978 additions and 383 deletions

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
*.vcxproj* text eol=crlf
*.png binary
*.bin binary
*.dds binary
*.ttf binary
*.otf binary
+9 -2
View File
@@ -46,6 +46,12 @@ newer releases. Install 0.6.3 or newer by hand once: delete `/data/homebrew/PPSA
For a crash or a bug, please [open an issue](../../issues) and attach `/data/mupen64plus/logs/boot.log`.
## Discussion and support
- **[Mupen64Plus PS5 thread](https://raaproductions.ca/forum/showthread.php?tid=20)** on the RAAI Productions
forum: questions, help and news.
- **[RAA! Forum](https://raaproductions.ca/forum/showthread.php?tid=20)**: my website.
## Documentation
[ps5/README.md](ps5/README.md): controls, settings, folders, how the port works, and building.
@@ -66,7 +72,8 @@ The GPU renderer needs RADV built first (mihawk-99/PS5_Vulkan, in WSL `/root/gpu
Releasing:
1. Raise `VERSION` in `ps5/Makefile`, and set the matching `contentVersion` in `ps5/app/sce_sys/param.json`
(0.7.0 → `00.007.000`; the build stops if they differ). That number is also the release tag.
(0.7.0 → `00.007.000`; the build stops if they differ). The release tag is the version as people write it
(`0.7.4`; up to 0.7.3 it was the `contentVersion`, `00.007.003`). The app reads both as numbers.
2. Write `release-notes/<contentVersion>.md`: a few short lines, shown on the console in the update question.
3. Commit and push, then run `release.bat`. It builds, creates the release with `Mupen64PlusPS5.zip`, and checks
GitHub's digest against the local zip.
@@ -75,7 +82,7 @@ Never reuse or lower a version.
## Repository layout
- `mupen64plus-core/`: the original core source, **unmodified**.
- `mupen64plus-core/`: the original core source, with one change: ROMs up to 252 MB (ROM hacks bigger than a 64 MB cartridge): `src/device/memory/memory.h` and `src/device/cart/cart_rom.{c,h}`, marked `Mupen64Plus PS5`.
- `ps5/`: the port (plugins, PS5 layer, frontend, build).
- `release-notes/`: one file per release.
+1 -1
View File
@@ -5,7 +5,7 @@ Mupen64Plus PS5 as a whole (the `eboot.bin` it builds) is distributed under the
| Part | Where | License |
| --- | --- | --- |
| mupen64plus-core 2.6.0 (unmodified) | `mupen64plus-core/` | GPL-2.0-or-later (`mupen64plus-core/LICENSES`) |
| mupen64plus-core 2.6.0 (changed for ROMs bigger than 64 MB: `memory.h`, `cart_rom.c/.h`, marked `Mupen64Plus PS5`) | `mupen64plus-core/` | GPL-2.0-or-later (`mupen64plus-core/LICENSES`) |
| The PS5 port: plugins glue, PS5 layer, frontend, build | `ps5/` | MIT (per-file SPDX headers) |
| ps5-native-app-boilerplate tooling (startup code, eboot builder and signer, libc.prx builder) | `ps5/proto/native/` | GPL-3.0-or-later |
| mupen64plus-rsp-cxd4 | `ps5/third_party/rsp-cxd4/` | CC0-1.0 (`COPYING`) |
+8 -4
View File
@@ -32,7 +32,9 @@
#define __STDC_FORMAT_MACROS
#include <inttypes.h>
#define CART_ROM_ADDR_MASK UINT32_C(0x03ffffff);
#define CART_ROM_ADDR_MASK UINT32_C(0x03ffffff)
/* Mupen64Plus PS5: for ROM hacks bigger than 64 MiB (see CART_ROM_MAX_SIZE) */
#define CART_ROM_ADDR_MASK_BIG UINT32_C(0x0fffffff)
void init_cart_rom(struct cart_rom* cart_rom,
@@ -42,6 +44,7 @@ void init_cart_rom(struct cart_rom* cart_rom,
{
cart_rom->rom = rom;
cart_rom->rom_size = rom_size;
cart_rom->addr_mask = (rom_size > CART_ROM_ADDR_MASK + 1) ? CART_ROM_ADDR_MASK_BIG : CART_ROM_ADDR_MASK;
cart_rom->r4300 = r4300;
cart_rom->pi = pi;
@@ -56,7 +59,7 @@ void poweron_cart_rom(struct cart_rom* cart_rom)
void read_cart_rom(void* opaque, uint32_t address, uint32_t* value)
{
struct cart_rom* cart_rom = (struct cart_rom*)opaque;
uint32_t addr = rom_address(address);
uint32_t addr = rom_address(cart_rom, address);
if (cart_rom->pi->regs[PI_STATUS_REG] & PI_STATUS_IO_BUSY)
{
@@ -84,7 +87,8 @@ void write_cart_rom(void* opaque, uint32_t address, uint32_t value, uint32_t mas
unsigned int cart_rom_dma_read(void* opaque, const uint8_t* dram, uint32_t dram_addr, uint32_t cart_addr, uint32_t length)
{
cart_addr &= CART_ROM_ADDR_MASK;
struct cart_rom* cart_rom = (struct cart_rom*)opaque;
cart_addr &= cart_rom->addr_mask;
DebugMessage(M64MSG_WARNING, "DMA Writing to CART_ROM: 0x%" PRIX32 " -> 0x%" PRIX32 " (0x%" PRIX32 ")", dram_addr, cart_addr, length);
@@ -97,7 +101,7 @@ unsigned int cart_rom_dma_write(void* opaque, uint8_t* dram, uint32_t dram_addr,
struct cart_rom* cart_rom = (struct cart_rom*)opaque;
const uint8_t* mem = cart_rom->rom;
cart_addr &= CART_ROM_ADDR_MASK;
cart_addr &= cart_rom->addr_mask;
if (cart_addr + length < cart_rom->rom_size)
{
+4 -2
View File
@@ -34,6 +34,8 @@ struct cart_rom
{
uint8_t* rom;
size_t rom_size;
/* Mupen64Plus PS5: cart addresses wrap at 64 MiB, as before, unless the ROM is bigger */
uint32_t addr_mask;
uint32_t last_write;
@@ -41,9 +43,9 @@ struct cart_rom
struct pi_controller* pi;
};
static osal_inline uint32_t rom_address(uint32_t address)
static osal_inline uint32_t rom_address(const struct cart_rom* cart_rom, uint32_t address)
{
return (address & 0x03fffffc);
return (address & cart_rom->addr_mask & ~UINT32_C(3));
}
void init_cart_rom(struct cart_rom* cart_rom,
+3 -1
View File
@@ -28,7 +28,9 @@
#include "osal/preproc.h"
enum { RDRAM_MAX_SIZE = 0x800000 };
enum { CART_ROM_MAX_SIZE = 0x4000000 };
/* Mupen64Plus PS5: ROM hacks can be bigger than the 64 MiB of the biggest cartridges.
* Up to 252 MiB, all of dom1 addr2 (0x10000000 - 0x1fbfffff); upstream lifted the limit after 2.6.0 too. */
enum { CART_ROM_MAX_SIZE = 0xfc00000 };
enum { DD_ROM_MAX_SIZE = 0x400000 };
typedef void (*read32fn)(void*,uint32_t,uint32_t*);
+50 -18
View File
@@ -20,8 +20,9 @@
# 3. build/ps5/Mupen64PS5.elf installer + helper, with the app folder from step 2 built in
#
# What goes into eboot.bin:
# - mupen64plus-core 2.6.0 (../mupen64plus-core, unmodified), with its x86-64 dynarec, minus the parts that need
# SDL/OpenGL/libpng/dlopen, which n64/core/*.c replace; n64/sdl is the bit of SDL it still uses;
# - mupen64plus-core 2.6.0 (../mupen64plus-core, changed only for ROMs up to 252 MiB), with its x86-64 dynarec,
# minus the parts that need SDL/OpenGL/libpng/dlopen, which n64/core/*.c replace; n64/sdl is the bit of SDL it
# still uses;
# - its plugins, linked in (n64/plugins): mupen64plus-rsp-cxd4 (LLE RSP) behind mupen64plus-rsp-hle (HLE
# audio), angrylion-rdp-plus (software RDP/VI), AudioOut and DualSense input;
# - the frontend (shelf, menus, covers) and the PS5 layer (coreorbis).
@@ -32,7 +33,7 @@
PS5_PAYLOAD_SDK ?= /opt/ps5-payload-sdk
PS5_HOST ?= ps5
PS5_PORT ?= 9021
VERSION ?= 0.7.0
VERSION ?= 0.7.5
# param.json's contentVersion, NN.NNN.NNN (0.6.1 -> 00.006.001): the release tag, as in the Homebrew Browser; it
# must also be written in app/sce_sys/param.json (checked below)
CONTENT_VERSION := $(shell echo $(VERSION) | awk -F. '{printf "%02d.%03d.%03d", $$1, $$2, $$3}')
@@ -62,6 +63,8 @@ APP_OUT := $(APP_BUILD)/$(TITLE_ID)
M64 := ../mupen64plus-core
M64SRC := $(M64)/src
# the core's headers: a change to one rebuilds the core (and the port's C files, which include some of them)
M64_HDRS := $(shell find $(M64SRC) -name '*.h')
ZLIB := third_party/zlib
CXD4 := third_party/rsp-cxd4
RSPHLE := third_party/rsp-hle/src
@@ -93,7 +96,7 @@ CORE_SRC := \
CORE_SUB_SRC := md5/md5.c minizip/ioapi.c minizip/unzip.c minizip/zip.c
# the port's side of the core (C)
N64_SRC := n64/sdl/sdl_shim.c n64/core/perf.c n64/core/jit_ps5.c n64/core/vidext_ps5.c n64/core/eventloop_ps5.c n64/core/screenshot_ps5.c \
n64/core/osal_dynlib_static.c
n64/core/osal_dynlib_static.c n64/core/memory_ps5.c
# the plugins
PLUGIN_C_SRC := n64/plugins/rsp_cxd4_ps5.c n64/plugins/rsp_hle_ps5.c n64/plugins/gfx_ps5.c
PLUGIN_CXX_SRC := n64/plugins/audio_ps5.cpp n64/plugins/input_ps5.cpp
@@ -118,9 +121,11 @@ EMU_SRC := coreorbis/main-boot.cpp \
coreorbis/orbis-shims/ProsperoVideo.cpp coreorbis/orbis-shims/ProsperoAudio.cpp \
coreorbis/orbis-shims/ProsperoInput.cpp coreorbis/orbis-shims/ProsperoJailbreak.cpp \
coreorbis/orbis-shims/ProsperoCrash.cpp coreorbis/orbis-shims/ProsperoUpdateJob.cpp \
coreorbis/orbis-shims/ProsperoKeyboard.cpp \
frontend/fe_emu.cpp frontend/fe_menu.cpp frontend/fe_settings.cpp frontend/fe_text.cpp \
frontend/fe_games.cpp frontend/fe_http.cpp frontend/fe_covers.cpp frontend/fe_shelf.cpp \
frontend/fe_prefetch.cpp frontend/fe_update.cpp
frontend/fe_prefetch.cpp frontend/fe_update.cpp frontend/fe_cheats.cpp frontend/fe_draw.cpp \
frontend/fe_search.cpp
# the payloads: the helper, and the installer (= helper + install)
HELPER_SRC := coreorbis/orbis-shims/orbis_paths.cpp coreorbis/orbis-shims/ProsperoNotify.cpp \
coreorbis/orbis-shims/ProsperoHelper.cpp coreorbis/orbis-shims/ProsperoUpdateJob.cpp
@@ -143,13 +148,22 @@ endif
GEN := build/gen
GAMEDB := $(GEN)/n64-gamedb.tsv
M64_INI := $(M64)/data/mupen64plus.ini
$(GAMEDB): tools/make_n64db.py $(M64_INI)
# libretro-thumbnails' box art names, so the table's names match its files (make_n64db.py --fetch-boxarts)
BOXARTS := tools/libretro-n64-boxarts.txt
$(GAMEDB): tools/make_n64db.py $(M64_INI) $(BOXARTS)
@mkdir -p $(dir $@)
python3 tools/make_n64db.py $(M64_INI) $@
python3 tools/make_n64db.py $(M64_INI) $@ $(BOXARTS)
%/port/frontend/fe_games.o: EXTRA_FLAGS = -DGAMEDB_TSV='"$(abspath $(GAMEDB))"'
# the ROM catalog itself goes into fe_emu.o (written to /data/mupen64plus/data for the core)
%/port/frontend/fe_emu.o: EXTRA_FLAGS = -DM64P_INI_PATH='"$(abspath $(M64_INI))"'
# the core's cheat database (1.2 MB of text), compressed into fe_cheats.o: 4 bytes of size, then zlib
CHEATS_Z := build/gen/mupencheat.z
$(CHEATS_Z): $(M64)/data/mupencheat.txt
@mkdir -p $(dir $@)
python3 -c "import struct, sys, zlib; d = open(sys.argv[1], 'rb').read(); \
open(sys.argv[2], 'wb').write(struct.pack('<I', len(d)) + zlib.compress(d, 9))" $< $@
%/port/frontend/fe_cheats.o: EXTRA_FLAGS = -DCHEATS_Z='"$(abspath $(CHEATS_Z))"'
# stb_image in fe_covers.o has warnings of its own
%/port/frontend/fe_covers.o: EXTRA_FLAGS = -Wno-error -Wno-sign-compare -Wno-unused-but-set-variable -Wno-implicit-fallthrough
# the UI fonts (PS5SX2's: PCSX2's Roboto Regular and PromptFont) built into fe_text.o
@@ -176,8 +190,9 @@ PS5_OUT := build/ps5-cpu
endif
# The PS5's CPU is Zen 2 (PS5SX2 builds with the same -march).
PS5_OPT := -O3 -fPIC -march=znver2 -fno-strict-aliasing
# the payloads: notifications, and the kernel access the helper needs (libkernel_sys: sceKernelGetAppInfo)
PS5_PAYLOAD_LIBS := -lkernel_sys
# the payloads: notifications, the kernel access the helper needs (libkernel_sys: sceKernelGetAppInfo), and
# starting the app again (libSceSystemService, libSceUserService: ProsperoUpdateJob.h, RelaunchIfPending)
PS5_PAYLOAD_LIBS := -lkernel_sys -lSceSystemService -lSceUserService
PS5_CORE_OBJ := $(patsubst %.c,$(PS5_OUT)/m64/%.o,$(CORE_SRC)) $(patsubst %.c,$(PS5_OUT)/m64sub/%.o,$(CORE_SUB_SRC)) \
$(PS5_OUT)/n64/n64/core/linkage_x64.o \
@@ -193,16 +208,16 @@ PS5_ZLIB_OBJ := $(patsubst $(ZLIB)/%.c,$(PS5_OUT)/zlib/%.o,$(ZLIB_SRC))
ps5: $(PS5_OUT)/Mupen64PS5.elf
# mupen64plus-core: third-party C, its own warnings are not ours
$(PS5_OUT)/m64/%.o: $(M64SRC)/%.c
$(PS5_OUT)/m64/%.o: $(M64SRC)/%.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(PS5_CC) -std=gnu11 $(PS5_OPT) $(CORE_DEFS) $(CORE_INCS) -w -c -o $@ $<
$(PS5_OUT)/m64/device/r4300/new_dynarec/new_dynarec.o: $(M64SRC)/device/r4300/new_dynarec/new_dynarec.c
$(PS5_OUT)/m64/device/r4300/new_dynarec/new_dynarec.o: $(M64SRC)/device/r4300/new_dynarec/new_dynarec.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(PS5_CC) -std=gnu11 $(PS5_OPT) $(CORE_DEFS) $(CORE_INCS) $(DYNAREC_FLAGS) -w -c -o $@ $<
# the struct offsets the dynarec's assembly needs (the core's tools/gen_asm_script.sh, on our compiler's object)
$(PS5_OUT)/gen/asm_defines.o: $(M64SRC)/asm_defines/asm_defines.c
$(PS5_OUT)/gen/asm_defines.o: $(M64SRC)/asm_defines/asm_defines.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(PS5_CC) -std=gnu11 $(PS5_OPT) $(CORE_DEFS) $(CORE_INCS) -w -c -o $@ $<
$(PS5_OUT)/gen/asm_defines_gas.h: $(PS5_OUT)/gen/asm_defines.o
@@ -224,7 +239,7 @@ $(PS5_OUT)/n64/n64/plugins/gfx_ps5.o: n64/plugins/gfx_ps5.c $(PORT_HDRS)
@mkdir -p $(dir $@)
$(PS5_CC) -std=gnu11 $(PS5_OPT) $(GFX_INCS) -Wall -Wextra -Werror -Wno-unused-parameter -c -o $@ $<
$(PS5_OUT)/n64/%.o: %.c $(PORT_HDRS)
$(PS5_OUT)/n64/%.o: %.c $(PORT_HDRS) $(M64_HDRS)
@mkdir -p $(dir $@)
$(PS5_CC) -std=gnu11 $(PS5_OPT) $(CORE_DEFS) $(CORE_INCS) -In64 -In64/core -Wall -Wextra -Werror -Wno-unused-parameter -c -o $@ $<
@@ -275,9 +290,13 @@ $(PS5_OUT)/helper_data.o: coreorbis/orbis-shims/helper_data.cpp coreorbis/orbis-
$(PS5_CXX) -std=c++17 $(PS5_OPT) $(DEFS) $(INCS) $(PORT_WARN) -DHELPER_ELF='"$(abspath $(PS5_OUT)/Mupen64PS5-helper.elf)"' -c -o $@ $<
# 3. the installer + helper, with the app folder built in
# the GPU renderer's pipeline cache shipped with the app (a console's cache/parallel-rdp-pipelines.bin), when there
# is one: assets/ in the app folder (the installer payload doesn't carry it)
PIPELINE_SEED := $(if $(filter 1,$(VULKAN)),$(wildcard n64/plugins/assets/parallel-rdp-pipelines.bin))
APP_FILES = $(APP_OUT)/eboot.bin $(APP_OUT)/sce_sys/param.json $(APP_OUT)/sce_sys/icon0.png $(APP_OUT)/sce_sys/pic0.dds \
$(APP_OUT)/sce_sys/pic1.dds \
$(APP_OUT)/sce_module/libc.prx
$(APP_OUT)/sce_module/libc.prx \
$(if $(PIPELINE_SEED),$(APP_OUT)/assets/parallel-rdp-pipelines.bin)
APP_DATA_DEFS = -DAPP_EBOOT='"$(abspath $(APP_OUT)/eboot.bin)"' -DAPP_PARAM='"$(abspath $(APP_OUT)/sce_sys/param.json)"' \
-DAPP_ICON0='"$(abspath $(APP_OUT)/sce_sys/icon0.png)"' -DAPP_PIC0='"$(abspath $(APP_OUT)/sce_sys/pic0.dds)"' \
-DAPP_PIC1='"$(abspath $(APP_OUT)/sce_sys/pic1.dds)"' \
@@ -426,6 +445,10 @@ endif
EBOOT_OBJS = $(APP_BUILD)/app_crt.o $(APP_BUILD)/app_cpp_runtime.o $(PS5_CORE_OBJ) $(PS5_EMU_OBJ) $(EBOOT_ZLIB) \
$(VULKAN_OBJ) $(PS5_OUT)/helper_data.o
# boot.log's "built <date> <time>" (__DATE__/__TIME__ in main-boot.cpp): recompiled whenever anything else in
# eboot.bin changes, so the date always says which build is running
MAIN_BOOT_O := $(PS5_OUT)/port/coreorbis/main-boot.o
$(MAIN_BOOT_O): $(filter-out $(MAIN_BOOT_O),$(PS5_CORE_OBJ) $(PS5_EMU_OBJ) $(VULKAN_OBJ) $(PS5_OUT)/helper_data.o)
$(APP_BUILD)/mupen64plus-pie.elf: $(EBOOT_OBJS) $(NATIVE)/tooling/native/ps5-pie.ld $(NATIVE)/tooling/native/ehframe.ld \
$(NATIVE)/tooling/native/app-symbols.map $(NATIVE)/tooling/native/localize.map $(VULKAN_STUBS)
$(LLD) -m elf_x86_64 -pie -z max-page-size=0x4000 --hash-style=gnu \
@@ -440,10 +463,18 @@ $(APP_BUILD)/eboot.elf: $(APP_BUILD)/mupen64plus-pie.elf $(NATIVE_TOOL)
--companion-sdk 0x08050001 --file-name eboot.elf
python3 tools/prepare_console_elf.py --input $@ --output $(APP_BUILD)/eboot-console-ready.elf
# 0.6.3 to 0.7.2 refuse an update with a file over 32 MiB (fe_update.cpp kMaxFile): eboot.bin must stay under it
EBOOT_MAX := 33554432
$(APP_OUT)/eboot.bin: $(APP_BUILD)/eboot.elf $(NATIVE_TOOL)
@mkdir -p $(dir $@)
$(NATIVE_TOOL) self --sign --in $< --out $@ --magic 0x1D3D154F
$(NATIVE_TOOL) self --inspect --file $@
@size=$$(stat -c %s $@); if [ $$size -gt $(EBOOT_MAX) ]; then rm -f $@; \
echo "eboot.bin is $$size bytes: apps 0.6.3-0.7.2 can't update to an eboot.bin over 32 MiB" >&2; exit 1; fi
$(APP_OUT)/assets/parallel-rdp-pipelines.bin: $(PIPELINE_SEED)
@mkdir -p $(dir $@)
cp $< $@
# app/sce_sys/param.json is committed with the contentVersion the release ships: the homebrew.page catalog reads
# it from the repository at the release tag. It must match VERSION; the build stops when it doesn't.
@@ -485,15 +516,15 @@ HOST_ZLIB_OBJ := $(patsubst $(ZLIB)/%.c,$(HOST_OUT)/zlib/%.o,$(ZLIB_SRC))
host: $(HOST_OUT)/mupen64plus-ps5-host $(HOST_OUT)/mupen64plus-ps5-installer $(HOST_OUT)/mupen64plus-ps5-helper
$(HOST_OUT)/m64/%.o: $(M64SRC)/%.c
$(HOST_OUT)/m64/%.o: $(M64SRC)/%.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 $(HOST_OPT) $(CORE_DEFS) $(CORE_INCS) -w -c -o $@ $<
$(HOST_OUT)/m64/device/r4300/new_dynarec/new_dynarec.o: $(M64SRC)/device/r4300/new_dynarec/new_dynarec.c
$(HOST_OUT)/m64/device/r4300/new_dynarec/new_dynarec.o: $(M64SRC)/device/r4300/new_dynarec/new_dynarec.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 $(HOST_OPT) $(CORE_DEFS) $(CORE_INCS) $(DYNAREC_FLAGS) -w -c -o $@ $<
$(HOST_OUT)/gen/asm_defines.o: $(M64SRC)/asm_defines/asm_defines.c
$(HOST_OUT)/gen/asm_defines.o: $(M64SRC)/asm_defines/asm_defines.c $(M64_HDRS)
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 $(HOST_OPT) $(CORE_DEFS) $(CORE_INCS) -w -c -o $@ $<
$(HOST_OUT)/gen/asm_defines_gas.h: $(HOST_OUT)/gen/asm_defines.o
@@ -515,7 +546,7 @@ $(HOST_OUT)/n64/n64/plugins/gfx_ps5.o: n64/plugins/gfx_ps5.c $(PORT_HDRS)
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 $(HOST_OPT) $(GFX_INCS) -Wall -Wextra -Werror -Wno-unused-parameter -c -o $@ $<
$(HOST_OUT)/n64/%.o: %.c $(PORT_HDRS)
$(HOST_OUT)/n64/%.o: %.c $(PORT_HDRS) $(M64_HDRS)
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 $(HOST_OPT) $(CORE_DEFS) $(CORE_INCS) -In64 -In64/core -Wall -Wextra -Werror -Wno-unused-parameter -c -o $@ $<
@@ -575,6 +606,7 @@ $(HOST_OUT)/mupen64plus-ps5-host: $(HOST_CORE_OBJ) $(HOST_EMU_OBJ) $(HOST_ZLIB_O
# the files built into fe_games.o / fe_emu.o
$(PS5_OUT)/port/frontend/fe_games.o $(HOST_OUT)/port/frontend/fe_games.o: $(GAMEDB)
$(PS5_OUT)/port/frontend/fe_emu.o $(HOST_OUT)/port/frontend/fe_emu.o: $(M64_INI)
$(PS5_OUT)/port/frontend/fe_cheats.o $(HOST_OUT)/port/frontend/fe_cheats.o: $(CHEATS_Z)
clean:
rm -rf build
+71 -21
View File
@@ -4,7 +4,7 @@ mupen64plus-core 2.6.0, the Nintendo 64 emulator, as a **native home-screen app*
shelf with covers, DualSense support and save states. The release is the app folder itself (`eboot.bin` and
its files): no payload to send.
- `../mupen64plus-core` is the original core source, **unmodified**.
- `../mupen64plus-core` is the original core source, with one change: ROMs up to 252 MB (ROM hacks bigger than a 64 MB cartridge): `src/device/memory/memory.h` and `src/device/cart/cart_rom.{c,h}`, marked `Mupen64Plus PS5`.
- Everything that makes it a PS5 app is in this folder:
- `n64/`: the core's PS5 side and its built-in plugins;
- `coreorbis/`: the PS5 layer;
@@ -75,6 +75,18 @@ picture is read back and shown like angrylion's. Its compiled shaders are kept i
`/data/mupen64plus/cache/parallel-rdp-pipelines.bin`. If Vulkan doesn't work, the app falls back to angrylion,
which renders on the CPU at the N64's own resolution.
**Shaders shipped with the app.** Every PS5 has the same GPU and runs the same RADV, built into `eboot.bin`, so
one console's compiled shaders suit the others. `n64/plugins/assets/parallel-rdp-pipelines.bin`, when present,
is a copy of a console's `cache/parallel-rdp-pipelines.bin` and goes into the app folder as
`assets/parallel-rdp-pipelines.bin`. At the first start of a new version, the app merges it into the player's own
cache (`cache/parallel-rdp-pipelines.seed` remembers it), so nobody waits for those compiles. A cache made by
another driver (its UUID differs) is left out. To refresh it, copy the cache from a console that has played
many games, after a build that changed RADV or paraLLEl-RDP.
**`eboot.bin` stays under 32 MiB.** The in-app updater of 0.6.3 to 0.7.2 refuses a file over 32 MiB, so those
versions could never update to a bigger `eboot.bin`; the build stops if it gets there. Large data goes into the
app folder as files of its own.
**What the core needs from SDL, libpng and dlopen is replaced:**
- `n64/sdl/` provides threads, mutexes and the clock on pthreads.
@@ -131,7 +143,8 @@ Title ID `PPSA99064` and the folders below don't overlap with Snes9x PS5 (PPSA99
## Controls
**In a game** (buttons by position, as on the N64 pad)
**In a game** (the default layout: buttons by position, as on the N64 pad; Settings → Controller layout changes
it, for every game or, from the pause menu, for one game)
| PS5 | N64 |
|---|---|
@@ -143,14 +156,37 @@ Title ID `PPSA99064` and the folders below don't overlap with Snes9x PS5 (PPSA99
| right stick | C buttons |
| Triangle / Circle | C-up / C-down |
| OPTIONS | Start |
| **touchpad click** or **L3 + R3** | pause menu |
| **touchpad click** | pause menu |
| **R3** (press the right stick) | save the state in the current slot |
| **L3** (press the left stick) | load the state of the current slot |
**The pause menu** has:
**The pause menu** (the touchpad) is a panel over the paused game, drawn on the CPU with the anti-aliased shapes
of `frontend/fe_draw.cpp`; Settings on the game list and the Controller Layout and Cheats pages share its look
(`fe_menu.cpp`, namespace `pm`). Its footer's buttons work anywhere in it: touchpad resume, Square reset the
game (after a question), Triangle back to the game list, Cross confirm, Circle back (resume). Its rows:
- resume, save state, load state, and the state slot (0–9);
- save state and load state (Cross), and the state slot (0–9);
- **cheats**: the GameShark codes of the core's `data/mupencheat.txt` (about 560 game versions, found by the ROM
header's CRCs and country; built into `eboot.bin` compressed, `frontend/fe_cheats.cpp`). Each one is off or on,
or off or one of its choices, with the database's note beside it; the ones left on are on again the next time
(`cheats/<ROM file>.txt`). Codes that need the GameShark's button are left out. Above the list:
- **Search for a Cheat** (`frontend/fe_search.cpp`): finds where the game keeps a value in its RAM. It starts
from every 8, 16 or 32-bit value, either equal to a number or unknown, and each step keeps the addresses whose
value is (not) the number, went up or down (by the number), changed or didn't, since the step before; Undo
goes one step back. A result becomes a cheat (GameShark 80 / 81 codes keeping the value) or is set once
(`n64/core/memory_ps5.c` reads and writes the RDRAM);
- **Type In Codes**: GameShark codes typed on the PS5's keyboard.
The player's own cheats come first in the list (Square renames one, changes its codes or deletes it; on one of
the database's, Square makes an own copy to change) and are kept in `cheats/<ROM file>.custom.txt` in the
database's format, so codes can be put there from a PC too. Names and codes are typed on the system's on-screen
keyboard (`libSceImeDialog`, `coreorbis/orbis-shims/ProsperoKeyboard.cpp`). Its system module is loaded at
start, before the app leaves the sandbox: afterwards the system looks for modules inside the sandbox and doesn't
find them (`80020063`). Where it doesn't load the app still runs, only without typing;
- speed (normal or fast forward);
- every setting below;
- reset, back to the game list, and quit.
- every setting below but the game list's (Continue where you left off, Game list view, Download covers, Check
for updates: Settings on the game list has them);
- quit (Cross).
**Settings** (marked `*` when they apply from the next game start):
@@ -164,18 +200,27 @@ Title ID `PPSA99064` and the folders below don't overlap with Snes9x PS5 (PPSA99
| Internal resolution * | GPU renderer: 1x (native), 2x, 4x (default), 8x |
| GPU sync * | GPU renderer: Accurate (default: waits for the GPU wherever the game waits for the RDP) or Fast (may glitch) |
| CPU core * | Dynarec (default, when the console allows it) or Interpreter (slower; for a game the dynarec gets wrong) |
| CPU overclock * | Off (default), 2x, 4x: the N64's CPU gets 2 or 4 times the cycles per frame (the core's `CountPerOpDenomPot`), so games that slowed down on the console (GoldenEye, Perfect Dark, Conker) run smoother. A few games misbehave with it |
| Audio processing * | Fast (HLE, default): rsp-hle does the RSP's audio work; Accurate (LLE): cxd4 runs the game's audio microcode (slower; for a game whose sound HLE gets wrong) |
| Render threads * | CPU renderer (angrylion) workers: 1–12, default 6 |
| Controller pak * | Controller Pak (default), Rumble Pak, none |
| Stick dead zone | 0–30% |
| Invert left stick | turns the analog stick's X (left/right), Y (up/down) or both round; Off by default |
| Invert right stick | the same for the right stick's C buttons (Triangle / Circle stay C-up / C-down); Off by default |
| Controller layout | what each DualSense control presses on the N64 pad: Cross, Circle, Square, Triangle, L1, R1, L2, R2 (separately), OPTIONS, the D-pad and the right stick's four directions, each set to an N64 button or nothing (the left stick stays the analog stick). From the game list it is every game's layout (in `mupen64plus-ps5.ini`); from the pause menu, the running game's own (`layouts/<ROM file>.txt`, gone again when it matches every game's) |
| Continue where you left off | Ask (default): leaving a game from the pause menu saves where you were (`states/<ROM file>.resume`), and starting it again asks "Continue where you left off?" (✕ continue, □ start over, ○ back). Off: neither |
| Game list view | Shelf (3D, default), Grid or List; the touchpad on the game list switches too |
| Show FPS | the speed (`VI/s`, 100% = full speed) and, below it, where the emulation thread's time goes: `cpu` (VR4300 interpreter), `rsp` (rsp-hle + cxd4), `rdp` (the renderer: angrylion's rasteriser incl. waiting for its threads, or handing commands to the GPU), `vi` (the VI filter, or reading the GPU's picture back), `idle` (waiting for the TV: spare time). Also logged every 10 s in `boot.log` |
| Volume | the game's sound, 0–100% (default 100%) |
| Sound, Download covers | |
| Check for updates | ask at start-up when a newer GitHub release exists (see [Updates](#updates)) |
**On the shelf:**
**On the game list** (three views: the 3D shelf, a grid of covers, a list of titles with the cover beside it):
- Left/Right: choose a game
- Cross: play
- D-pad: choose a game (shelf: Left/Right; grid: all four directions; list: Up/Down)
- L1/R1: 10 games back or on (shelf), a page up or down (grid, list)
- Cross: play (a game you left from the pause menu asks whether to continue there)
- Touchpad: the next view (remembered; also Settings → Game list view)
- Triangle: settings
- Square: fetch the cover again
- OPTIONS: quit
@@ -206,11 +251,14 @@ At every start, before it asks for `/data` (the console's HTTPS works only then,
- **Names:** a ROM's name comes from its file name when that is already a No-Intro name ("F-Zero X
(USA).z64"). Otherwise it comes from the CRC in its cartridge header, looked up in a table built from the
core's `mupen64plus.ini` (1891 games, `tools/make_n64db.py`).
- **Covers:** they come from libretro-thumbnails (Nintendo 64, `Named_Boxarts`). The source works exactly as
in Snes9x PS5 (a prefetch at start-up, then a restart for new covers).
- **When a cover doesn't download:** the catalog's names are GoodN64 names converted to No-Intro style, and
some differ from libretro's in capitalisation, which the download URL is sensitive to. Name the ROM file the
core's `mupen64plus.ini` (1891 games, `tools/make_n64db.py`). Its GoodN64 names are matched against
libretro-thumbnails' file list (`tools/libretro-n64-boxarts.txt`) and take their spelling there, language
tags included ("Diddy Kong Racing (USA) (En,Fr) (Rev 1)").
- **Covers:** they come from libretro-thumbnails (Nintendo 64, `Named_Boxarts`), downloaded at start-up, before
the app asks for `/data` (HTTPS works only then). When the game list has new games without covers, the app
closes and its helper starts it again (`sceLncUtilLaunchApp`), so the next start fetches them; if it can't, the
covers come the next time you open the app.
- **When a cover doesn't download:** the game isn't on libretro-thumbnails under that name. Name the ROM file the
No-Intro way, or put your own `.png` in `covers/`.
## Known limitations
@@ -221,7 +269,7 @@ At every start, before it asks for `/data` (the console's HTTPS works only then,
Interpreter.
- **The RSP is the next bottleneck.** cxd4 interprets every graphics microcode instruction (GoldenEye:
~30% of the time); audio goes through rsp-hle (Audio processing: Fast).
- **Unsupported:** 64DD, the Transfer Pak, screenshots, a cheats menu, netplay.
- **Unsupported:** 64DD, the Transfer Pak, screenshots, netplay.
- PAL games are paced by the core's millisecond limiter, so motion is less smooth than NTSC.
## Building
@@ -276,16 +324,18 @@ ROM, on Linux:
N64PS5_PS5_ROOT=/tmp/n64 N64PS5_HOST_DUMP=150 N64PS5_HOST_MAX_FLIPS=200 build/host/mupen64plus-ps5-host test.z64
```
The run writes flip 150 to a `.ppm` file. `N64PS5_HOST_PAD="frame:buttons;..."` scripts the pad (see
`host/sce_host.cpp`).
The run writes flip 150 to a `.ppm` file. `N64PS5_HOST_PAD="frame:buttons;..."` scripts the pad, and
`N64PS5_HOST_IME="text|text..."` what is typed on the keyboard each time it opens (see `host/sce_host.cpp`).
**Dynarec.** `new_dynarec.c` is built with `-Dmprotect=n64ps5_jit_mprotect` (the core stays unmodified),
**Dynarec.** `new_dynarec.c` is built with `-Dmprotect=n64ps5_jit_mprotect` (no change to the core's source),
its NASM stubs are translated to GNU syntax in `n64/core/linkage_x64.S`, and the struct offsets they need are
generated from the core's `asm_defines.c` with its own `tools/gen_asm_script.sh`. On the host,
`N64PS5_JIT=direct` exercises the PS5's direct-memory way (an anonymous RWX mapping stands in for it).
**Art.** `tools/make_art.py app/sce_sys` redraws `icon0.png`, `background-source.png` and the BC7
`pic0.dds`/`pic1.dds`.
**Art.** The home screen's icon and background are pictures in `app/art/` (`icon.png`, `background.png`).
`tools/make_art.py app/sce_sys --images app/art/icon.png app/art/background.png` fits them to `icon0.png`
(512x512) and `background-source.png` (3840x2160) and writes the BC7 `pic0.dds`/`pic1.dds`; without
`--images` it draws the old art itself.
## Source layout
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 314 KiB

+1 -1
View File
@@ -10,7 +10,7 @@
"conceptId": "99064",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA99064_00-MUPEN64PS5000001",
"contentVersion": "00.007.000",
"contentVersion": "00.007.005",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
Binary file not shown.
Binary file not shown.
+4
View File
@@ -30,6 +30,10 @@ std::string OrbisDir(const char* sub);
bool OrbisPathsInit();
// Folders that hold ROMs and exist right now (internal first, then USB drives).
std::vector<std::string> OrbisRomRoots();
// Every copy of the app folder (<base>/<TITLE_ID> with sce_sys/param.json and eboot.bin) in ShadowMountPlus's scan
// paths: /data/homebrew, then USB and extended storage, at their root or in homebrew/ (N64PS5_PS5_HOMEBREW
// replaces them all for the host tests).
std::vector<std::string> OrbisAppFolders();
bool OrbisIsDir(const std::string& path);
bool OrbisIsFile(const std::string& path);
+66
View File
@@ -42,6 +42,21 @@ int sceSystemServiceHideSplashScreen(void);
// "exit" ends the app the way the system wants (PS5SX2's OrbisExitApp: exit()/_exit() end in SIGSYS)
int sceSystemServiceLoadExec(const char* path, const char* argv[]);
// Starting a title from a payload (the helper: ProsperoUpdateJob.h, RelaunchIfPending), with the parameter
// etaHEN's launcher passes.
typedef struct
{
uint32_t size;
int32_t user_id;
uint32_t app_opt;
uint64_t crash_report;
uint64_t check_flag;
} SceLncAppParam;
int sceLncUtilLaunchApp(const char* title_id, const char* argv[], SceLncAppParam* param);
int sceSystemServiceLaunchApp(const char* title_id, const char* argv[], SceLncAppParam* param);
uint32_t sceLncUtilGetAppIdOfRunningBigApp(void);
int sceLncUtilGetAppTitleId(uint32_t app_id, char* title_id);
// ---- libSceVideoOut ----------------------------------------------------------------------------------------
typedef struct SceVideoOutBuffers
{
@@ -162,6 +177,57 @@ int sceUserServiceInitialize(void* params);
int sceUserServiceGetForegroundUser(int32_t* user);
int sceUserServiceGetLoginUserIdList(int32_t users[4]);
// ---- libSceSysmodule: a system module the app loads while it runs ---------------------------------------
// Its functions are imported like the others, but they work only once the module is loaded (ps5-generals'
// SDL_ps5kbm.c does the same for the keyboard 0x0106 and the mouse 0x00a9, the PS4's numbers).
#define SCE_SYSMODULE_IME_DIALOG 0x0096
int sceSysmoduleLoadModule(uint16_t id);
// ---- libSceImeDialog (the on-screen keyboard; PS4's layout, as shadPS4's ime_dialog.h has it) -------------
// Called only after sceSysmoduleLoadModule(SCE_SYSMODULE_IME_DIALOG) succeeded (ProsperoKeyboard.cpp). Loading
// the .sprx by its path doesn't work from the app (sceKernelLoadStartModule: 80020063).
#define SCE_IME_TYPE_DEFAULT 0
#define SCE_IME_TYPE_BASIC_LATIN 1
#define SCE_IME_TYPE_NUMBER 4
#define SCE_IME_HALIGN_CENTER 1
#define SCE_IME_VALIGN_CENTER 1
#define SCE_IME_DIALOG_MAX_TEXT_LENGTH 2048
#define SCE_IME_DIALOG_STATUS_NONE 0
#define SCE_IME_DIALOG_STATUS_RUNNING 1
#define SCE_IME_DIALOG_STATUS_FINISHED 2
#define SCE_IME_DIALOG_END_STATUS_OK 0
#define SCE_IME_DIALOG_END_STATUS_USER_CANCELED 1
#define SCE_IME_DIALOG_END_STATUS_ABORTED 2
typedef struct SceImeDialogParam
{
int32_t userId;
int32_t type; // SCE_IME_TYPE_*
uint64_t supportedLanguages; // 0: the system's
int32_t enterLabel; // 0: Done
int32_t inputMethod; // 0
void* filter;
uint32_t option;
uint32_t maxTextLength; // characters, the buffer holds one more
uint16_t* inputTextBuffer; // UTF-16: the text to start with, and the result
float posx, posy; // the text field, 1920 x 1080
int32_t horizontalAlignment, verticalAlignment;
const uint16_t* placeholder;
const uint16_t* title;
int8_t reserved[16];
} SceImeDialogParam;
typedef struct SceImeDialogResult
{
int32_t endstatus; // SCE_IME_DIALOG_END_STATUS_*
int32_t reserved[12];
} SceImeDialogResult;
int sceImeDialogInit(const SceImeDialogParam* param, const void* extended);
int sceImeDialogGetStatus(void); // SCE_IME_DIALOG_STATUS_*
int sceImeDialogGetResult(SceImeDialogResult* result);
int sceImeDialogTerm(void);
// ---- libSceNet / libSceNetCtl / libSceSsl / libSceHttp2 (as the SDK's http2_get sample and PS5SX2) ----
int sceNetInit(void);
int sceNetPoolCreate(const char* name, int size, int flags);
+8 -4
View File
@@ -18,6 +18,7 @@
#include "ProsperoCrash.h"
#include "ProsperoInput.h"
#include "ProsperoJailbreak.h"
#include "ProsperoKeyboard.h"
#include "ProsperoNotify.h"
#include "ProsperoSce.h"
#include "ProsperoVideo.h"
@@ -56,10 +57,10 @@ void WritePid()
}
// One game, until the player leaves it. Returns false when the player chose "quit Mupen64Plus".
bool PlayGame(const std::string& rom)
bool PlayGame(const std::string& rom, bool resume)
{
std::string error;
switch (emu::RunGame(rom, &error))
switch (emu::RunGame(rom, &error, resume))
{
case emu::ExitReason::BackToList: return true;
case emu::ExitReason::Quit: return false;
@@ -106,6 +107,8 @@ int Run(int argc, char** argv)
// A newer release on GitHub, downloaded now for the same reason (fe_update.h); offered further down.
N64_STAGE(Boot, "update check");
fe::UpdateOffer update = fe::CheckForUpdate();
// the on-screen keyboard's system module (cheat names and codes): out of the sandbox it can't be loaded
ps5keyboard::Preload();
// out of the sandbox, like PS5SX2: before it the app sees no /data and no USB drives
N64_STAGE(Boot, "jailbreak");
@@ -177,11 +180,12 @@ int Run(int argc, char** argv)
std::string rom = (argc > 1 && argv[1] && OrbisIsFile(argv[1])) ? argv[1] : "";
for (;;)
{
bool resume = false;
if (rom.empty())
rom = fe::Shelf();
rom = fe::Shelf(&resume);
if (rom.empty())
break; // OPTIONS on the shelf
const bool again = PlayGame(rom);
const bool again = PlayGame(rom, resume);
rom.clear();
if (!again)
break;
@@ -0,0 +1,186 @@
// Mupen64Plus PS5: the system's on-screen keyboard (ProsperoKeyboard.h).
//
// libSceImeDialog's functions are imported like the other system calls, but its module is one the app loads
// itself, with sceSysmoduleLoadModule (ps5-generals' SDL port does the same for the keyboard and mouse
// modules). That has to happen while the app is still in its sandbox: once it left it, a module asked for is
// looked for at the sandbox's path and not found (80020063 on the console, by path or by sysmodule; ps5-native's
// docs/CURL.md, "Elevated apps"), so main-boot.cpp calls Preload() before the jailbreak. Until the module loaded
// its functions aren't called, so where it doesn't load the app still runs, only without typing. The dialog is the
// system's: it draws itself over the app's picture and takes the controller while it is up. sceImeDialogInit
// opens it, sceImeDialogGetStatus says when the player closed it, sceImeDialogGetResult how (Done or canceled),
// and sceImeDialogTerm ends it.
//
// SPDX-License-Identifier: MIT
#include "ProsperoKeyboard.h"
#include "OrbisPaths.h"
#include "ProsperoSce.h"
#include <cstdio>
#include <cstring>
#include <vector>
static_assert(sizeof(SceImeDialogParam) == 0x60, "SceImeDialogParam is 0x60 bytes");
namespace ps5keyboard
{
namespace
{
bool s_tried = false;
int s_load = -1; // sceSysmoduleLoadModule's answer: 0 (or more) loaded
bool Load()
{
if (!s_tried)
{
s_tried = true;
s_load = sceSysmoduleLoadModule(SCE_SYSMODULE_IME_DIALOG);
OrbisLog("[keyboard] sceSysmoduleLoadModule(IME dialog %#x) -> %x", SCE_SYSMODULE_IME_DIALOG, unsigned(s_load));
}
return s_load >= 0;
}
} // namespace
void Preload()
{
Load();
}
namespace
{
// UTF-8 -> UTF-16 (at most max units), and back
std::vector<uint16_t> ToUtf16(const std::string& s, size_t max)
{
std::vector<uint16_t> out;
for (size_t i = 0; i < s.size();)
{
const unsigned char c = (unsigned char)s[i];
uint32_t cp = c;
int extra = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : c >= 0xC0 ? 1 : 0;
if (extra)
cp = c & (0x3F >> extra);
i++;
for (; extra > 0 && i < s.size(); extra--, i++)
cp = cp << 6 | ((unsigned char)s[i] & 0x3F);
const size_t need = cp >= 0x10000 ? 2 : 1;
if (out.size() + need > max)
break;
if (cp >= 0x10000)
{
cp -= 0x10000;
out.push_back(uint16_t(0xD800 | (cp >> 10)));
out.push_back(uint16_t(0xDC00 | (cp & 0x3FF)));
}
else
out.push_back(uint16_t(cp));
}
return out;
}
std::string ToUtf8(const uint16_t* s)
{
std::string out;
for (size_t i = 0; s[i]; i++)
{
uint32_t cp = s[i];
if (cp >= 0xD800 && cp < 0xDC00 && s[i + 1] >= 0xDC00 && s[i + 1] < 0xE000)
{
cp = 0x10000 + ((cp - 0xD800) << 10) + (s[i + 1] - 0xDC00);
i++;
}
if (cp < 0x80)
out += char(cp);
else if (cp < 0x800)
{
out += char(0xC0 | (cp >> 6));
out += char(0x80 | (cp & 0x3F));
}
else if (cp < 0x10000)
{
out += char(0xE0 | (cp >> 12));
out += char(0x80 | ((cp >> 6) & 0x3F));
out += char(0x80 | (cp & 0x3F));
}
else
{
out += char(0xF0 | (cp >> 18));
out += char(0x80 | ((cp >> 12) & 0x3F));
out += char(0x80 | ((cp >> 6) & 0x3F));
out += char(0x80 | (cp & 0x3F));
}
}
return out;
}
bool Fail(std::string* error, const char* what, int code)
{
char text[96];
snprintf(text, sizeof(text), "The keyboard did not open (%s %08X)", what, unsigned(code));
OrbisLog("[keyboard] %s", text);
if (error)
*error = text;
return false;
}
} // namespace
bool Edit(const std::string& title, std::string& text, int max_chars, Type type, const std::function<void()>& frame,
std::string* error)
{
if (!Load())
return Fail(error, "module", s_load);
const size_t max = size_t(max_chars < 1 ? 1 : max_chars > SCE_IME_DIALOG_MAX_TEXT_LENGTH ? SCE_IME_DIALOG_MAX_TEXT_LENGTH : max_chars);
std::vector<uint16_t> buffer = ToUtf16(text, max);
buffer.resize(max + 1, 0);
std::vector<uint16_t> title16 = ToUtf16(title, 128);
title16.push_back(0);
SceImeDialogParam param;
memset(&param, 0, sizeof(param));
int32_t user = -1;
sceUserServiceGetForegroundUser(&user);
param.userId = user;
param.type = type == Type::Number ? SCE_IME_TYPE_NUMBER : type == Type::Latin ? SCE_IME_TYPE_BASIC_LATIN : SCE_IME_TYPE_DEFAULT;
param.maxTextLength = uint32_t(max);
param.inputTextBuffer = buffer.data();
param.posx = 960.0f;
param.posy = 540.0f;
param.horizontalAlignment = SCE_IME_HALIGN_CENTER;
param.verticalAlignment = SCE_IME_VALIGN_CENTER;
param.title = title16.data();
int r = sceImeDialogInit(&param, nullptr);
if (r != 0)
{
// one left open (a crash of the menu around it): end that one and try again
sceImeDialogTerm();
r = sceImeDialogInit(&param, nullptr);
}
OrbisLog("[keyboard] sceImeDialogInit(user %x, type %d, max %zu) -> %x", unsigned(user), param.type, max, unsigned(r));
if (r != 0)
return Fail(error, "init", r);
// running until the player closes it (the first frames may still say "none" while it comes up)
int status = SCE_IME_DIALOG_STATUS_RUNNING;
for (int n = 0;; n++)
{
status = sceImeDialogGetStatus();
if (status != SCE_IME_DIALOG_STATUS_RUNNING && !(status == SCE_IME_DIALOG_STATUS_NONE && n < 30))
break;
frame();
}
SceImeDialogResult result;
memset(&result, 0, sizeof(result));
result.endstatus = SCE_IME_DIALOG_END_STATUS_ABORTED;
const int rr = status == SCE_IME_DIALOG_STATUS_FINISHED ? sceImeDialogGetResult(&result) : status;
sceImeDialogTerm();
OrbisLog("[keyboard] closed: status %x, result %x, end %d", unsigned(status), unsigned(rr), int(result.endstatus));
if (status != SCE_IME_DIALOG_STATUS_FINISHED || rr != 0)
return Fail(error, "status", status != SCE_IME_DIALOG_STATUS_FINISHED ? status : rr);
if (result.endstatus != SCE_IME_DIALOG_END_STATUS_OK)
return false;
buffer[max] = 0;
text = ToUtf8(buffer.data());
return true;
}
} // namespace ps5keyboard
@@ -0,0 +1,27 @@
// Mupen64Plus PS5: the system's on-screen keyboard (libSceImeDialog), for naming cheats and typing codes.
// SPDX-License-Identifier: MIT
#pragma once
#include <functional>
#include <string>
namespace ps5keyboard
{
// Loads the keyboard's system module. Before the app leaves the sandbox (main-boot.cpp): afterwards the system
// looks for modules at a path inside the sandbox and doesn't find them (80020063; ps5-native's docs/CURL.md,
// "Elevated apps"). The module stays loaded.
void Preload();
enum class Type
{
Text, // any text: a name
Number, // digits
Latin, // letters and digits: GameShark codes
};
// Opens the keyboard over the picture with `text` in its field, titled `title`, and calls `frame` (show one
// frame) until the player closes it. True with the new text (UTF-8) when the player pressed Done; false when they
// canceled it, or when the keyboard could not open -- then `error` says why ("the keyboard did not open (0x...)").
bool Edit(const std::string& title, std::string& text, int max_chars, Type type, const std::function<void()>& frame,
std::string* error = nullptr);
} // namespace ps5keyboard
@@ -6,6 +6,7 @@
#include "OrbisPaths.h"
#include "ProsperoNotify.h"
#include "ProsperoSce.h"
#include <arpa/inet.h>
#include <cerrno>
@@ -182,6 +183,159 @@ std::string JobPath()
return OrbisDir("update") + "/job.txt";
}
std::string RelaunchPath()
{
return OrbisDir("update") + "/relaunch.txt";
}
bool WriteRelaunch(int pid, std::string& why)
{
OrbisMkdirs(OrbisDir("update"));
const std::string path = RelaunchPath(), tmp = path + ".part";
FILE* f = fopen(tmp.c_str(), "w");
if (!f)
{
why = "can't write " + tmp;
return false;
}
fprintf(f, "pid=%d\ntime=%lld\n", pid, (long long)time(nullptr));
const bool ok = fclose(f) == 0;
if (!ok || rename(tmp.c_str(), path.c_str()) != 0)
{
unlink(tmp.c_str());
why = "can't write " + path;
return false;
}
return true;
}
namespace
{
constexpr uint32_t kNoApp = 0xffffffffu;
constexpr uint32_t kAlreadyRunning = 0x8094000cu;
// The title in the foreground ("" when none), as the system reports it.
std::string RunningTitle()
{
const uint32_t id = sceLncUtilGetAppIdOfRunningBigApp();
if (id == kNoApp)
return "";
char tid[32] = {};
if (sceLncUtilGetAppTitleId(id, tid) != 0)
return "?";
return tid;
}
// Starts the title. Which call and which user a payload's launch is accepted with isn't worth guessing
// (PS5_Vulkan's ps5vkctl, confirmed on the console: etaHEN launches through sceLncUtilLaunchApp as the foreground
// user, AirPSX through sceSystemServiceLaunchApp as a logged-in one), so the ways are tried in turn, and
// after each the system is asked which title is up rather than trusting the return code.
bool LaunchTitle(std::string& how)
{
sceUserServiceInitialize(nullptr); // the user getters answer only once it is initialized
int32_t fg = -1;
if (sceUserServiceGetForegroundUser(&fg) != 0)
fg = -1;
int32_t users[4] = {-1, -1, -1, -1};
int32_t login = -1;
if (sceUserServiceGetLoginUserIdList(users) == 0)
for (int32_t u : users)
if (u >= 0)
{
login = u;
break;
}
struct Attempt
{
const char* name;
int (*call)(const char*, const char*[], SceLncAppParam*);
int32_t user;
uint64_t check_flag;
};
const Attempt attempts[] = {
{"sceLncUtilLaunchApp, foreground user", sceLncUtilLaunchApp, fg, 0},
{"sceLncUtilLaunchApp, login user", sceLncUtilLaunchApp, login, 0},
{"sceSystemServiceLaunchApp, login user", sceSystemServiceLaunchApp, login, 0},
{"sceLncUtilLaunchApp, login user, skip check", sceLncUtilLaunchApp, login, 1},
};
for (const Attempt& a : attempts)
{
if (a.user < 0)
continue;
SceLncAppParam param;
memset(&param, 0, sizeof(param));
param.size = uint32_t(sizeof(param));
param.user_id = a.user;
param.check_flag = a.check_flag;
const uint32_t rc = uint32_t(a.call(N64PS5_TITLE_ID, nullptr, &param));
// a call that reports an error may still have started it: look for 2 s before the next way
bool up = false;
for (int i = 0; i < 8 && !up && rc != 0 && rc != kAlreadyRunning; i++)
{
up = RunningTitle() == N64PS5_TITLE_ID;
if (!up)
usleep(250 * 1000);
}
OrbisLog("[relaunch] %s (user %d) -> 0x%08x%s", a.name, int(a.user), rc, up ? ", the app is up" : "");
if (rc == 0 || rc == kAlreadyRunning || up)
{
how = a.name;
return true;
}
}
OrbisLog("[relaunch] no launch call worked (foreground user %d, login user %d)", int(fg), int(login));
return false;
}
} // namespace
void RelaunchIfPending()
{
FILE* f = fopen(RelaunchPath().c_str(), "r");
if (!f)
return;
int pid = 0;
long long when = 0;
char line[128];
while (fgets(line, sizeof(line), f))
{
if (strncmp(line, "pid=", 4) == 0)
pid = atoi(line + 4);
else if (strncmp(line, "time=", 5) == 0)
when = atoll(line + 5);
}
fclose(f);
// take the job first: a second helper started meanwhile must not run it too
const std::string taken = RelaunchPath() + ".running";
if (rename(RelaunchPath().c_str(), taken.c_str()) != 0)
return;
const long long age = (long long)time(nullptr) - when;
if (pid <= 0 || when <= 0 || age < 0 || age > 120)
{
OrbisLog("[relaunch] dropping an old job (pid %d, %lld s old)", pid, age);
unlink(taken.c_str());
return;
}
OrbisLog("[relaunch] starting " N64PS5_TITLE_ID " again once pid %d has closed", pid);
for (int i = 0; i < 300 && ProcessAlive(pid); i++) // 30 s
usleep(100 * 1000);
if (ProcessAlive(pid))
{
OrbisLog("[relaunch] pid %d still running after 30 s: not starting it again", pid);
unlink(taken.c_str());
return;
}
// the system still lists the title for a moment after its process ends
for (int i = 0; i < 40 && RunningTitle() == N64PS5_TITLE_ID; i++)
usleep(250 * 1000);
usleep(500 * 1000);
std::string how;
if (LaunchTitle(how))
OrbisLog("[relaunch] " N64PS5_TITLE_ID " started again (%s)", how.c_str());
else
ProsperoNotify("Mupen64Plus PS5: open the app again to download the covers of your new games.");
unlink(taken.c_str());
}
bool Write(const Job& job, std::string& why)
{
OrbisMkdirs(OrbisDir("update"));
@@ -57,4 +57,14 @@ std::string EbootMode(const std::string& app_dir);
// The helper payload, at start: performs a pending job (and deletes it). Returns at once when there is none.
void RunIfPending();
// ---- starting the app again (covers for new games, frontend/fe_prefetch.h) ---------------------------------
// The app can only download before it asks for /data, so to fetch covers for new games it closes and its
// helper starts it again: the app writes this job (its pid), starts a fresh helper through the ELF loader and
// closes; the helper (RelaunchIfPending) waits for it to exit and launches the title the way the home screen
// does (sceLncUtilLaunchApp, as PS5_Vulkan's ps5vkctl and etaHEN launch titles). If no launch call works, a
// notification says to open the app again.
std::string RelaunchPath(); // /data/mupen64plus/update/relaunch.txt
bool WriteRelaunch(int pid, std::string& why);
void RelaunchIfPending();
} // namespace updatejob
+33
View File
@@ -102,6 +102,39 @@ std::vector<std::string> OrbisRomRoots()
return roots;
}
std::vector<std::string> OrbisAppFolders()
{
std::vector<std::string> out;
std::vector<std::string> bases;
const char* hb = getenv("N64PS5_PS5_HOMEBREW"); // host tests
bases.push_back(hb && *hb ? hb : "/data/homebrew");
if (!(hb && *hb))
{
char buf[64];
for (int i = 0; i < 8; i++)
{
snprintf(buf, sizeof(buf), "/mnt/usb%d/homebrew", i);
bases.push_back(buf);
snprintf(buf, sizeof(buf), "/mnt/usb%d", i);
bases.push_back(buf);
}
for (int i = 0; i < 2; i++)
{
snprintf(buf, sizeof(buf), "/mnt/ext%d/homebrew", i);
bases.push_back(buf);
snprintf(buf, sizeof(buf), "/mnt/ext%d", i);
bases.push_back(buf);
}
}
for (const std::string& b : bases)
{
const std::string dir = b + "/" N64PS5_TITLE_ID;
if (OrbisIsFile(dir + "/sce_sys/param.json") && OrbisIsFile(dir + "/eboot.bin"))
out.push_back(dir);
}
return out;
}
// Lines logged before the log file is open (the native app logs its jailbreak before /data is visible).
static std::vector<std::string>& EarlyLines()
{
+334
View File
@@ -0,0 +1,334 @@
// Mupen64Plus PS5 frontend: the cheat database (fe_cheats.h).
//
// mupencheat.txt, as the core ships it (converted from Project64's database by Gent):
//
// crc 635A2BFF-8B022326-C:45 the ROM header's CRC1, CRC2 and country code
// gn Super Mario 64 (U)
// cn Infinite Lives a cheat (a '\' puts it in a group)
// cd Some explanation optional
// 8033B21D 0064 its codes: address, value
// 8013CD0E ???? 0001:"1 Lap",... a value to choose from a list
//
// SPDX-License-Identifier: MIT
#include "fe_cheats.h"
#include "OrbisPaths.h"
#include "zlib.h"
#include <cstdlib>
#include <cstring>
#ifndef CHEATS_Z
#error "CHEATS_Z: the compressed mupencheat.txt (the Makefile builds it)"
#endif
#define FE_INCBIN(sym, path) \
__asm__(".section .rodata\n" \
".balign 16\n" \
".global " #sym "_begin\n" #sym "_begin:\n" \
".incbin \"" path "\"\n" \
".global " #sym "_end\n" #sym "_end:\n" \
".previous\n"); \
extern "C" const unsigned char sym##_begin[]; \
extern "C" const unsigned char sym##_end[];
// 4 bytes: the text's size (little endian), then the text, zlib-compressed
FE_INCBIN(n64ps5_cheats_z, CHEATS_Z)
namespace fe
{
namespace cheats
{
namespace
{
std::string Inflate()
{
const unsigned char* z = n64ps5_cheats_z_begin;
const size_t zsize = size_t(n64ps5_cheats_z_end - n64ps5_cheats_z_begin);
if (zsize < 4)
return "";
uLongf size = uLongf(z[0]) | uLongf(z[1]) << 8 | uLongf(z[2]) << 16 | uLongf(z[3]) << 24;
std::string text(size, '\0');
if (uncompress(reinterpret_cast<Bytef*>(&text[0]), &size, z + 4, uLong(zsize - 4)) != Z_OK)
{
OrbisLog("[cheats] the built-in cheat list is damaged");
return "";
}
text.resize(size);
return text;
}
// a code line: "8033B21D 0064" or "8013CD0E ???? 0001:"1 Lap",0002:"2 Laps""
bool ParseCode(const std::string& line, Cheat& c)
{
char* end = nullptr;
const unsigned long address = strtoul(line.c_str(), &end, 16);
if (end == line.c_str() || *end != ' ')
return false;
const std::string rest = end + 1;
if (rest.compare(0, 4, "????") == 0)
{
c.addresses.push_back(uint32_t(address));
c.values.push_back(-1);
// the choices: hex value, ':', quoted name, separated by commas. Every "????" of a cheat takes the same
// choice; the list is on one of its lines (28 cheats leave the first one's empty).
std::vector<std::pair<std::string, int>> options;
size_t at = 4;
while (at < rest.size())
{
while (at < rest.size() && (rest[at] == ' ' || rest[at] == ','))
at++;
const size_t colon = rest.find(':', at);
const size_t q1 = colon == std::string::npos ? colon : rest.find('"', colon);
const size_t q2 = q1 == std::string::npos ? q1 : rest.find('"', q1 + 1);
if (q2 == std::string::npos)
break;
const int value = int(strtol(rest.substr(at, colon - at).c_str(), nullptr, 16));
options.emplace_back(rest.substr(q1 + 1, q2 - q1 - 1), value);
at = q2 + 1;
}
if (c.options.empty())
c.options = std::move(options);
return true;
}
c.addresses.push_back(uint32_t(address));
c.values.push_back(int(strtol(rest.c_str(), nullptr, 16)));
return true;
}
// codes the core applies only while the GameShark's button is held
bool NeedsGameSharkButton(const Cheat& c)
{
for (uint32_t a : c.addresses)
{
const uint32_t type = a & 0xFF000000u;
if (type == 0x88000000u || type == 0x89000000u || type == 0xA8000000u || type == 0xA9000000u ||
(type >= 0xD8000000u && type <= 0xDB000000u))
return true;
}
return false;
}
// a cheat the menu can offer: it has codes, needs no GameShark button, and a choice has its list
bool Usable(const Cheat& c)
{
if (c.addresses.empty() || NeedsGameSharkButton(c))
return false;
for (int v : c.values)
if (v < 0 && c.options.empty())
return false;
return true;
}
} // namespace
std::vector<Cheat> ForGame(uint32_t crc1, uint32_t crc2, uint8_t country, std::string* game)
{
std::vector<Cheat> out;
const std::string text = Inflate();
char want[48];
snprintf(want, sizeof(want), "crc %08X-%08X-C:%02X", unsigned(crc1), unsigned(crc2), unsigned(country));
size_t at = text.find(want);
if (at == std::string::npos)
return out;
at = text.find('\n', at);
int skipped = 0;
Cheat* cur = nullptr;
while (at != std::string::npos && at + 1 < text.size())
{
const size_t start = at + 1;
at = text.find('\n', start);
std::string line = text.substr(start, at == std::string::npos ? std::string::npos : at - start);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (line.compare(0, 4, "crc ") == 0)
break; // the next game
if (line.compare(0, 3, "gn ") == 0)
{
if (game)
*game = line.substr(3);
}
else if (line.compare(0, 4, " cn ") == 0)
{
if (cur && !Usable(*cur))
{
skipped++;
out.pop_back();
}
out.emplace_back();
cur = &out.back();
cur->name = line.substr(4);
}
else if (cur && line.compare(0, 5, " cd ") == 0)
cur->note = line.substr(5);
else if (cur && line.size() > 2 && line[0] == ' ' && line[1] == ' ')
ParseCode(line.substr(2), *cur);
}
if (cur && !Usable(*cur))
{
skipped++;
out.pop_back();
}
if (skipped)
OrbisLog("[cheats] %d cheat(s) left out (the GameShark's button, or no codes)", skipped);
return out;
}
std::vector<Cheat> Parse(const std::string& text)
{
std::vector<Cheat> out;
size_t at = 0;
while (at < text.size())
{
size_t end = text.find('\n', at);
if (end == std::string::npos)
end = text.size();
std::string line = text.substr(at, end - at);
at = end + 1;
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
size_t lead = 0;
while (lead < line.size() && (line[lead] == ' ' || line[lead] == '\t'))
lead++;
line.erase(0, lead);
if (line.empty() || line[0] == '#' || line.compare(0, 4, "crc ") == 0 || line.compare(0, 3, "gn ") == 0)
continue;
if (line.compare(0, 3, "cn ") == 0)
{
out.emplace_back();
out.back().name = line.substr(3);
out.back().mine = true;
}
else if (!out.empty() && line.compare(0, 3, "cd ") == 0)
out.back().note = line.substr(3);
else if (!out.empty())
ParseCode(line, out.back());
}
std::vector<Cheat> usable;
for (Cheat& c : out)
if (!c.name.empty() && Usable(c))
usable.push_back(std::move(c));
return usable;
}
std::string Format(const std::vector<Cheat>& list)
{
std::string out = "# Mupen64Plus PS5: your own cheats for this game, in the format of mupen64plus's cheat list.\n"
"# A cheat is a \" cn name\" line, then its codes: an address and a value, \"8033B21D 0064\".\n";
for (const Cheat& c : list)
{
out += " cn " + c.name + "\n";
if (!c.note.empty())
out += " cd " + c.note + "\n";
char code[24];
bool listed = false; // a cheat with choices lists them on its first "????" line
for (size_t i = 0; i < c.addresses.size(); i++)
{
if (c.values[i] >= 0)
snprintf(code, sizeof(code), " %08X %04X", unsigned(c.addresses[i]), unsigned(c.values[i] & 0xFFFF));
else
snprintf(code, sizeof(code), " %08X ????", unsigned(c.addresses[i]));
out += code;
if (c.values[i] < 0 && !listed)
{
listed = true;
for (size_t k = 0; k < c.options.size(); k++)
{
snprintf(code, sizeof(code), "%s%04X:", k ? "," : " ", unsigned(c.options[k].second & 0xFFFF));
out += code + ("\"" + c.options[k].first + "\"");
}
}
out += "\n";
}
}
return out;
}
bool ParseCodes(const std::string& text, Cheat& c, std::string* error)
{
std::vector<std::string> tokens;
std::string cur;
for (const char ch : text + " ")
{
if (ch == ' ' || ch == ',' || ch == '\n' || ch == '\r' || ch == '\t' || ch == ';' || ch == ':' || ch == '+')
{
if (!cur.empty())
tokens.push_back(cur);
cur.clear();
}
else
cur += ch;
}
auto hex = [](const std::string& t, size_t max) {
if (t.empty() || t.size() > max)
return false;
for (const char ch : t)
if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')))
return false;
return true;
};
Cheat out;
for (size_t i = 0, step = 2; i < tokens.size(); i += step)
{
// "8033B21D0064" as one token is a code too
std::string address = tokens[i], value = i + 1 < tokens.size() ? tokens[i + 1] : "";
step = 2;
if (address.size() == 12 && hex(address, 12))
{
value = address.substr(8);
address.resize(8);
step = 1;
}
if (address.size() != 8 || !hex(address, 8) || !hex(value, 4))
{
if (error)
*error = "\"" + tokens[i] + (value.empty() ? "" : " " + value) +
"\" is not a code: a code is 8 hex digits and 4 more, like 8033B21D 0064";
return false;
}
out.addresses.push_back(uint32_t(strtoul(address.c_str(), nullptr, 16)));
out.values.push_back(int(strtol(value.c_str(), nullptr, 16)));
}
if (out.addresses.empty())
{
if (error)
*error = "No codes: type them like 8033B21D 0064";
return false;
}
if (NeedsGameSharkButton(out))
{
if (error)
*error = "Codes starting 88, 89, A8, A9 or D8 to DB need the GameShark's button, which a DualSense hasn't got";
return false;
}
c.addresses = std::move(out.addresses);
c.values = std::move(out.values);
c.options.clear();
return true;
}
std::string CodesText(const Cheat& c)
{
std::string out;
char code[24];
for (size_t i = 0; i < c.addresses.size(); i++)
{
int v = c.values[i];
if (v < 0)
v = c.options.empty() ? 0 : c.options[size_t(c.state > 0 ? c.state - 1 : 0)].second;
snprintf(code, sizeof(code), "%s%08X %04X", out.empty() ? "" : ", ", unsigned(c.addresses[i]), unsigned(v & 0xFFFF));
out += code;
}
return out;
}
std::string DisplayName(const std::string& name)
{
std::string s = name;
for (size_t i = s.find('\\'); i != std::string::npos; i = s.find('\\', i + 2))
s.replace(i, 1, ": ");
return s;
}
} // namespace cheats
} // namespace fe
+48
View File
@@ -0,0 +1,48 @@
// Mupen64Plus PS5 frontend: cheats -- the GameShark codes of mupen64plus-core's data/mupencheat.txt (built into
// eboot.bin, compressed) and the player's own (found with the cheat search or typed in), switched on and off in
// the pause menu and remembered per game (fe_emu.cpp).
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace fe
{
namespace cheats
{
struct Cheat
{
std::string name; // as in the file: "Play As\Player 1" is "Player 1" in the group "Play As"
std::string note; // what to know before using it (its "cd" line), often empty
std::vector<uint32_t> addresses;
std::vector<int> values; // -1: the chosen option's value
std::vector<std::pair<std::string, int>> options; // a cheat with choices ("????"), else empty
int state = 0; // 0 off; then 1 on, or with options 1 + the chosen option's index
bool mine = false; // the player's own (found with the cheat search or typed in), not the database's
int uid = 0; // its name in the core while the game runs (fe_emu.cpp)
int States() const { return options.empty() ? 2 : int(options.size()) + 1; }
};
// The cheats of the game whose ROM header has these CRCs and country code (the database's "crc" lines), in
// the database's order; `game` gets its name there. Cheats that need the GameShark's button are left out:
// the DualSense has none.
std::vector<Cheat> ForGame(uint32_t crc1, uint32_t crc2, uint8_t country, std::string* game);
// The player's own cheats: a file in the database's format (" cn name" lines, each followed by its codes,
// "8033B21D 0064"), read leniently so one written on a PC works too; and back.
std::vector<Cheat> Parse(const std::string& text);
std::string Format(const std::vector<Cheat>& list);
// Codes typed in: pairs of an address (8 hex digits) and a value (up to 4), separated by spaces, commas or new
// lines. False with `error` set when they aren't (or need the GameShark's button).
bool ParseCodes(const std::string& text, Cheat& c, std::string* error);
// "8033B21D 0064, 8033B21E 0001" (the chosen option's value, or the first, where the code has "????")
std::string CodesText(const Cheat& c);
// "Play As\Player 1" -> "Play As: Player 1"
std::string DisplayName(const std::string& name);
} // namespace cheats
} // namespace fe
+311
View File
@@ -0,0 +1,311 @@
// Mupen64Plus PS5 frontend: anti-aliased shapes (fe_draw.h). Rounded rectangles and circles use their signed
// distance (coverage = 0.5 - distance), the triangles 4 x 4 samples per pixel: menus only, a few per frame.
// SPDX-License-Identifier: MIT
#include "fe_draw.h"
#include "ProsperoVideo.h"
#include <algorithm>
#include <cmath>
namespace fe
{
namespace gfx
{
namespace
{
constexpr int W = ps5video::kWidth;
constexpr int H = ps5video::kHeight;
inline float Clamp01(float v)
{
return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v);
}
inline void Put(int x, int y, uint32_t color, float coverage, int alpha)
{
if (coverage <= 0.0f || x < 0 || y < 0 || x >= W || y >= H)
return;
uint32_t& p = ps5video::Surface()[size_t(y) * W + x];
p = Blend(p, color, int(coverage * alpha + 0.5f));
}
struct RoundRect
{
float cx, cy, hw, hh, r;
RoundRect(int x, int y, int w, int h, int radius)
{
hw = w * 0.5f;
hh = h * 0.5f;
cx = x + hw;
cy = y + hh;
r = std::max(0.0f, std::min(float(radius), std::min(hw, hh)));
}
// signed distance of a pixel's centre to the outline (negative inside)
float Distance(int x, int y) const
{
const float ax = std::fabs(x + 0.5f - cx), ay = std::fabs(y + 0.5f - cy);
const float qx = ax - (hw - r), qy = ay - (hh - r);
if (qx <= 0.0f || qy <= 0.0f)
return std::max(ax - hw, ay - hh);
return std::sqrt(qx * qx + qy * qy) - r;
}
// deep inside, away from the edges and corners by more than `band`
bool Inside(int x, int y, float band) const
{
const float ax = std::fabs(x + 0.5f - cx), ay = std::fabs(y + 0.5f - cy);
return (ax < hw - r && ay < hh - band) || (ay < hh - r && ax < hw - band);
}
};
} // namespace
uint32_t Blend(uint32_t dst, uint32_t color, int alpha)
{
if (alpha <= 0)
return dst;
const uint32_t a = uint32_t(std::min(alpha, 255));
const uint32_t w = a + (a >> 7);
const uint32_t iw = 256 - w;
const uint32_t rb = (((dst & 0xff00ffu) * iw + (color & 0xff00ffu) * w) >> 8) & 0xff00ffu;
const uint32_t g = (((dst & 0x00ff00u) * iw + (color & 0x00ff00u) * w) >> 8) & 0x00ff00u;
return 0xff000000u | rb | g;
}
uint32_t Mix(uint32_t a, uint32_t b, float t)
{
return Blend(a, b, int(Clamp01(t) * 255.0f + 0.5f));
}
void FillRoundRect(int x, int y, int w, int h, int r, uint32_t color, int alpha)
{
const RoundRect rr(x, y, w, h, r);
for (int yy = std::max(0, y); yy < std::min(H, y + h); yy++)
for (int xx = std::max(0, x); xx < std::min(W, x + w); xx++)
Put(xx, yy, color, rr.Inside(xx, yy, 1.0f) ? 1.0f : Clamp01(0.5f - rr.Distance(xx, yy)), alpha);
}
void GradientRoundRect(int x, int y, int w, int h, int r, uint32_t c0, uint32_t c1, bool horizontal, int alpha)
{
const RoundRect rr(x, y, w, h, r);
for (int yy = std::max(0, y); yy < std::min(H, y + h); yy++)
for (int xx = std::max(0, x); xx < std::min(W, x + w); xx++)
{
const float t = horizontal ? float(xx - x) / std::max(1, w - 1) : float(yy - y) / std::max(1, h - 1);
Put(xx, yy, Mix(c0, c1, t), rr.Inside(xx, yy, 1.0f) ? 1.0f : Clamp01(0.5f - rr.Distance(xx, yy)), alpha);
}
}
void StrokeRoundRect(int x, int y, int w, int h, int r, float thick, uint32_t color, int alpha)
{
const RoundRect rr(x, y, w, h, r);
for (int yy = std::max(0, y); yy < std::min(H, y + h); yy++)
for (int xx = std::max(0, x); xx < std::min(W, x + w); xx++)
{
if (rr.Inside(xx, yy, thick + 1.0f))
continue;
const float d = rr.Distance(xx, yy);
Put(xx, yy, color, Clamp01(0.5f - d) - Clamp01(0.5f - d - thick), alpha);
}
}
void GlowRoundRect(int x, int y, int w, int h, int r, int size, uint32_t color, int alpha)
{
const RoundRect rr(x, y, w, h, r);
for (int yy = std::max(0, y - size); yy < std::min(H, y + h + size); yy++)
for (int xx = std::max(0, x - size); xx < std::min(W, x + w + size); xx++)
{
if (rr.Inside(xx, yy, 0.0f))
continue;
const float d = rr.Distance(xx, yy);
if (d <= 0.0f || d >= size)
continue;
const float f = 1.0f - d / size;
Put(xx, yy, color, f * f, alpha);
}
}
void FillCircle(float cx, float cy, float r, uint32_t color, int alpha)
{
for (int yy = std::max(0, int(cy - r - 1)); yy <= std::min(H - 1, int(cy + r + 1)); yy++)
for (int xx = std::max(0, int(cx - r - 1)); xx <= std::min(W - 1, int(cx + r + 1)); xx++)
{
const float dx = xx + 0.5f - cx, dy = yy + 0.5f - cy;
Put(xx, yy, color, Clamp01(0.5f - (std::sqrt(dx * dx + dy * dy) - r)), alpha);
}
}
void StrokeCircle(float cx, float cy, float r, float thick, uint32_t color, int alpha)
{
StrokeArc(cx, cy, r - thick * 0.5f, thick, 0.0f, 360.0f, color, alpha);
}
void FillTriangle(float x0, float y0, float x1, float y1, float x2, float y2, uint32_t color, int alpha)
{
const float area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
if (std::fabs(area) < 1e-3f)
return;
const float s = area > 0 ? 1.0f : -1.0f;
auto edge = [s](float ax, float ay, float bx, float by, float px, float py) {
return s * ((bx - ax) * (py - ay) - (by - ay) * (px - ax));
};
const int minx = std::max(0, int(std::floor(std::min({x0, x1, x2})))), maxx = std::min(W - 1, int(std::ceil(std::max({x0, x1, x2}))));
const int miny = std::max(0, int(std::floor(std::min({y0, y1, y2})))), maxy = std::min(H - 1, int(std::ceil(std::max({y0, y1, y2}))));
for (int yy = miny; yy <= maxy; yy++)
for (int xx = minx; xx <= maxx; xx++)
{
int in = 0;
for (int sy = 0; sy < 4; sy++)
for (int sx = 0; sx < 4; sx++)
{
const float px = xx + (sx + 0.5f) / 4.0f, py = yy + (sy + 0.5f) / 4.0f;
if (edge(x0, y0, x1, y1, px, py) >= 0 && edge(x1, y1, x2, y2, px, py) >= 0 && edge(x2, y2, x0, y0, px, py) >= 0)
in++;
}
Put(xx, yy, color, in / 16.0f, alpha);
}
}
void StrokeArc(float cx, float cy, float r, float thick, float a0, float a1, uint32_t color, int alpha)
{
const float outer = r + thick * 0.5f + 1.0f;
const bool full = a1 - a0 >= 360.0f;
for (int yy = std::max(0, int(cy - outer)); yy <= std::min(H - 1, int(cy + outer)); yy++)
for (int xx = std::max(0, int(cx - outer)); xx <= std::min(W - 1, int(cx + outer)); xx++)
{
const float dx = xx + 0.5f - cx, dy = yy + 0.5f - cy;
const float cov = Clamp01(thick * 0.5f + 0.5f - std::fabs(std::sqrt(dx * dx + dy * dy) - r));
if (cov <= 0.0f)
continue;
if (!full)
{
float a = std::atan2(dy, dx) * 57.29578f;
if (a < 0)
a += 360.0f;
float lo = std::fmod(a0, 360.0f), span = a1 - a0;
if (lo < 0)
lo += 360.0f;
float rel = a - lo;
if (rel < 0)
rel += 360.0f;
if (rel > span)
continue;
}
Put(xx, yy, color, cov, alpha);
}
}
void HLine(int x, int y, int w, uint32_t color, int alpha)
{
for (int xx = x; xx < x + w; xx++)
Put(xx, y, color, 1.0f, alpha);
}
void VLine(int x, int y, int h, uint32_t color, int alpha)
{
for (int yy = y; yy < y + h; yy++)
Put(x, yy, color, 1.0f, alpha);
}
void DrawIcon(Icon icon, int x, int y, int size, uint32_t color)
{
const float s = float(size);
const float cx = x + s * 0.5f, cy = y + s * 0.5f;
switch (icon)
{
case Icon::Pause:
{
const int bw = int(s * 0.2f), bh = int(s * 0.62f), gap = int(s * 0.16f);
const int top = int(cy - bh * 0.5f), left = int(cx - gap * 0.5f) - bw;
FillRoundRect(left, top, bw, bh, bw / 3, color);
FillRoundRect(int(cx + gap * 0.5f), top, bw, bh, bw / 3, color);
break;
}
case Icon::Play:
FillTriangle(x + s * 0.22f, y + s * 0.14f, x + s * 0.22f, y + s * 0.86f, x + s * 0.86f, cy, color);
break;
case Icon::Restart:
{
// most of a circle, open at the top, with an arrowhead at its upper left end pointing on round
const float r = s * 0.32f, t = s * 0.12f;
StrokeArc(cx, cy, r, t, 300.0f, 600.0f, color);
const float a = 240.0f / 57.29578f;
const float px = cx + r * std::cos(a), py = cy + r * std::sin(a);
const float tx = -std::sin(a), ty = std::cos(a); // the way round (clockwise)
const float nx = std::cos(a), ny = std::sin(a);
const float hl = s * 0.22f, hw = s * 0.17f;
FillTriangle(px + tx * hl, py + ty * hl, px + nx * hw, py + ny * hw, px - nx * hw, py - ny * hw, color);
break;
}
case Icon::FastForward:
FillTriangle(x + s * 0.08f, y + s * 0.2f, x + s * 0.08f, y + s * 0.8f, x + s * 0.5f, cy, color);
FillTriangle(x + s * 0.5f, y + s * 0.2f, x + s * 0.5f, y + s * 0.8f, x + s * 0.92f, cy, color);
break;
case Icon::Grid:
for (int j = 0; j < 3; j++)
for (int i = 0; i < 3; i++)
FillRoundRect(x + int(s * (0.1f + 0.29f * i)), y + int(s * (0.1f + 0.29f * j)), int(s * 0.22f),
int(s * 0.22f), 2, color);
break;
case Icon::Tiles:
for (int j = 0; j < 2; j++)
for (int i = 0; i < 2; i++)
FillRoundRect(x + int(s * (0.1f + 0.43f * i)), y + int(s * (0.1f + 0.43f * j)), int(s * 0.36f),
int(s * 0.36f), int(s * 0.08f), color);
break;
case Icon::Frame:
{
// a dashed square: four dashes a side
const int a = int(s * 0.12f), b = int(s * 0.88f), t = std::max(2, int(s * 0.09f)), d = (b - a) / 7;
for (int k = 0; k < 7; k += 2)
{
FillRoundRect(x + a + k * d, y + a, d, t, 1, color);
FillRoundRect(x + a + k * d, y + b - t, d, t, 1, color);
FillRoundRect(x + a, y + a + k * d, t, d, 1, color);
FillRoundRect(x + b - t, y + a + k * d, t, d, 1, color);
}
break;
}
case Icon::Bars:
for (int i = 0; i < 3; i++)
{
const float hgt = s * (0.3f + 0.25f * i);
FillRoundRect(x + int(s * (0.12f + 0.28f * i)), y + int(s * 0.88f - hgt), int(s * 0.2f), int(hgt), 2, color);
}
break;
case Icon::Screen:
StrokeRoundRect(x + int(s * 0.06f), y + int(s * 0.2f), int(s * 0.88f), int(s * 0.6f), int(s * 0.08f),
std::max(2.0f, s * 0.09f), color);
break;
case Icon::Gauge:
{
const float r = s * 0.38f;
StrokeArc(cx, cy + s * 0.08f, r, s * 0.1f, 150.0f, 390.0f, color);
const float a = 300.0f / 57.29578f;
const float ex = cx + r * 0.8f * std::cos(a), ey = cy + s * 0.08f + r * 0.8f * std::sin(a);
const float nx = -std::sin(a) * s * 0.05f, ny = std::cos(a) * s * 0.05f;
FillTriangle(cx + nx, cy + s * 0.08f + ny, cx - nx, cy + s * 0.08f - ny, ex, ey, color);
FillCircle(cx, cy + s * 0.08f, s * 0.08f, color);
break;
}
case Icon::Download:
FillRoundRect(int(cx - s * 0.07f), y + int(s * 0.1f), int(s * 0.14f), int(s * 0.4f), 1, color);
FillTriangle(cx - s * 0.25f, y + s * 0.45f, cx + s * 0.25f, y + s * 0.45f, cx, y + s * 0.72f, color);
FillRoundRect(x + int(s * 0.12f), y + int(s * 0.8f), int(s * 0.76f), std::max(2, int(s * 0.09f)), 1, color);
break;
case Icon::Search:
{
// a magnifying glass: a ring at the upper left, its handle down to the lower right
const float r = s * 0.25f, rx = x + s * 0.42f, ry = y + s * 0.42f;
StrokeArc(rx, ry, r, s * 0.1f, 0.0f, 360.0f, color);
const float d = 0.70710678f, hw = s * 0.07f;
const float ax = rx + (r + s * 0.03f) * d, ay = ry + (r + s * 0.03f) * d;
const float bx = x + s * 0.9f, by = y + s * 0.9f;
FillTriangle(ax - hw * d, ay + hw * d, ax + hw * d, ay - hw * d, bx + hw * d, by - hw * d, color);
FillTriangle(ax - hw * d, ay + hw * d, bx + hw * d, by - hw * d, bx - hw * d, by + hw * d, color);
break;
}
}
}
} // namespace gfx
} // namespace fe
+49
View File
@@ -0,0 +1,49 @@
// Mupen64Plus PS5 frontend: anti-aliased shapes on the CPU-drawn screen (ps5video::Surface()), for the pause menu's
// look: rounded panels and buttons, glows, gradients, switches, sliders and a few drawn icons.
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
namespace fe
{
namespace gfx
{
// color over the pixel at `alpha` (0..255)
uint32_t Blend(uint32_t dst, uint32_t color, int alpha);
uint32_t Mix(uint32_t a, uint32_t b, float t); // a -> b
void FillRoundRect(int x, int y, int w, int h, int r, uint32_t color, int alpha = 255);
// from c0 to c1, left to right (horizontal) or top to bottom
void GradientRoundRect(int x, int y, int w, int h, int r, uint32_t c0, uint32_t c1, bool horizontal, int alpha = 255);
// a border `thick` pixels wide, inside the rectangle
void StrokeRoundRect(int x, int y, int w, int h, int r, float thick, uint32_t color, int alpha = 255);
// light around the rectangle, fading out over `size` pixels
void GlowRoundRect(int x, int y, int w, int h, int r, int size, uint32_t color, int alpha);
void FillCircle(float cx, float cy, float r, uint32_t color, int alpha = 255);
void StrokeCircle(float cx, float cy, float r, float thick, uint32_t color, int alpha = 255);
void FillTriangle(float x0, float y0, float x1, float y1, float x2, float y2, uint32_t color, int alpha = 255);
// part of a ring, angles in degrees, clockwise from 3 o'clock (screen y grows downwards)
void StrokeArc(float cx, float cy, float r, float thick, float a0, float a1, uint32_t color, int alpha = 255);
void HLine(int x, int y, int w, uint32_t color, int alpha = 255);
void VLine(int x, int y, int h, uint32_t color, int alpha = 255);
// small icons drawn with the shapes above, in a `size` x `size` box
enum class Icon
{
Pause,
Play,
Restart, // a circular arrow
FastForward,
Grid, // 3 x 3 squares
Tiles, // 2 x 2 squares
Frame, // a dashed square
Bars, // a bar chart
Screen, // a rectangle
Gauge,
Download,
Search, // a magnifying glass
};
void DrawIcon(Icon icon, int x, int y, int size, uint32_t color);
} // namespace gfx
} // namespace fe
+424 -21
View File
@@ -17,6 +17,8 @@
#include "fe_emu.h"
#include "fe_cheats.h"
#include "fe_games.h"
#include "fe_menu.h"
#include "fe_settings.h"
@@ -49,6 +51,7 @@
#include <cstdarg>
#include <dirent.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <mutex>
@@ -73,7 +76,18 @@ FE_INCBIN(n64ps5_rom_catalog, M64P_INI_PATH)
namespace
{
constexpr int kFrontendApiVersion = 0x020106;
constexpr size_t kMaxRomSize = 64u << 20; // the biggest N64 cartridges are 64 MiB
// the biggest N64 cartridges are 64 MiB, ROM hacks can fill the whole cartridge space (the core's CART_ROM_MAX_SIZE)
constexpr size_t kMaxRomSize = 252u << 20;
std::string RomSizeError(long size)
{
if (size <= 0)
return "Not an N64 ROM (the file is empty)";
char text[96];
snprintf(text, sizeof(text), "Too big for an N64 ROM (%.1f MB, the most is %zu MB)", double(size) / (1 << 20),
kMaxRomSize >> 20);
return text;
}
double Now()
{
@@ -210,6 +224,22 @@ struct State
uint32_t prev_buttons = 0;
bool block_until_release = false;
int pending_save_slot = -1;
// the controller layout while a game is loaded (from RunGame on): its own or Settings'
bool game_layout = false;
bool own_layout = false;
fe::Layout layout;
// cheats: the player's own, then the database's for this ROM, with what the player chose
std::vector<fe::cheats::Cheat> cheats;
std::string cheats_game;
int next_uid = 0;
fe::search::Search search; // the cheat search, while the game runs
// "continue where you left off": the state loaded at the first VI, and the one saved on the way out
bool resume_load = false;
bool resume_loading = false;
bool leaving = false; // the pause menu chose to leave: the state is being saved, then the game stops
bool leave_saved = false;
uint32_t leave_vi = 0;
emu::ExitReason leave_reason = emu::ExitReason::BackToList;
// FPS counter
double fps_t0 = 0;
uint64_t fps_tsc0 = 0;
@@ -272,7 +302,14 @@ void CoreState(void*, m64p_core_param param, int value)
OrbisLog("[emu] state %s %s", save ? "save" : "load", value ? "done" : "failed");
if (save)
g.pending_save_slot = -1;
if (!value)
if (save && g.leaving)
g.leave_saved = true;
else if (!save && g.resume_loading)
{
g.resume_loading = false;
emu::Osd(value ? "Continuing where you left off" : "Could not continue where you left off: the game starts over");
}
else if (!value)
emu::Osd(save ? "Could not save the state" : "Could not load the state");
}
}
@@ -334,10 +371,11 @@ bool ReadWholeFile(const std::string& path, std::vector<uint8_t>& out, std::stri
fseek(f, 0, SEEK_END);
const long size = ftell(f);
fseek(f, 0, SEEK_SET);
OrbisLog("[emu] ROM file: %ld bytes", size);
if (size <= 0 || size_t(size) > kMaxRomSize)
{
fclose(f);
*error = "Not an N64 ROM (wrong size)";
*error = RomSizeError(size);
return false;
}
out.resize(size_t(size));
@@ -371,8 +409,11 @@ bool ReadZippedRom(const std::string& path, std::vector<uint8_t>& out, std::stri
}
}
bool ok = false;
if (best == 0 || best > kMaxRomSize)
OrbisLog("[emu] ROM in the zip: %s, %lu bytes", best_name, best);
if (best == 0)
*error = "No N64 ROM in the zip file";
else if (best > kMaxRomSize)
*error = RomSizeError(long(best));
else if (unzLocateFile(z, best_name, 0) != UNZ_OK || unzOpenCurrentFile(z) != UNZ_OK)
*error = "Can't read the zip file";
else
@@ -444,6 +485,128 @@ std::string StatePath(int slot)
return OrbisDir("states") + "/" + g.file_base + ".st" + std::to_string(slot);
}
// The ROM's file name without folder and extension: its states are named after it.
std::string FileBase(const std::string& path)
{
const size_t slash = path.find_last_of('/');
std::string base = path.substr(slash == std::string::npos ? 0 : slash + 1);
if (const size_t d = base.find_last_of('.'); d != std::string::npos && d > 0)
base.resize(d);
return base;
}
// The cheats the player left on, by name: "<state>\t<name>" lines (fe_cheats.h's state).
std::string CheatsPath()
{
return OrbisDir("cheats") + "/" + g.file_base + ".txt";
}
void LoadCheatStates()
{
FILE* f = fopen(CheatsPath().c_str(), "r");
if (!f)
return;
char line[512];
while (fgets(line, sizeof(line), f))
{
line[strcspn(line, "\r\n")] = 0;
char* tab = strchr(line, '\t');
if (!tab)
continue;
*tab = 0;
const int state = atoi(line);
for (fe::cheats::Cheat& c : g.cheats)
if (c.name == tab + 1 && state > 0 && state < c.States())
c.state = state;
}
fclose(f);
}
void SaveCheatStates()
{
std::string text;
for (const fe::cheats::Cheat& c : g.cheats)
if (c.state)
text += std::to_string(c.state) + "\t" + c.name + "\n";
if (text.empty())
{
unlink(CheatsPath().c_str());
return;
}
OrbisMkdirs(OrbisDir("cheats"));
if (FILE* f = fopen(CheatsPath().c_str(), "w"))
{
fputs(text.c_str(), f);
fclose(f);
}
}
// The player's own cheats: cheats/<ROM file>.custom.txt, in the database's format (fe::cheats::Parse).
std::string CustomCheatsPath()
{
return OrbisDir("cheats") + "/" + g.file_base + ".custom.txt";
}
std::vector<fe::cheats::Cheat> LoadCustomCheats()
{
std::string text;
if (FILE* f = fopen(CustomCheatsPath().c_str(), "r"))
{
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
text.append(buf, n);
fclose(f);
}
return fe::cheats::Parse(text);
}
void SaveCustomCheats()
{
std::vector<fe::cheats::Cheat> mine;
for (const fe::cheats::Cheat& c : g.cheats)
if (c.mine)
mine.push_back(c);
if (mine.empty())
{
unlink(CustomCheatsPath().c_str());
return;
}
OrbisMkdirs(OrbisDir("cheats"));
if (FILE* f = fopen(CustomCheatsPath().c_str(), "w"))
{
fputs(fe::cheats::Format(mine).c_str(), f);
fclose(f);
}
}
// Hands cheat `index` to the core as it is now: on (with its codes, the chosen option's value in place of
// "????") or off (the core puts the old values back). Its name in the core is "#<uid>", which stays the same
// while the game runs whatever the list or the cheat's name does.
void ApplyCheat(int index)
{
const fe::cheats::Cheat& c = g.cheats[size_t(index)];
const std::string id = "#" + std::to_string(c.uid);
if (c.state == 0)
{
CoreCheatEnabled(id.c_str(), 0);
return;
}
const int choice = c.options.empty() ? 0 : c.options[size_t(c.state - 1)].second;
std::vector<m64p_cheat_code> codes;
for (size_t i = 0; i < c.addresses.size(); i++)
codes.push_back({c.addresses[i], c.values[i] < 0 ? choice : c.values[i]});
const m64p_error r = CoreAddCheat(id.c_str(), codes.data(), int(codes.size()));
if (r != M64ERR_SUCCESS)
OrbisLog("[cheats] the core refused \"%s\" (%d)", c.name.c_str(), int(r));
}
// "Continue where you left off": saved when the player leaves the game from the pause menu.
std::string ResumePath(const std::string& file_base)
{
return OrbisDir("states") + "/" + file_base + ".resume";
}
void SetLimiter(bool on)
{
if (g.limiter_set && g.limiter_on == on)
@@ -463,6 +626,31 @@ void StopGame(emu::ExitReason why)
CoreDoCommand(M64CMD_STOP, 0, nullptr);
}
// Leaving the game from the pause menu: with "Continue where you left off" on, its state is saved first (at
// the next VI; n64ps5_on_vi stops the game once the core says it is written), else it stops now.
void LeaveGame(emu::ExitReason why)
{
if (g.leaving || g.stopping)
return;
if (fe::Config().resume)
{
const std::string path = ResumePath(g.file_base);
if (CoreDoCommand(M64CMD_STATE_SAVE, 1, (void*)path.c_str()) == M64ERR_SUCCESS)
{
OrbisLog("[emu] leaving: saving where the player is -> %s", path.c_str());
g.leaving = true;
g.leave_saved = false;
g.leave_vi = g.vi;
g.leave_reason = why;
n64ps5_input_block(true);
n64ps5_audio_set_mute(true);
return;
}
OrbisLog("[emu] leaving: the core refused to save the state");
}
StopGame(why);
}
void OpenPauseMenu()
{
N64_STAGE(Emu, "pause menu");
@@ -480,8 +668,8 @@ void OpenPauseMenu()
switch (action)
{
case fe::PauseAction::Resume: break;
case fe::PauseAction::BackToList: StopGame(emu::ExitReason::BackToList); break;
case fe::PauseAction::Quit: StopGame(emu::ExitReason::Quit); break;
case fe::PauseAction::BackToList: LeaveGame(emu::ExitReason::BackToList); break;
case fe::PauseAction::Quit: LeaveGame(emu::ExitReason::Quit); break;
}
N64_STAGE(Emu, "running");
}
@@ -529,6 +717,20 @@ extern "C" void n64ps5_on_vi(const n64ps5_frame* frame)
g.vi++;
if (g.vi == 1)
N64_STAGE(Emu, "running");
if (g.vi == 1 && g.resume_load)
{
g.resume_load = false;
const std::string path = ResumePath(g.file_base);
g.resume_loading = CoreDoCommand(M64CMD_STATE_LOAD, 0, (void*)path.c_str()) == M64ERR_SUCCESS;
OrbisLog("[emu] continuing where the player left off <- %s: %s", path.c_str(), g.resume_loading ? "queued" : "refused");
}
// leaving: the state is written (or the core never said so in 5 s): now the game stops
if (g.leaving && !g.stopping && (g.leave_saved || g.vi - g.leave_vi > 300))
{
if (!g.leave_saved)
OrbisLog("[emu] leaving: no word from the core about the state, stopping anyway");
StopGame(g.leave_reason);
}
const bool ff = g.fast_forward.load();
SetLimiter(g.pal && !ff);
@@ -539,15 +741,23 @@ extern "C" void n64ps5_on_vi(const n64ps5_frame* frame)
if (g.block_until_release && raw == 0)
{
g.block_until_release = false;
n64ps5_input_block(false);
if (!g.leaving)
n64ps5_input_block(false);
}
const uint32_t combo = SCE_PAD_BUTTON_L3 | SCE_PAD_BUTTON_R3;
const bool menu = (pressed & SCE_PAD_BUTTON_TOUCH_PAD) || ((raw & combo) == combo && (pressed & combo));
if (menu && !g.stopping)
// the touchpad opens the pause menu; R3 (pressed) saves to the state slot, L3 loads it
if (!g.stopping && !g.leaving)
{
OpenPauseMenu();
g.prev_buttons = ps5input::Pad(0).raw_buttons;
const int slot = fe::Config().state_slot;
if (pressed & SCE_PAD_BUTTON_TOUCH_PAD)
{
OpenPauseMenu();
g.prev_buttons = ps5input::Pad(0).raw_buttons;
}
else if (pressed & SCE_PAD_BUTTON_R3)
emu::Osd(emu::SaveState(slot) ? "Saved in slot %d" : "Could not save in slot %d", slot);
else if (pressed & SCE_PAD_BUTTON_L3)
emu::Osd(emu::StateExists(slot) && emu::LoadState(slot) ? "Loaded slot %d" : "Slot %d is empty", slot);
}
UpdateFps();
@@ -556,7 +766,7 @@ extern "C" void n64ps5_on_vi(const n64ps5_frame* frame)
ps5audio::Queued(), n64ps5_audio_target(),
(unsigned long long)ps5audio::Underruns());
if (frame && !g.stopping && (!ff || (g.vi % 4) == 0))
if (frame && !g.stopping && !g.leaving && (!ff || (g.vi % 4) == 0))
g_presenter.Submit(frame->pixels, frame->width, frame->height, frame->pitch, !ff && !g.pal);
// No picture: the game blanked the screen (VI off, e.g. Perfect Dark while it loads). A real N64 shows
@@ -570,7 +780,7 @@ extern "C" void n64ps5_on_vi(const n64ps5_frame* frame)
g.blank_vis / (g.pal ? 50.0 : 60.0));
g.blank_vis = 0;
}
else if (++g.blank_vis == kBlankVis && !g.stopping)
else if (++g.blank_vis == kBlankVis && !g.stopping && !g.leaving)
{
static const uint32_t kBlack[320 * 240] = {};
g_presenter.Submit(kBlack, 320, 240, 320, !ff && !g.pal);
@@ -650,8 +860,9 @@ void DeinitCore()
g.core_ok = false;
}
ExitReason RunGame(const std::string& path, std::string* error)
ExitReason RunGame(const std::string& path, std::string* error, bool resume)
{
g.game_layout = false; // Settings' until this game's is known
std::string dummy;
if (!error)
error = &dummy;
@@ -687,10 +898,11 @@ ExitReason RunGame(const std::string& path, std::string* error)
CoreDoCommand(M64CMD_ROM_GET_SETTINGS, sizeof(settings), &settings);
g.rom_path = path;
const size_t slash = path.find_last_of('/');
g.file_base = path.substr(slash == std::string::npos ? 0 : slash + 1);
if (const size_t d = g.file_base.find_last_of('.'); d != std::string::npos && d > 0)
g.file_base.resize(d);
g.file_base = FileBase(path);
g.own_layout = fe::LoadGameLayout(g.file_base, g.layout);
if (!g.own_layout)
g.layout = fe::Config().layout;
g.game_layout = true;
g.pal = IsPalCountry(header.Country_code);
// the title: as on the shelf (No-Intro name), else the catalog's GoodName, else the file name
uint32_t crc = 0;
@@ -708,12 +920,45 @@ ExitReason RunGame(const std::string& path, std::string* error)
// the CPU core: the dynarec when the setting asks for it and the console allows executable memory
const bool use_dynarec = cfg.dynarec && n64ps5_jit_probe() != 0;
m64p_handle core_cfg = nullptr;
// overclock: the core counts 2^n times fewer cycles per instruction, so the game gets more of them per frame
const int overclock = std::clamp(cfg.overclock, 0, 2);
if (ConfigOpenSection("Core", &core_cfg) == M64ERR_SUCCESS && core_cfg)
{
SetCoreInt(core_cfg, "R4300Emulator", use_dynarec ? 2 : 1);
OrbisLog("[emu] CPU: %s", use_dynarec ? "dynamic recompiler" : "cached interpreter");
SetCoreInt(core_cfg, "CountPerOpDenomPot", overclock);
}
OrbisLog("[emu] CPU: %s, overclock %s", use_dynarec ? "dynamic recompiler" : "cached interpreter",
overclock == 0 ? "off" : overclock == 1 ? "2x" : "4x");
n64ps5_rsp_set_hle(cfg.hle_audio);
static const char* const kPaks[] = {"none", "Controller Pak", "Rumble Pak"};
OrbisLog("[emu] controller pak: %s", kPaks[cfg.pak < 0 || cfg.pak > 2 ? 1 : cfg.pak]);
// cheats: the player's own, then the database's for this ROM (by the header's CRCs and country); the ones
// left on are on again
g.cheats_game.clear();
g.cheats = LoadCustomCheats();
const size_t mine = g.cheats.size();
for (fe::cheats::Cheat& c : fe::cheats::ForGame(__builtin_bswap32(header.CRC1), __builtin_bswap32(header.CRC2),
header.Country_code, &g.cheats_game))
g.cheats.push_back(std::move(c));
g.next_uid = 0;
for (fe::cheats::Cheat& c : g.cheats)
c.uid = ++g.next_uid;
if (mine)
OrbisLog("[cheats] %zu of your own", mine);
g.search.Clear();
LoadCheatStates();
std::string cheats_on;
for (size_t i = 0; i < g.cheats.size(); i++)
if (g.cheats[i].state)
{
ApplyCheat(int(i));
cheats_on += (cheats_on.empty() ? ": " : ", ") + fe::cheats::DisplayName(g.cheats[i].name);
}
if (!g.cheats.empty())
OrbisLog("[cheats] %zu for \"%s\"%s", g.cheats.size(), g.cheats_game.c_str(),
cheats_on.empty() ? ", none on" : cheats_on.c_str());
OrbisLog("[emu] controller layout: %s%s", g.own_layout ? "this game's own " : "",
g.layout == fe::Layout() ? "(the N64 pad's)" : fe::LayoutToText(g.layout).c_str());
LogSaves("at start", settings.goodname, settings.MD5);
const n64ps5_gfx_options gfx = {cfg.vi_filter ? 0 : 1, cfg.hide_overscan, cfg.render_threads, cfg.dp_compat,
cfg.upscale, cfg.gpu_sync};
@@ -762,6 +1007,10 @@ ExitReason RunGame(const std::string& path, std::string* error)
g.fast_forward.store(false);
g.fps_t0 = 0;
g.pending_save_slot = -1;
g.resume_load = resume && OrbisIsFile(ResumePath(g.file_base));
g.resume_loading = false;
g.leaving = false;
g.leave_saved = false;
g.fps_text[0] = 0;
ps5video::FillRect(0, 0, ps5video::kWidth, ps5video::kHeight, ps5video::Rgb(0, 0, 0));
ps5video::InvalidateN64();
@@ -782,7 +1031,12 @@ ExitReason RunGame(const std::string& path, std::string* error)
for (int i = 0; i < ps5input::kMaxPads; i++)
ps5input::SetRumble(i, 0, 0);
n64ps5_input_block(false);
if (g.leaving)
n64ps5_audio_set_mute(!fe::Config().audio);
g.leaving = false;
g.running = false;
g.game_layout = false;
g.search.Clear(); // its 10-20 MB
N64_STAGE(Emu, "idle");
if (r != M64ERR_SUCCESS && g.vi == 0)
{
@@ -792,11 +1046,154 @@ ExitReason RunGame(const std::string& path, std::string* error)
return g.exit;
}
bool ResumeStateExists(const std::string& rom, std::string* saved_at)
{
struct stat st = {};
if (stat(ResumePath(FileBase(rom)).c_str(), &st) != 0 || !S_ISREG(st.st_mode))
return false;
if (saved_at)
{
char t[48];
const time_t mt = st.st_mtime;
strftime(t, sizeof(t), "%b %d, %H:%M", localtime(&mt));
*saved_at = t;
}
return true;
}
bool GameLoaded()
{
return g.running;
}
std::vector<fe::cheats::Cheat>& Cheats()
{
return g.cheats;
}
std::string CheatsGameName()
{
return g.cheats_game;
}
void SetCheat(int index, int state)
{
if (!g.running || index < 0 || index >= int(g.cheats.size()))
return;
fe::cheats::Cheat& c = g.cheats[size_t(index)];
c.state = state < 0 || state >= c.States() ? 0 : state;
ApplyCheat(index);
SaveCheatStates();
OrbisLog("[cheats] %s: %s", fe::cheats::DisplayName(c.name).c_str(),
c.state == 0 ? "off" : c.options.empty() ? "on" : c.options[size_t(c.state - 1)].first.c_str());
}
std::string UniqueCheatName(const std::string& name, int except)
{
std::string base = name.empty() ? "My cheat" : name, out = base;
for (int n = 2;; n++)
{
bool taken = false;
for (size_t i = 0; i < g.cheats.size(); i++)
taken |= int(i) != except && g.cheats[i].name == out;
if (!taken)
return out;
out = base + " (" + std::to_string(n) + ")";
}
}
int AddMyCheat(const fe::cheats::Cheat& cheat)
{
if (!g.running)
return -1;
fe::cheats::Cheat c = cheat;
c.mine = true;
c.uid = ++g.next_uid;
c.name = UniqueCheatName(c.name, -1);
c.state = 1;
// after the player's other own cheats, before the database's
size_t at = 0;
while (at < g.cheats.size() && g.cheats[at].mine)
at++;
g.cheats.insert(g.cheats.begin() + long(at), c);
ApplyCheat(int(at));
SaveCustomCheats();
SaveCheatStates();
OrbisLog("[cheats] yours: \"%s\" (%s), on", c.name.c_str(), fe::cheats::CodesText(c).c_str());
return int(at);
}
void UpdateMyCheat(int index, const fe::cheats::Cheat& cheat)
{
if (!g.running || index < 0 || index >= int(g.cheats.size()) || !g.cheats[size_t(index)].mine)
return;
fe::cheats::Cheat& c = g.cheats[size_t(index)];
c.name = UniqueCheatName(cheat.name, index);
c.note = cheat.note;
c.addresses = cheat.addresses;
c.values = cheat.values;
c.options = cheat.options;
if (c.state >= c.States())
c.state = 1;
if (c.state)
ApplyCheat(index); // the new codes
SaveCustomCheats();
SaveCheatStates();
OrbisLog("[cheats] yours: \"%s\" changed (%s)", c.name.c_str(), fe::cheats::CodesText(c).c_str());
}
void DeleteMyCheat(int index)
{
if (!g.running || index < 0 || index >= int(g.cheats.size()) || !g.cheats[size_t(index)].mine)
return;
g.cheats[size_t(index)].state = 0;
ApplyCheat(index); // off: the core puts the old values back
OrbisLog("[cheats] yours: \"%s\" deleted", g.cheats[size_t(index)].name.c_str());
g.cheats.erase(g.cheats.begin() + index);
SaveCustomCheats();
SaveCheatStates();
}
fe::search::Search& CheatSearch()
{
return g.search;
}
const uint32_t* Ram(uint32_t* size)
{
*size = 0;
return g.running ? n64ps5_ram(size) : nullptr;
}
void PokeRam(uint32_t address, uint32_t value, int bytes)
{
if (g.running)
{
n64ps5_ram_write(address, value, bytes);
OrbisLog("[cheats] RAM %08X = %u (%d-bit)", unsigned(0x80000000u | address), unsigned(value), bytes * 8);
}
}
fe::Layout GameLayout()
{
return g.game_layout ? g.layout : fe::Config().layout;
}
bool GameHasOwnLayout()
{
return g.game_layout && g.own_layout;
}
void SetGameLayout(const fe::Layout& layout)
{
if (!g.game_layout)
return;
g.layout = layout;
g.own_layout = layout != fe::Config().layout;
fe::SaveGameLayout(g.file_base, g.own_layout ? &layout : nullptr);
ApplySettings();
}
std::string GameName()
{
return g.title;
@@ -806,8 +1203,14 @@ void ApplySettings()
{
const fe::Settings& cfg = fe::Config();
n64ps5_audio_set_mute(!cfg.audio);
n64ps5_audio_set_volume(cfg.volume);
n64ps5_audio_set_fast_forward(g.fast_forward.load());
const n64ps5_input_options in = {cfg.deadzone, 80, cfg.pak};
n64ps5_input_options in = {cfg.deadzone, 80, cfg.pak, cfg.invert_stick, cfg.invert_c_stick, {}};
const fe::Layout& layout = g.game_layout ? g.layout : cfg.layout;
memcpy(in.layout, layout.to, sizeof(in.layout));
// L3 and R3 load and save states (n64ps5_on_vi): the game doesn't get them
in.layout[N64PS5_PAD_L3] = N64PS5_N64_NONE;
in.layout[N64PS5_PAD_R3] = N64PS5_N64_NONE;
n64ps5_input_set_options(&in);
ps5video::InvalidateN64();
}
+32 -2
View File
@@ -2,6 +2,10 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "fe_cheats.h"
#include "fe_search.h"
#include "fe_settings.h"
#include <string>
namespace emu
@@ -16,10 +20,36 @@ enum class ExitReason
Quit, // "quit Mupen64Plus"
Error, // the game could not be started (see `error`)
};
// Opens the ROM and runs it until the player leaves it (blocks; the pause menu runs inside).
ExitReason RunGame(const std::string& path, std::string* error);
// Opens the ROM and runs it until the player leaves it (blocks; the pause menu runs inside). With `resume`, the
// game continues where the player left it (see ResumeStateExists).
ExitReason RunGame(const std::string& path, std::string* error, bool resume = false);
// "Continue where you left off" (Settings): leaving a game from the pause menu saves its state in
// states/<rom file>.resume. True if `rom` has one; `saved_at` gets when it was written.
bool ResumeStateExists(const std::string& rom, std::string* saved_at = nullptr);
bool GameLoaded();
// The running game's cheats (fe_cheats.h): the player's own first, then the database's (none when it has none
// for the game). Switching one (pause menu) applies it at once; what is on is remembered for the game in
// cheats/<ROM file>.txt.
std::vector<fe::cheats::Cheat>& Cheats();
std::string CheatsGameName();
void SetCheat(int index, int state);
// The player's own cheats, kept in cheats/<ROM file>.custom.txt: a new one (turned on; its index is returned),
// changed (its name, note and codes) or deleted. Names are made unique: "Lives" -> "Lives (2)".
int AddMyCheat(const fe::cheats::Cheat& cheat);
void UpdateMyCheat(int index, const fe::cheats::Cheat& cheat);
void DeleteMyCheat(int index);
std::string UniqueCheatName(const std::string& name, int except = -1);
// The cheat search (fe_search.h) of the running game, over its RAM (nullptr when no game runs). PokeRam writes a
// value once (the cheat search's "Set it now").
fe::search::Search& CheatSearch();
const uint32_t* Ram(uint32_t* size);
void PokeRam(uint32_t address, uint32_t value, int bytes);
// The running game's controller layout: its own (fe::LoadGameLayout) or else the one of Settings. Setting it
// (pause menu) gives the game its own layout file, unless it is the same as Settings' again.
fe::Layout GameLayout();
bool GameHasOwnLayout();
void SetGameLayout(const fe::Layout& layout);
std::string GameName(); // the title shown on the shelf (No-Intro name, ROM header name or file name)
// Settings from fe::Config() that apply while a game runs (aspect, smoothing, FPS, sound, dead zone).
+1564 -140
View File
File diff suppressed because it is too large. Load diff
+27 -10
View File
@@ -12,6 +12,7 @@
#include "ProsperoJailbreak.h"
#include "ProsperoNotify.h"
#include "ProsperoSce.h"
#include "ProsperoUpdateJob.h"
#include <cstdio>
#include <ctime>
@@ -52,6 +53,7 @@ bool WriteAtomic(const std::string& path, const std::vector<uint8_t>& data)
}
PrefetchResult g_last;
bool g_have_last = false;
int g_next_start = 0; // new games' covers this start couldn't fetch (CoversForNextStart)
} // namespace
void RememberPrefetch(const PrefetchResult& result)
@@ -73,16 +75,19 @@ void CoversRestartIfNeeded(const std::vector<GameInfo>& games, bool downloads_on
int fresh = 0;
for (const WantedCover& w : wanted)
fresh += g_last.attempted.count(w.file) ? 0 : 1;
g_next_start = 0;
if (wanted.empty() || (!force && fresh == 0))
return;
if (!g_have_last || !g_last.helper_ok)
{
g_next_start = fresh;
OrbisLog("[covers] %zu cover(s) wanted, but no helper answered at start: not restarting", wanted.size());
return;
}
if (!NetConnected())
{
OrbisLog("[covers] %zu cover(s) wanted; the console is offline: next start", wanted.size());
g_next_start = fresh;
return;
}
// A guard against a loop: no second restart within two minutes (one for Square: ten seconds).
@@ -96,6 +101,7 @@ void CoversRestartIfNeeded(const std::vector<GameInfo>& games, bool downloads_on
if (n == 1 && now - time_t(t) >= 0 && now - time_t(t) < (force ? 10 : 120))
{
OrbisLog("[covers] restarted %lld s ago: not again", (long long)(now - time_t(t)));
g_next_start = fresh;
return;
}
}
@@ -104,21 +110,32 @@ void CoversRestartIfNeeded(const std::vector<GameInfo>& games, bool downloads_on
fprintf(f, "%lld\n", (long long)now);
fclose(f);
}
const char* path = "/data/homebrew/" N64PS5_TITLE_ID "/eboot.bin";
if (access(path, F_OK) != 0)
path = "/app0/eboot.bin";
OrbisLog("[covers] %d new cover(s) to fetch (%zu wanted): restarting %s so the prefetch gets them", fresh,
wanted.size(), path);
// Closing and starting again: sceSystemServiceLoadExec(own eboot.bin) only closed the app on the console,
// so the helper starts it again once it has exited (ProsperoUpdateJob.h, RelaunchIfPending).
std::string why;
if (!updatejob::WriteRelaunch(int(getpid()), why) || !jailbreak::StartHelper())
{
unlink(updatejob::RelaunchPath().c_str());
OrbisLog("[covers] %d new cover(s) wanted, but the helper can't start the app again (%s): next start", fresh,
why.empty() ? "the ELF loader didn't answer" : why.c_str());
g_next_start = fresh;
return;
}
OrbisLog("[covers] %d new cover(s) to fetch (%zu wanted): closing, the helper starts the app again", fresh,
wanted.size());
if (show)
show("Downloading covers...");
show("Getting covers for your new games: the app restarts by itself...");
ProsperoNotifyFlush();
OrbisLogClose();
const int rc = sceSystemServiceLoadExec(path, nullptr);
if (rc == 0)
if (sceSystemServiceLoadExec("exit", nullptr) == 0)
for (int i = 0; i < 100; i++)
usleep(100 * 1000);
OrbisLogOpen("boot-after-restart");
OrbisLog("[covers] sceSystemServiceLoadExec(%s) -> %x: carrying on without the new covers", path, unsigned(rc));
_exit(0);
}
int CoversForNextStart()
{
return g_next_start;
}
bool NetConnected()
+6 -2
View File
@@ -53,8 +53,12 @@ void RememberPrefetch(const PrefetchResult& result);
// The shelf, after a scan (console only): writes covers/wanted.txt for `games`. When it holds covers this
// start didn't try (or `force`: Square on a game), the network is up and a helper answered, shows `text` with
// `show` and starts the app again (sceSystemServiceLoadExec, as PS5SX2 re-executes its eboot), so the
// prefetch fetches them. Returns only when it didn't restart.
// `show` and closes; the helper starts the app again once it has exited (ProsperoUpdateJob.h,
// RelaunchIfPending), so the prefetch fetches them. Returns only when it didn't close: then the covers wait
// for the next start (CoversForNextStart), and the app never closes without coming back.
void CoversRestartIfNeeded(const std::vector<GameInfo>& games, bool downloads_on, bool force,
void (*show)(const char* text));
// New games whose covers come at the next start (the last CoversRestartIfNeeded didn't close), for the shelf.
int CoversForNextStart();
} // namespace fe
+153
View File
@@ -0,0 +1,153 @@
// Mupen64Plus PS5 frontend: the cheat search (fe_search.h).
//
// 8 MiB of RAM is 8,388,608 8-bit values: a step over all of them takes a few milliseconds. The search keeps one
// bit per value still in it (1 MiB at most) and a copy of the RAM from the last step, and the same once more for
// Undo.
//
// SPDX-License-Identifier: MIT
#include "fe_search.h"
#include <cstring>
namespace fe
{
namespace search
{
uint32_t Read(const uint32_t* ram, uint32_t address, int bytes)
{
// the core keeps the RAM as host-order 32-bit words: byte n of the N64's memory is byte n ^ 3 of the array
const uint8_t* b = reinterpret_cast<const uint8_t*>(ram);
switch (bytes)
{
case 1: return b[address ^ 3];
case 2:
{
uint16_t v;
memcpy(&v, b + (address ^ 2), 2);
return v;
}
default: return ram[address >> 2];
}
}
uint32_t MaxValue(int bytes)
{
return bytes == 1 ? 0xFFu : bytes == 2 ? 0xFFFFu : 0xFFFFFFFFu;
}
std::string Thousands(uint64_t n)
{
std::string digits = std::to_string(n), out;
for (size_t i = 0; i < digits.size(); i++)
{
if (i && (digits.size() - i) % 3 == 0)
out += ',';
out += digits[i];
}
return out;
}
void Search::Start(const uint32_t* ram, uint32_t ram_size, int bytes)
{
Clear();
m_bytes = bytes == 2 || bytes == 4 ? bytes : 1;
m_size = ram_size & ~3u;
const uint64_t n = m_size / uint32_t(m_bytes);
m_bits.assign(size_t((n + 63) / 64), ~0ull);
if (n % 64)
m_bits.back() = (1ull << (n % 64)) - 1;
m_count = n;
m_before.assign(ram, ram + m_size / 4);
}
void Search::Filter(const uint32_t* ram, Test test, uint32_t number)
{
if (!Active())
return;
m_undo_bits = m_bits;
m_undo_before = m_before;
m_undo_count = m_count;
const uint32_t mask = MaxValue(m_bytes);
number &= mask;
const uint32_t* before = m_before.data();
uint64_t count = 0;
for (size_t w = 0; w < m_bits.size(); w++)
{
uint64_t bits = m_bits[w];
while (bits)
{
const int i = __builtin_ctzll(bits);
bits &= bits - 1;
const uint32_t address = uint32_t((w * 64 + size_t(i)) * size_t(m_bytes));
const uint32_t now = Read(ram, address, m_bytes), was = Read(before, address, m_bytes);
bool keep;
switch (test)
{
case Test::Equal: keep = now == number; break;
case Test::NotEqual: keep = now != number; break;
case Test::Greater: keep = now > number; break;
case Test::Less: keep = now < number; break;
case Test::Changed: keep = now != was; break;
case Test::Unchanged: keep = now == was; break;
case Test::Increased: keep = now > was; break;
case Test::Decreased: keep = now < was; break;
case Test::IncreasedBy: keep = ((now - was) & mask) == number && now != was; break;
case Test::DecreasedBy: keep = ((was - now) & mask) == number && now != was; break;
default: keep = true; break;
}
if (keep)
count++;
else
m_bits[w] &= ~(1ull << i);
}
}
m_count = count;
m_before.assign(ram, ram + m_size / 4);
m_steps++;
}
bool Search::Undo()
{
if (!CanUndo())
return false;
m_bits.swap(m_undo_bits);
m_before.swap(m_undo_before);
m_count = m_undo_count;
m_undo_bits.clear();
m_undo_before.clear();
m_steps = m_steps > 0 ? m_steps - 1 : 0;
return true;
}
void Search::Clear()
{
m_bytes = 0;
m_size = 0;
m_count = 0;
m_steps = 0;
std::vector<uint64_t>().swap(m_bits);
std::vector<uint32_t>().swap(m_before);
std::vector<uint64_t>().swap(m_undo_bits);
std::vector<uint32_t>().swap(m_undo_before);
m_undo_count = 0;
}
std::vector<Search::Result> Search::Results(size_t max) const
{
std::vector<Result> out;
for (size_t w = 0; w < m_bits.size() && out.size() < max; w++)
{
uint64_t bits = m_bits[w];
while (bits && out.size() < max)
{
const int i = __builtin_ctzll(bits);
bits &= bits - 1;
const uint32_t address = uint32_t((w * 64 + size_t(i)) * size_t(m_bytes));
out.push_back({address, Read(m_before.data(), address, m_bytes)});
}
}
return out;
}
} // namespace search
} // namespace fe
+76
View File
@@ -0,0 +1,76 @@
// Mupen64Plus PS5 frontend: the cheat search -- finding where a game keeps a value (lives, health, money) in the
// N64's RAM, by narrowing every address down to the ones that changed the way the value did.
//
// A search looks at every 1, 2 or 4-byte value of the RAM (8, 16 or 32-bit; at addresses of that alignment).
// Start() remembers the RAM; each Filter() keeps the addresses whose value passes the test -- against a number,
// or against what it was the last time -- and remembers the RAM again. Undo() goes one step back.
//
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace fe
{
namespace search
{
enum class Test
{
Equal, // == the number
NotEqual,
Greater, // > the number
Less,
Changed, // than the last time
Unchanged,
Increased,
Decreased,
IncreasedBy, // by the number
DecreasedBy,
};
// the value at N64 RAM address `address` (bytes 1, 2 or 4), from RAM in the core's layout (n64_bridge.h)
uint32_t Read(const uint32_t* ram, uint32_t address, int bytes);
// the biggest value of that size
uint32_t MaxValue(int bytes);
// 1234567 -> "1,234,567"
std::string Thousands(uint64_t n);
class Search
{
public:
bool Active() const { return m_bytes != 0; }
int Bytes() const { return m_bytes; }
uint64_t Count() const { return m_count; }
int Steps() const { return m_steps; } // filters since Start
bool CanUndo() const { return !m_undo_bits.empty(); }
// a new search over the RAM (ram_size bytes) for values of `bytes` bytes: every address is in it
void Start(const uint32_t* ram, uint32_t ram_size, int bytes);
// keeps the addresses that pass `test` (with `number` where the test has one); the RAM is remembered again
void Filter(const uint32_t* ram, Test test, uint32_t number);
bool Undo();
void Clear();
struct Result
{
uint32_t address; // in the RAM, 0..size-1
uint32_t before; // the value the last step saw
};
// the first `max` addresses still in the search, lowest first
std::vector<Result> Results(size_t max) const;
private:
int m_bytes = 0;
uint32_t m_size = 0;
uint64_t m_count = 0;
int m_steps = 0;
std::vector<uint64_t> m_bits; // one bit per address / bytes
std::vector<uint32_t> m_before; // the RAM at the last step
std::vector<uint64_t> m_undo_bits;
std::vector<uint32_t> m_undo_before;
uint64_t m_undo_count = 0;
};
} // namespace search
} // namespace fe
+111
View File
@@ -8,6 +8,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
namespace fe
{
@@ -22,8 +23,97 @@ int Clamp(int v, int lo, int hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
// the layout's names in the settings file and the per-game files
const char* const kPadKeys[N64PS5_PAD_COUNT] = {"cross", "circle", "square", "triangle", "l1", "r1", "l2", "r2",
"l3", "r3", "options", "up", "down", "left", "right", "rs_up", "rs_down", "rs_left", "rs_right"};
const char* const kN64Keys[N64PS5_N64_COUNT] = {"none", "a", "b", "z", "l", "r", "start", "c_up", "c_down",
"c_left", "c_right", "d_up", "d_down", "d_left", "d_right"};
std::string GameLayoutPath(const std::string& file_base)
{
return OrbisDir("layouts") + "/" + file_base + ".txt";
}
} // namespace
Layout::Layout()
{
n64ps5_input_default_layout(to);
}
bool Layout::operator==(const Layout& o) const
{
return memcmp(to, o.to, sizeof(to)) == 0;
}
std::string LayoutToText(const Layout& layout)
{
std::string s;
for (int i = 0; i < N64PS5_PAD_COUNT; i++)
{
if (i)
s += ',';
s += std::string(kPadKeys[i]) + '=' + kN64Keys[layout.to[i] < N64PS5_N64_COUNT ? layout.to[i] : 0];
}
return s;
}
Layout LayoutFromText(const std::string& text)
{
Layout layout;
size_t at = 0;
while (at < text.size())
{
size_t end = text.find(',', at);
if (end == std::string::npos)
end = text.size();
const std::string item = text.substr(at, end - at);
at = end + 1;
const size_t eq = item.find('=');
if (eq == std::string::npos)
continue;
const std::string pad = item.substr(0, eq), n64 = item.substr(eq + 1);
for (int i = 0; i < N64PS5_PAD_COUNT; i++)
if (pad == kPadKeys[i])
for (int t = 0; t < N64PS5_N64_COUNT; t++)
if (n64 == kN64Keys[t])
layout.to[i] = (unsigned char)t;
}
return layout;
}
bool LoadGameLayout(const std::string& file_base, Layout& out)
{
FILE* f = fopen(GameLayoutPath(file_base).c_str(), "r");
if (!f)
return false;
char line[1024] = "";
const bool ok = fgets(line, sizeof(line), f) != nullptr;
fclose(f);
if (!ok)
return false;
line[strcspn(line, "\r\n")] = 0;
out = LayoutFromText(line);
return true;
}
void SaveGameLayout(const std::string& file_base, const Layout* layout)
{
const std::string path = GameLayoutPath(file_base);
if (!layout)
{
unlink(path.c_str());
return;
}
OrbisMkdirs(OrbisDir("layouts"));
if (FILE* f = fopen(path.c_str(), "w"))
{
fprintf(f, "%s\n", LayoutToText(*layout).c_str());
fclose(f);
OrbisLog("[settings] %s: own controller layout", path.c_str());
}
}
Settings& Config()
{
static Settings s;
@@ -61,6 +151,8 @@ void Settings::Load()
dynarec = atoi(val) != 0;
else if (key == "hle_audio")
hle_audio = atoi(val) != 0;
else if (key == "overclock")
overclock = Clamp(atoi(val), 0, 2);
else if (key == "gpu")
gpu = atoi(val) != 0;
else if (key == "upscale")
@@ -74,12 +166,24 @@ void Settings::Load()
dp_compat = Clamp(atoi(val), 0, 2);
else if (key == "audio")
audio = atoi(val) != 0;
else if (key == "volume")
volume = Clamp(atoi(val), 0, 100);
else if (key == "pak")
pak = Clamp(atoi(val), 0, 2);
else if (key == "deadzone")
deadzone = Clamp(atoi(val), 0, 30);
else if (key == "invert_stick")
invert_stick = Clamp(atoi(val), 0, 3);
else if (key == "invert_c_stick")
invert_c_stick = Clamp(atoi(val), 0, 3);
else if (key == "layout")
layout = LayoutFromText(val);
else if (key == "game_view")
game_view = Clamp(atoi(val), 0, 2);
else if (key == "state_slot")
state_slot = Clamp(atoi(val), 0, 9);
else if (key == "resume")
resume = atoi(val) != 0;
else if (key == "covers_download")
covers_download = atoi(val) != 0;
else if (key == "updates")
@@ -111,14 +215,21 @@ void Settings::Save() const
fprintf(f, "render_threads=%d\n", render_threads);
fprintf(f, "dynarec=%d\n", dynarec ? 1 : 0);
fprintf(f, "hle_audio=%d\n", hle_audio ? 1 : 0);
fprintf(f, "overclock=%d\n", overclock);
fprintf(f, "gpu=%d\n", gpu ? 1 : 0);
fprintf(f, "upscale=%d\n", upscale);
fprintf(f, "gpu_sync=%d\n", gpu_sync ? 1 : 0);
fprintf(f, "dp_compat=%d\n", dp_compat);
fprintf(f, "audio=%d\n", audio ? 1 : 0);
fprintf(f, "volume=%d\n", volume);
fprintf(f, "pak=%d\n", pak);
fprintf(f, "deadzone=%d\n", deadzone);
fprintf(f, "invert_stick=%d\n", invert_stick);
fprintf(f, "invert_c_stick=%d\n", invert_c_stick);
fprintf(f, "layout=%s\n", LayoutToText(layout).c_str());
fprintf(f, "game_view=%d\n", game_view);
fprintf(f, "state_slot=%d\n", state_slot);
fprintf(f, "resume=%d\n", resume ? 1 : 0);
fprintf(f, "covers_download=%d\n", covers_download ? 1 : 0);
fprintf(f, "updates=%d\n", updates ? 1 : 0);
fprintf(f, "last_dir=%s\n", last_dir.c_str());
+26
View File
@@ -2,10 +2,29 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "n64_bridge.h"
#include <string>
namespace fe
{
// The controller layout: for each DualSense control (N64PS5_PAD_*), the N64 button it presses (N64PS5_N64_*).
// The default one is the N64 pad's layout by position (n64ps5_input_default_layout).
struct Layout
{
unsigned char to[N64PS5_PAD_COUNT];
Layout();
bool operator==(const Layout& o) const;
bool operator!=(const Layout& o) const { return !(*this == o); }
};
// "cross=a,circle=c_down,..."; names it doesn't know keep their default
std::string LayoutToText(const Layout& layout);
Layout LayoutFromText(const std::string& text);
// A game's own layout (pause menu): <root>/layouts/<ROM file name>.txt, by the ROM's file name like its states.
bool LoadGameLayout(const std::string& file_base, Layout& out);
// With nullptr the file goes and the game follows the layout of Settings.
void SaveGameLayout(const std::string& file_base, const Layout* layout);
struct Settings
{
// picture
@@ -17,17 +36,24 @@ struct Settings
// emulation
bool dynarec = true; // the x86-64 dynarec when the console gives us executable memory, else the interpreter
bool hle_audio = true; // audio tasks done by rsp-hle (fast), else by cxd4 running the game's microcode
int overclock = 0; // the N64 CPU's speed: 0 as on the console, 1 twice, 2 four times (CountPerOpDenomPot)
bool gpu = true; // the GPU renderer, paraLLEl-RDP (builds with Vulkan only), else angrylion on the CPU
int upscale = 4; // the GPU renderer's internal resolution: 1, 2, 4 or 8 times the N64's
bool gpu_sync = true; // wait for the GPU wherever the game waits for the RDP (else faster, may glitch)
int render_threads = 6; // angrylion workers (1 = single-threaded)
int dp_compat = 1; // angrylion sync points between its workers: 0 fewest (fastest), 1, 2 most (safest)
bool audio = true;
int volume = 100; // the game's sound, 0..100 %
// controller
int pak = 1; // 0 none, 1 Controller Pak, 2 Rumble Pak
int deadzone = 12; // % of the stick's travel
int invert_stick = 0; // the left (analog) stick's axes turned round: 0 off, 1 X, 2 Y, 3 X and Y
int invert_c_stick = 0; // the right stick (C buttons), the same way
Layout layout; // every game's, unless it has its own (LoadGameLayout)
// misc
int state_slot = 0; // 0..9
bool resume = true; // leaving a game saves where the player was; starting it again offers to continue there
int game_view = 0; // the game list: 0 shelf (3D cover flow), 1 grid, 2 list (the touchpad cycles them)
bool covers_download = true; // fetch box art from libretro-thumbnails
bool updates = true; // offer new GitHub releases at start-up (fe_update.h)
std::string last_dir; // the browser reopens here
+450 -58
View File
@@ -4,6 +4,7 @@
#include "fe_shelf.h"
#include "fe_covers.h"
#include "fe_emu.h"
#include "fe_games.h"
#include "fe_menu.h"
#include "fe_prefetch.h"
@@ -44,8 +45,9 @@ namespace
using ps5video::Rgb;
constexpr int W = ps5video::kWidth;
constexpr int H = ps5video::kHeight;
// Under the wordmark: what runs the games (the port's credits are in ps5/README.md).
constexpr const char* kTagline = "Nintendo 64 - mupen64plus, angrylion RDP, cxd4 RSP";
// Under the wordmark: what runs the games (the port's credits are in ps5/README.md). Short enough to end
// before the middle cover (x ~580): the renderer names are in Settings.
constexpr const char* kTagline = "Nintendo 64 - mupen64plus";
// ---- camera and layout (world units: the middle cover is kCoverH tall at distance kCamDist) ----------
constexpr float kFocal = 1000.0f;
@@ -173,12 +175,14 @@ struct Backdrop
{
std::vector<uint32_t> px; // W*H
const CoverTex* from = nullptr;
bool floor = true; // the shelf's floor and horizon (the grid and the list have none)
};
void BuildBackdrop(Pool& pool, Backdrop& bd, const CoverTex* tex)
void BuildBackdrop(Pool& pool, Backdrop& bd, const CoverTex* tex, bool floor)
{
bd.px.resize(size_t(W) * H);
bd.from = tex;
bd.floor = floor;
// 1. a 64x36 thumbnail: the cover's smallest level, scaled to fill (cropped), or a plain colour
constexpr int TW = 64, TH = 36;
float small[TH][TW][3];
@@ -248,7 +252,7 @@ void BuildBackdrop(Pool& pool, Backdrop& bd, const CoverTex* tex)
const float wy = std::clamp(fy - y0, 0.0f, 1.0f);
const float vy = (y - H * 0.45f) / (H * 0.75f);
float floor_k = 1.0f;
if (y > floor_y)
if (floor && y > floor_y)
floor_k = 0.78f - 0.25f * std::min(1.0f, (y - floor_y) / (H - floor_y));
uint32_t* row = &bd.px[size_t(y) * W];
for (int x = x0; x < x1; x++)
@@ -271,7 +275,7 @@ void BuildBackdrop(Pool& pool, Backdrop& bd, const CoverTex* tex)
});
// a faint horizon line on the floor
const int fy = int(floor_y);
for (int x = 0; x < W; x++)
for (int x = 0; x < W && floor; x++)
{
const float k = 1.0f - std::abs(x - W * 0.5f) / (W * 0.5f);
uint32_t& p = bd.px[size_t(fy) * W + x];
@@ -422,18 +426,15 @@ void DrawCover(const Placed& p, uint32_t* surf, int x0, int x1)
}
}
// The glow behind the middle cover, in the cover's average colour.
void DrawGlow(const Placed& p, uint32_t* surf, int x0, int x1, float strength)
// A glow of radius R around the rectangle l..r, top..bot, in colour col (reaching below * R under it);
// columns [x0, x1), rows [y0, y1).
void DrawGlowRect(float l, float r, float top, float bot, uint32_t col, float strength, float R, float below,
uint32_t* surf, int x0, int x1, int y0, int y1)
{
if (strength <= 0)
return;
const float top = kHorizon - kFocal * (p.wh - kCamY) / p.dist;
const float bot = kHorizon + kFocal * kCamY / p.dist;
const float l = float(p.sx0), r = float(p.sx1);
const float R = 70.0f;
const uint32_t col = p.tex->average;
const int gx0 = std::max(x0, int(l - R)), gx1 = std::min(x1, int(r + R));
const int gy0 = std::max(0, int(top - R)), gy1 = std::min(H, int(bot + R * 0.6f));
const int gy0 = std::max(y0, int(top - R)), gy1 = std::min(y1, int(bot + R * below));
for (int y = gy0; y < gy1; y++)
{
const float dy = std::max({top - y, 0.0f, y - bot});
@@ -457,6 +458,94 @@ void DrawGlow(const Placed& p, uint32_t* surf, int x0, int x1, float strength)
}
}
// The glow behind the shelf's middle cover, in the cover's average colour.
void DrawGlow(const Placed& p, uint32_t* surf, int x0, int x1, float strength)
{
const float top = kHorizon - kFocal * (p.wh - kCamY) / p.dist;
const float bot = kHorizon + kFocal * kCamY / p.dist;
DrawGlowRect(float(p.sx0), float(p.sx1), top, bot, p.tex->average, strength, 70.0f, 0.6f, surf, x0, x1, 0, H);
}
// ---- flat covers: the grid and the list ------------------------------------------------------------------
struct FlatRect
{
float x = 0, y = 0, w = 0, h = 0;
};
// The cover fitted into the box (bx, by, bw, bh), keeping its shape, centred.
FlatRect FitCover(const CoverTex& t, float bx, float by, float bw, float bh)
{
const float aspect = float(t.w[0]) / float(t.h[0]);
float w = bw, h = bw / aspect;
if (h > bh)
{
h = bh;
w = bh * aspect;
}
return {bx + (bw - w) * 0.5f, by + (bh - h) * 0.5f, w, h};
}
// Draws a cover into r, straight on (bilinear from the nearest mip level, anti-aliased edges): columns
// [x0, x1), rows [y0, y1).
void DrawFlat(const CoverTex& t, const FlatRect& r, uint32_t bright, uint32_t* surf, int x0, int x1, int y0, int y1)
{
const float scale = r.h / float(t.h[0]);
const int l = scale < 0.38f ? 2 : scale < 0.75f ? 1 : 0;
const int tw = t.w[l], th = t.h[l];
const uint32_t* px = t.px[l].data();
const int cx0 = std::max(x0, int(std::floor(r.x))), cx1 = std::min(x1, int(std::ceil(r.x + r.w)));
const int ry0 = std::max(std::max(y0, 0), int(std::floor(r.y)));
const int ry1 = std::min(std::min(y1, H), int(std::ceil(r.y + r.h)));
if (cx0 >= cx1 || ry0 >= ry1 || th < 2)
return;
const float du = tw / r.w, dv = th / r.h;
const int32_t dv16 = int32_t(dv * 65536.0f);
const int32_t vmax = (th - 1) << 16;
for (int x = cx0; x < cx1; x++)
{
const float cov_x = std::min(x + 1.0f, r.x + r.w) - std::max(float(x), r.x);
if (cov_x <= 0)
continue;
const float u = (x + 0.5f - r.x) * du - 0.5f;
const int u0 = std::clamp(int(std::floor(u)), 0, tw - 1), u1 = std::min(u0 + 1, tw - 1);
const uint32_t fu = uint32_t(std::clamp(u - std::floor(u), 0.0f, 1.0f) * 256);
const uint32_t* c0 = px + size_t(u0) * th;
const uint32_t* c1 = px + size_t(u1) * th;
int32_t v = int32_t(((ry0 + 0.5f - r.y) * dv - 0.5f) * 65536.0f);
uint32_t* d = surf + size_t(ry0) * W + x;
for (int y = ry0; y < ry1; y++, v += dv16, d += W)
{
float cov = cov_x;
if (y < r.y || y + 1 > r.y + r.h)
cov *= std::min(y + 1.0f, r.y + r.h) - std::max(float(y), r.y);
if (cov <= 0)
continue;
const int32_t vc = std::clamp(v, 0, vmax - 1);
const int ty = vc >> 16;
const uint32_t fv = uint32_t(vc >> 8) & 0xff;
const uint32_t s = Lerp32(Lerp32(c0[ty], c0[ty + 1], fv), Lerp32(c1[ty], c1[ty + 1], fv), fu);
const uint32_t c = bright >= 256 ? s : Scale32(s, bright);
const uint32_t a = uint32_t(std::min(cov, 1.0f) * 256);
*d = a >= 256 ? c : Lerp32(*d, c, a);
}
}
}
// A frame of thickness t around r, in col: columns [x0, x1), rows [y0, y1).
void DrawFrame(const FlatRect& r, int t, uint32_t col, uint32_t* surf, int x0, int x1, int y0, int y1)
{
const int l = int(std::floor(r.x)) - t, rr = int(std::ceil(r.x + r.w)) + t;
const int top = int(std::floor(r.y)) - t, bot = int(std::ceil(r.y + r.h)) + t;
for (int y = std::max({top, y0, 0}); y < std::min({bot, y1, H}); y++)
{
const bool edge_row = y < top + t || y >= bot - t;
uint32_t* row = surf + size_t(y) * W;
for (int x = std::max(l, x0); x < std::min(rr, x1); x++)
if (edge_row || x < l + t || x >= rr - t)
row[x] = col;
}
}
// ---- input: left/right with key repeat that speeds up -------------------------------------------------
struct Repeat
{
@@ -520,6 +609,41 @@ bool Confirm(const char* question)
}
}
// A game with a state saved when the player last left it: 1 continue there, 0 start over, -1 back to the list.
int AskResume(const std::string& saved_at)
{
std::vector<uint32_t> saved(ps5video::Surface(), ps5video::Surface() + size_t(W) * H);
ps5input::Poll();
uint32_t prev = ps5input::Pad(0).buttons;
ps5video::DarkenRect(0, 0, W, H);
const int bw = 1100, bh = 300, bx = (W - bw) / 2, by = (H - bh) / 2;
ps5video::FillRect(bx - 3, by - 3, bw + 6, bh + 6, Rgb(150, 125, 255));
ps5video::FillRect(bx, by, bw, bh, Rgb(26, 24, 48));
DrawText(bx + 50, by + 50, "Continue where you left off?", 5, Rgb(240, 240, 250));
DrawText(bx + 50, by + 135, ("Saved " + saved_at).c_str(), 3, Rgb(170, 160, 210));
DrawText(bx + 50, by + 215,
(std::string(icon::Cross) + " Continue " + icon::Square + " Start over " + icon::Circle + " Back").c_str(), 3,
Rgb(205, 200, 228));
ps5video::Present(0, 0, 0, 0, true);
for (;;)
{
ps5input::Poll();
const uint32_t cur = ps5input::Pad(0).buttons;
const uint32_t down = cur & ~prev;
prev = cur;
if (down & SCE_PAD_BUTTON_CROSS)
return 1;
if (down & SCE_PAD_BUTTON_SQUARE)
return 0;
if (down & (SCE_PAD_BUTTON_CIRCLE | SCE_PAD_BUTTON_OPTIONS))
{
memcpy(ps5video::Surface(), saved.data(), saved.size() * 4);
return -1;
}
ps5video::Present(0, 0, 1, 1, true);
}
}
// ---- state kept between visits to the shelf (the game list, its covers) -----------------------------------
struct ShelfState
{
@@ -570,6 +694,47 @@ void Loading(const char* text)
CenterText(H / 2 - 20, text, 4, Rgb(220, 220, 235));
ps5video::Present(0, 0, 0, 0, true);
}
// ---- the views of the game list: the 3D shelf, a grid, a list (setting "Game list view"; the touchpad
// cycles them) ---------------------------------------------------------------------------------------------
enum View
{
kViewShelf = 0,
kViewGrid = 1,
kViewList = 2,
kViewCount = 3
};
// grid: kGridCols x kGridRows cells, each with the box art fitted in, scrolling by rows
constexpr int kGridCols = 6, kGridRows = 3;
constexpr float kGridX = 150, kGridY = 215, kCellW = 270, kCellH = 205, kCellPad = 14;
// covers are drawn between these rows only (the next row doesn't peek in); the selection's glow a bit further
constexpr int kGridTop = 205, kGridBottom = 836;
constexpr int kGlowTop = 190, kGlowBottom = 852;
// list: kListRows titles with a small cover, the selected game's box art large on the right
constexpr int kListRows = 13;
constexpr int kListX = 100, kListY = 212, kListW = 1000, kListRowH = 56;
constexpr float kListArtX = 1170, kListArtY = 222, kListArtW = 650, kListArtH = 470;
const uint32_t kAccent = Rgb(150, 125, 255);
const uint32_t kSel = Rgb(78, 62, 170);
// "USA Z64 USB", as under the selection
std::string SubLine(const GameInfo& g, const CoverTex* tex)
{
std::string sub;
if (!g.region.empty())
sub = g.region + " ";
std::string ext = g.ext.size() > 1 ? g.ext.substr(1) : g.ext;
for (char& c : ext)
c = char(toupper(uint8_t(c)));
sub += ext;
if (g.on_usb)
sub += " USB";
if (tex && !tex->real)
sub += " no cover";
return sub;
}
} // namespace
void ShelfShutdown()
@@ -577,9 +742,11 @@ void ShelfShutdown()
S().covers.Stop();
}
std::string Shelf()
std::string Shelf(bool* resume)
{
Settings& cfg = Config();
if (resume)
*resume = false;
ShelfState& st = S();
N64_STAGE(Shelf, "pool");
Pool pool;
@@ -611,18 +778,22 @@ std::string Shelf()
for (int i = 0; i < n; i++)
if (st.games[size_t(i)].path == cfg.last_rom)
sel = i;
float pos = float(sel);
st.covers.SetFocus(sel);
float pos = float(sel); // shelf: the cover in the middle (gliding)
int grid_top = 0; // grid: the first row shown
float grid_pos = 0; // ... gliding
int list_top = 0; // list: the first title shown
int focus = -1; // where the cover service loads around
int last_view = -1;
Backdrop bd_cur, bd_old;
float fade = 1.0f; // 0..1: old -> cur
N64_STAGE(Shelf, "build backdrop");
BuildBackdrop(pool, bd_cur, nullptr);
BuildBackdrop(pool, bd_cur, nullptr, std::clamp(cfg.game_view, 0, kViewCount - 1) == kViewShelf);
N64_STAGE(Shelf, "first frame");
const CoverTex* bd_wanted = nullptr;
double sel_since = Now();
Repeat rep_l, rep_r, rep_l1, rep_r1;
Repeat rep_l, rep_r, rep_u, rep_d, rep_l1, rep_r1;
ps5input::Poll();
uint32_t prev = ps5input::Pad(0).buttons;
double last = Now();
@@ -635,6 +806,7 @@ std::string Shelf()
const double now = Now();
const float dt = float(std::min(0.05, now - last));
last = now;
const int view = std::clamp(cfg.game_view, 0, kViewCount - 1);
// -- input
ps5input::Poll();
@@ -642,28 +814,81 @@ std::string Shelf()
const uint32_t down = cur & ~prev;
prev = cur;
const int old_sel = sel;
if (n > 0)
{
if (rep_l.Fire(cur & SCE_PAD_BUTTON_LEFT, now))
sel = std::max(0, sel - 1);
if (rep_r.Fire(cur & SCE_PAD_BUTTON_RIGHT, now))
sel = std::min(n - 1, sel + 1);
if (rep_l1.Fire(cur & SCE_PAD_BUTTON_L1, now))
sel = std::max(0, sel - 10);
if (rep_r1.Fire(cur & SCE_PAD_BUTTON_R1, now))
sel = std::min(n - 1, sel + 10);
const bool left = rep_l.Fire(cur & SCE_PAD_BUTTON_LEFT, now);
const bool right = rep_r.Fire(cur & SCE_PAD_BUTTON_RIGHT, now);
const bool up = rep_u.Fire(cur & SCE_PAD_BUTTON_UP, now);
const bool dn = rep_d.Fire(cur & SCE_PAD_BUTTON_DOWN, now);
const bool l1 = rep_l1.Fire(cur & SCE_PAD_BUTTON_L1, now);
const bool r1 = rep_r1.Fire(cur & SCE_PAD_BUTTON_R1, now);
if (n > 0 && view == kViewShelf)
{
if (left)
sel = std::max(0, sel - 1);
if (right)
sel = std::min(n - 1, sel + 1);
if (l1)
sel = std::max(0, sel - 10);
if (r1)
sel = std::min(n - 1, sel + 10);
}
else if (n > 0 && view == kViewGrid)
{
const int page = kGridCols * kGridRows;
const int last_row = (n - 1) / kGridCols;
if (left)
sel = std::max(0, sel - 1);
if (right)
sel = std::min(n - 1, sel + 1);
if (up && sel >= kGridCols)
sel -= kGridCols;
if (dn && sel / kGridCols < last_row)
sel = std::min(n - 1, sel + kGridCols);
// a page up or down, in the same column where it can
if (l1)
sel = sel >= page ? sel - page : sel % kGridCols;
if (r1)
sel = sel + page <= n - 1 ? sel + page : std::max(sel, std::min(n - 1, last_row * kGridCols + sel % kGridCols));
}
else if (n > 0)
{
if (up)
sel = std::max(0, sel - 1);
if (dn)
sel = std::min(n - 1, sel + 1);
if (l1)
sel = std::max(0, sel - (kListRows - 1));
if (r1)
sel = std::min(n - 1, sel + (kListRows - 1));
}
}
if (sel != old_sel)
{
st.covers.SetFocus(sel);
sel_since = now;
dirty = true;
}
if ((down & SCE_PAD_BUTTON_CROSS) && n > 0)
{
cfg.last_rom = st.games[size_t(sel)].path;
const std::string path = st.games[size_t(sel)].path;
std::string saved_at;
const int choice = cfg.resume && emu::ResumeStateExists(path, &saved_at) ? AskResume(saved_at) : 0;
if (choice >= 0)
{
cfg.last_rom = path;
cfg.Save();
if (resume)
*resume = choice == 1;
return path;
}
ps5input::Poll();
prev = ps5input::Pad(0).buttons;
dirty = true;
}
if (down & SCE_PAD_BUTTON_TOUCH_PAD)
{
cfg.game_view = (view + 1) % kViewCount;
cfg.Save();
return st.games[size_t(sel)].path;
dirty = true;
}
if (down & SCE_PAD_BUTTON_TRIANGLE)
{
@@ -699,7 +924,52 @@ std::string Shelf()
dirty = true;
}
// -- covers that arrived
// -- scrolling: the grid's rows and the list's titles keep the selection in view
if (n > 0)
{
const int row = sel / kGridCols;
const int rows = (n + kGridCols - 1) / kGridCols;
if (row < grid_top)
grid_top = row;
if (row > grid_top + kGridRows - 1)
grid_top = row - kGridRows + 1;
grid_top = std::clamp(grid_top, 0, std::max(0, rows - kGridRows));
if (sel < list_top + 2)
list_top = sel - 2;
if (sel > list_top + kListRows - 3)
list_top = sel - kListRows + 3;
list_top = std::clamp(list_top, 0, std::max(0, n - kListRows));
}
const int new_view = std::clamp(cfg.game_view, 0, kViewCount - 1);
if (new_view != last_view)
{
// a new view: no glide into it, and the shelf's floor only behind the shelf
last_view = new_view;
pos = float(sel);
grid_pos = float(grid_top);
if (bd_cur.floor != (new_view == kViewShelf))
{
BuildBackdrop(pool, bd_cur, bd_cur.from, new_view == kViewShelf);
bd_old.px.clear();
fade = 1.0f;
}
dirty = true;
}
// -- covers: loaded around the selection (shelf) or the middle of what's on screen (grid, list)
if (n > 0)
{
int want = sel;
if (new_view == kViewGrid)
want = std::min(n - 1, grid_top * kGridCols + kGridCols * kGridRows / 2);
else if (new_view == kViewList)
want = std::min(n - 1, list_top + kListRows / 2);
if (want != focus)
{
focus = want;
st.covers.SetFocus(focus);
}
}
const size_t before = std::count_if(st.slots.begin(), st.slots.end(), [](const CoverPtr& c) { return c != nullptr; });
st.covers.Collect(st.slots, kKeepRadius);
const size_t after = std::count_if(st.slots.begin(), st.slots.end(), [](const CoverPtr& c) { return c != nullptr; });
@@ -715,6 +985,13 @@ std::string Shelf()
pos = target;
dirty = true;
}
if (std::abs(float(grid_top) - grid_pos) > 0.0005f)
{
grid_pos += (float(grid_top) - grid_pos) * (1.0f - std::exp(-dt * 14.0f));
if (std::abs(float(grid_top) - grid_pos) < 0.0005f)
grid_pos = float(grid_top);
dirty = true;
}
// -- backdrop follows the selection once it rests for a moment
const CoverTex* sel_tex = (n > 0 && st.slots[size_t(sel)]) ? st.slots[size_t(sel)].get() : nullptr;
@@ -722,7 +999,7 @@ std::string Shelf()
{
bd_wanted = sel_tex;
std::swap(bd_old, bd_cur);
BuildBackdrop(pool, bd_cur, sel_tex);
BuildBackdrop(pool, bd_cur, sel_tex, new_view == kViewShelf);
fade = bd_old.px.empty() ? 1.0f : 0.0f;
dirty = true;
}
@@ -747,21 +1024,71 @@ std::string Shelf()
}
dirty = false;
// -- place the covers, far ones first
std::vector<Placed> placed;
if (n > 0)
auto tex_of = [&](int i) -> const CoverTex* {
return st.slots[size_t(i)] ? st.slots[size_t(i)].get() : LoadingCard().get();
};
// -- lay out what this view shows
std::vector<Placed> placed; // shelf: far ones first
struct Flat
{
const CoverTex* tex;
FlatRect r;
bool selected;
};
std::vector<Flat> flats; // grid and list
if (n > 0 && new_view == kViewShelf)
{
const int lo = std::max(0, int(std::floor(pos - kVisible)));
const int hi = std::min(n - 1, int(std::ceil(pos + kVisible)));
for (int i = lo; i <= hi; i++)
{
const CoverTex* tex = st.slots[size_t(i)] ? st.slots[size_t(i)].get() : LoadingCard().get();
Placed p;
if (Place(p, tex, float(i) - pos))
if (Place(p, tex_of(i), float(i) - pos))
placed.push_back(p);
}
std::sort(placed.begin(), placed.end(), [](const Placed& a, const Placed& b) { return std::abs(a.d) > std::abs(b.d); });
}
else if (n > 0 && new_view == kViewGrid)
{
const int r0 = std::max(0, int(std::floor(grid_pos)) - 1);
const int r1 = int(std::ceil(grid_pos)) + kGridRows;
for (int row = r0; row <= r1; row++)
for (int c = 0; c < kGridCols; c++)
{
const int i = row * kGridCols + c;
if (i >= n)
break;
const float cy = kGridY + (float(row) - grid_pos) * kCellH;
if (cy + kCellH < kGridTop || cy > kGridBottom)
continue;
float bx = kGridX + c * kCellW + kCellPad, by = cy + kCellPad;
float bw = kCellW - 2 * kCellPad, bh = kCellH - 2 * kCellPad;
if (i == sel) // the selection stands out a little
{
bx -= bw * 0.04f;
by -= bh * 0.04f;
bw *= 1.08f;
bh *= 1.08f;
}
const CoverTex* tex = tex_of(i);
flats.push_back({tex, FitCover(*tex, bx, by, bw, bh), i == sel});
}
// the selection last, over its neighbours
std::stable_partition(flats.begin(), flats.end(), [](const Flat& f) { return !f.selected; });
}
else if (n > 0 && new_view == kViewList)
{
for (int k = 0; k < kListRows && list_top + k < n; k++)
{
const CoverTex* tex = tex_of(list_top + k);
const float ry = float(kListY + k * kListRowH);
flats.push_back({tex, FitCover(*tex, kListX + 14.0f, ry + 6, 80, kListRowH - 12.0f), false});
}
const CoverTex* tex = tex_of(sel);
flats.push_back({tex, FitCover(*tex, kListArtX, kListArtY, kListArtW, kListArtH), true});
}
uint32_t* surf = ps5video::Surface();
const uint32_t fade_w = uint32_t(fade * 256);
const double t_render = Now();
@@ -780,12 +1107,47 @@ std::string Shelf()
out[x] = Lerp32(o[x], a[x], fade_w);
}
}
// covers; the glow goes right before the middle one
for (const Placed& p : placed)
if (new_view == kViewShelf)
{
if (std::abs(p.d) < 0.5f)
DrawGlow(p, surf, x0, x1, 1.0f - std::abs(p.d) * 2.0f);
DrawCover(p, surf, x0, x1);
// covers; the glow goes right before the middle one
for (const Placed& p : placed)
{
if (std::abs(p.d) < 0.5f)
DrawGlow(p, surf, x0, x1, 1.0f - std::abs(p.d) * 2.0f);
DrawCover(p, surf, x0, x1);
}
}
else if (new_view == kViewGrid)
{
for (const Flat& f : flats)
{
if (f.selected)
DrawGlowRect(f.r.x, f.r.x + f.r.w, f.r.y, f.r.y + f.r.h, f.tex->average, 1.0f, 46.0f, 1.0f, surf, x0, x1,
kGlowTop, kGlowBottom);
DrawFlat(*f.tex, f.r, f.selected ? 256 : 200, surf, x0, x1, kGridTop, kGridBottom);
if (f.selected)
DrawFrame(f.r, 4, kAccent, surf, x0, x1, kGridTop, kGridBottom);
}
}
else
{
// a darker panel behind the titles, and the selected row in the menus' colour
const int py0 = kListY - 8, py1 = kListY + kListRows * kListRowH + 8;
const int px0 = std::max(x0, kListX), px1 = std::min(x1, kListX + kListW);
const int srow = sel - list_top;
for (int y = py0; y < py1 && px0 < px1; y++)
{
uint32_t* row = surf + size_t(y) * W;
const bool in_sel = n > 0 && y >= kListY + srow * kListRowH && y < kListY + (srow + 1) * kListRowH;
for (int x = px0; x < px1; x++)
row[x] = in_sel ? Lerp32(row[x], kSel, 215) : Scale32(row[x], 120);
}
for (const Flat& f : flats)
{
if (f.selected)
DrawGlowRect(f.r.x, f.r.x + f.r.w, f.r.y, f.r.y + f.r.h, f.tex->average, 0.9f, 60.0f, 1.0f, surf, x0, x1, 0, H);
DrawFlat(*f.tex, f.r, 256, surf, x0, x1, 0, H);
}
}
});
@@ -795,7 +1157,8 @@ std::string Shelf()
sum += Now() - t_render;
if (++frames == 60)
{
OrbisLog("[shelf] 3D pass: %.2f ms a frame (average of 60)", sum / frames * 1000.0);
OrbisLog("[shelf] %s pass: %.2f ms a frame (average of 60)", new_view == kViewShelf ? "3D" : "flat",
sum / frames * 1000.0);
sum = 0;
frames = 0;
}
@@ -812,19 +1175,41 @@ std::string Shelf()
snprintf(count, sizeof(count), "%d / %d", sel + 1, n);
DrawText(W - 80 - TextWidth(count, 4), 50, count, 4, Rgb(220, 220, 235));
const GameInfo& g = st.games[size_t(sel)];
CenterText(800, g.title, 6, Rgb(245, 245, 252));
std::string sub;
if (!g.region.empty())
sub = g.region + " ";
std::string ext = g.ext.size() > 1 ? g.ext.substr(1) : g.ext;
for (char& c : ext)
c = char(toupper(uint8_t(c)));
sub += ext;
if (g.on_usb)
sub += " USB";
if (sel_tex && !sel_tex->real)
sub += " no cover";
CenterText(880, sub, 3, Rgb(175, 170, 205));
if (new_view == kViewShelf)
{
CenterText(800, g.title, 6, Rgb(245, 245, 252));
CenterText(880, SubLine(g, sel_tex), 3, Rgb(175, 170, 205));
}
else if (new_view == kViewGrid)
{
CenterText(872, g.title, 5, Rgb(245, 245, 252));
CenterText(940, SubLine(g, sel_tex), 3, Rgb(175, 170, 205));
}
else
{
for (int k = 0; k < kListRows && list_top + k < n; k++)
{
const int i = list_top + k;
const std::string t = FitText(st.games[size_t(i)].title, 3, kListW - 150);
DrawText(kListX + 112, kListY + k * kListRowH + 13, t.c_str(), 3,
i == sel ? Rgb(250, 250, 255) : Rgb(200, 196, 224));
}
// a scroll bar when the list is longer than the panel
if (n > kListRows)
{
const int track = kListRows * kListRowH;
const int thumb = std::max(30, track * kListRows / n);
const int ty = kListY + (track - thumb) * list_top / (n - kListRows);
ps5video::FillRect(kListX + kListW - 14, kListY, 6, track, Rgb(40, 36, 70));
ps5video::FillRect(kListX + kListW - 14, ty, 6, thumb, kAccent);
}
const int tx = int(kListArtX);
const std::string title = FitText(g.title, 4, int(kListArtW));
DrawText(tx, int(kListArtY + kListArtH) + 28, title.c_str(), 4, Rgb(245, 245, 252));
DrawText(tx, int(kListArtY + kListArtH) + 82, SubLine(g, sel_tex).c_str(), 3, Rgb(175, 170, 205));
const std::string file = FitText(g.file_base + g.ext, 2, int(kListArtW));
DrawText(tx, int(kListArtY + kListArtH) + 126, file.c_str(), 2, Rgb(140, 136, 170));
}
}
else
{
@@ -845,12 +1230,19 @@ std::string Shelf()
const char* s = "Offline: covers next time";
DrawText(W - 80 - TextWidth(s, 3), 112, s, 3, Rgb(170, 160, 210));
}
else if (CoversForNextStart() > 0 && cfg.covers_download)
{
const char* s = "New games: covers next time you open the app";
DrawText(W - 80 - TextWidth(s, 3), 112, s, 3, Rgb(170, 160, 210));
}
// hints
ps5video::DarkenRect(0, H - 78, W, 78);
const std::string sp = " ";
const std::string browse = new_view == kViewShelf ? icon::DpadLeftRight : new_view == kViewGrid ? icon::Dpad : icon::DpadUpDown;
const std::string skip = new_view == kViewShelf ? " Skip 10" : " Page";
const std::string hint = n > 0
? std::string(icon::Cross) + " Play" + sp + icon::DpadLeftRight + " Browse" + sp + icon::L1 + " " + icon::R1 +
" Skip 10" + sp + icon::Triangle + " Settings" + sp + icon::Square + " Get cover" + sp +
? std::string(icon::Cross) + " Play" + sp + browse + " Browse" + sp + icon::L1 + " " + icon::R1 + skip + sp +
icon::Triangle + " Settings" + sp + icon::Square + " Get cover" + sp + icon::Touchpad + " View" + sp +
icon::Options + " Quit"
: std::string(icon::Triangle) + " Settings" + sp + icon::Options + " Quit";
DrawText((W - TextWidth(hint.c_str(), 3)) / 2, H - 60, hint.c_str(), 3, Rgb(205, 200, 228));
+5 -3
View File
@@ -1,4 +1,5 @@
// Mupen64Plus PS5 frontend: the game shelf -- a 3D cover flow of the library, like PS5SX2's.
// Mupen64Plus PS5 frontend: the game shelf -- a 3D cover flow of the library, like PS5SX2's. Setting "Game
// list view" (the touchpad cycles it) shows the library as a grid of covers or a list of titles instead.
//
// The selected game's box art stands in the middle, lit by a glow in its own colour, over a blurred
// copy of the same art; the rest of the library stands at an angle on both sides, in perspective, and every
@@ -16,8 +17,9 @@
namespace fe
{
// Returns the game to play, or "" when the player quits.
std::string Shelf();
// Returns the game to play, or "" when the player quits. `resume`: the player chose to continue where they
// left the game (emu::ResumeStateExists).
std::string Shelf(bool* resume = nullptr);
// Stops the cover downloads before the payload exits.
void ShelfShutdown();
} // namespace fe
+27 -10
View File
@@ -191,19 +191,19 @@ struct Target
int w, h, pitch;
};
void Draw(const Target& t, int x, int y, const char* utf8, int scale, uint32_t color, bool shadow)
// bold: the text again 1..bold pixels to the right (one weight of Roboto is built in)
void Draw(const Target& t, int x, int y, const char* utf8, int px, uint32_t color, bool shadow, int bold = 0)
{
Fonts& fonts = F();
if (!fonts.ok || !utf8)
return;
const int px = EmPx(scale);
const int baseline = y + int(std::lround(px * 0.80f));
const int sh = std::max(1, scale / 2); // shadow offset
for (int pass = shadow ? 0 : 1; pass < 2; pass++)
const int sh = std::max(1, px / 20); // shadow offset
for (int pass = shadow ? 0 : 1; pass < 2 + bold; pass++)
{
const uint32_t col = pass == 0 ? 0xff000000u : color;
const uint32_t strength = pass == 0 ? 150 : 255;
const int ox = pass == 0 ? sh : 0, oy = pass == 0 ? sh : 0;
const int ox = pass == 0 ? sh : pass - 1, oy = pass == 0 ? sh : 0;
float pen = float(x);
uint32_t prev = 0;
const char* s = utf8;
@@ -238,11 +238,15 @@ void Draw(const Target& t, int x, int y, const char* utf8, int scale, uint32_t c
} // namespace
int TextWidth(const char* utf8, int scale)
{
return TextWidthPx(utf8, EmPx(scale));
}
int TextWidthPx(const char* utf8, int px)
{
Fonts& fonts = F();
if (!fonts.ok || !utf8)
return 0;
const int px = EmPx(scale);
float pen = 0;
uint32_t prev = 0;
const char* s = utf8;
@@ -263,17 +267,30 @@ void DrawText(int x, int y, const char* utf8, int scale, uint32_t color, bool ou
uint32_t* surf = ps5video::Surface();
if (!surf)
return;
Draw(Target{surf, ps5video::kWidth, ps5video::kHeight, ps5video::kWidth}, x, y, utf8, scale, color, outline);
Draw(Target{surf, ps5video::kWidth, ps5video::kHeight, ps5video::kWidth}, x, y, utf8, EmPx(scale), color, outline);
}
void DrawTextPx(int x, int y, const char* utf8, int px, uint32_t color, bool outline, int bold)
{
uint32_t* surf = ps5video::Surface();
if (!surf)
return;
Draw(Target{surf, ps5video::kWidth, ps5video::kHeight, ps5video::kWidth}, x, y, utf8, px, color, outline, bold);
}
void DrawTextOn(const Canvas& canvas, int x, int y, const char* utf8, int scale, uint32_t color, bool outline)
{
Draw(Target{canvas.px, canvas.w, canvas.h, canvas.pitch}, x, y, utf8, scale, color, outline);
Draw(Target{canvas.px, canvas.w, canvas.h, canvas.pitch}, x, y, utf8, EmPx(scale), color, outline);
}
std::string FitText(const std::string& utf8, int scale, int max_px)
{
if (TextWidth(utf8.c_str(), scale) <= max_px)
return FitTextPx(utf8, EmPx(scale), max_px);
}
std::string FitTextPx(const std::string& utf8, int px, int max_px)
{
if (TextWidthPx(utf8.c_str(), px) <= max_px)
return utf8;
std::string s = utf8;
while (!s.empty())
@@ -283,7 +300,7 @@ std::string FitText(const std::string& utf8, int scale, int max_px)
n--;
s.resize(n);
const std::string t = s + "\xE2\x80\xA6"; // the ellipsis character, as PS5SX2
if (TextWidth(t.c_str(), scale) <= max_px)
if (TextWidthPx(t.c_str(), px) <= max_px)
return t;
}
return "\xE2\x80\xA6";
+8
View File
@@ -30,6 +30,12 @@ void DrawTextOn(const Canvas& canvas, int x, int y, const char* utf8, int scale,
// Cuts the string so it fits max_px (adds "...").
std::string FitText(const std::string& utf8, int scale, int max_px);
// The same with the line height in pixels (the em; the pause menu's sizes), and a bold made of the text drawn
// again `bold` pixels further right.
int TextWidthPx(const char* utf8, int px);
void DrawTextPx(int x, int y, const char* utf8, int px, uint32_t color, bool outline = false, int bold = 0);
std::string FitTextPx(const std::string& utf8, int px, int max_px);
// PromptFont's controller symbols (the same code points as PS5SX2's fe_text.h).
namespace icon
{
@@ -45,6 +51,8 @@ constexpr const char* L3 = "\xE2\x86\xBA";
constexpr const char* R3 = "\xE2\x86\xBB";
constexpr const char* DpadLeftRight = "\xE2\x86\xA2";
constexpr const char* DpadUpDown = "\xE2\x86\xA3";
constexpr const char* Dpad = "\xE2\x87\x8E"; // all four directions
constexpr const char* Touchpad = "\xE2\x87\xA7";
constexpr const char* Options = "\xE2\x88\x88";
constexpr const char* GitHub = "\xEF\x82\x9B"; // U+F09B, Font Awesome Brands
} // namespace icon
+3 -35
View File
@@ -47,7 +47,8 @@ namespace
{
constexpr size_t kMaxReleaseJson = 1u << 20;
constexpr size_t kMaxZip = 64u << 20;
constexpr size_t kMaxFile = 32u << 20;
// 0.6.3 to 0.7.2 refuse a file over 32 MiB: the Makefile keeps eboot.bin under that, so they can still update
constexpr size_t kMaxFile = 64u << 20;
const char* const kAsset = N64PS5_RELEASE_ZIP; // not PPSA99064.zip: see RELEASE_ZIP in the Makefile
// ---- a small JSON reader (the release object) --------------------------------------------------------------
@@ -454,39 +455,6 @@ bool ParseRelease(const std::string& body, Release& r, std::string& why)
}
// ---- installing ------------------------------------------------------------------------------------------
// Every copy of the app folder the console can see (ShadowMountPlus's scan paths).
std::vector<std::string> AppFolders()
{
std::vector<std::string> out;
std::vector<std::string> bases;
const char* hb = getenv("N64PS5_PS5_HOMEBREW"); // host tests
bases.push_back(hb && *hb ? hb : "/data/homebrew");
if (!(hb && *hb))
{
char buf[64];
for (int i = 0; i < 8; i++)
{
snprintf(buf, sizeof(buf), "/mnt/usb%d/homebrew", i);
bases.push_back(buf);
snprintf(buf, sizeof(buf), "/mnt/usb%d", i);
bases.push_back(buf);
}
for (int i = 0; i < 2; i++)
{
snprintf(buf, sizeof(buf), "/mnt/ext%d/homebrew", i);
bases.push_back(buf);
snprintf(buf, sizeof(buf), "/mnt/ext%d", i);
bases.push_back(buf);
}
}
for (const std::string& b : bases)
{
const std::string dir = b + "/" N64PS5_TITLE_ID;
if (OrbisIsFile(dir + "/sce_sys/param.json") && OrbisIsFile(dir + "/eboot.bin"))
out.push_back(dir);
}
return out;
}
// A path inside the zip we accept: under PPSA99064/, no "..", no absolute path or backslash.
bool SafeEntry(const std::string& name, std::string& rel)
@@ -747,7 +715,7 @@ void OfferUpdate(UpdateOffer& offer, bool ask)
std::vector<std::string> folders;
if (ok)
{
folders = AppFolders();
folders = OrbisAppFolders();
if (folders.empty())
{
ok = false;
+100
View File
@@ -6,6 +6,7 @@
// N64PS5_HOST_DUMP_DIR=dir where (default .)
// N64PS5_HOST_MAX_FLIPS=n abort after n flips (a hang guard)
// N64PS5_HOST_REALTIME=1 flips wait 1/60 s
// N64PS5_HOST_IME="text[|text...]" what is typed on the keyboard, in turn ("!" cancels)
//
// SPDX-License-Identifier: MIT
@@ -198,6 +199,77 @@ int sceKernelUsleep(unsigned int usec)
{
return usleep(usec);
}
// ---- libSceSysmodule (only the IME dialog loads) and libSceImeDialog, faked --------------------------------
// N64PS5_HOST_IME="text[|text...]": what the player types in each keyboard opened, in turn ("!" cancels it; with
// none left, it is canceled). The keyboard stays up for three status calls.
static std::vector<std::string> g_ime_texts;
static size_t g_ime_next = 0;
static int g_ime_polls = -1; // -1: not open
static bool g_ime_cancel = false;
static SceImeDialogParam g_ime_param;
int sceImeDialogInit(const SceImeDialogParam* p, const void*)
{
if (g_ime_polls >= 0)
return (int)0x80bc0001; // busy
static bool parsed = false;
if (!parsed)
{
parsed = true;
if (const char* s = getenv("N64PS5_HOST_IME"))
{
std::string str = s;
size_t pos = 0;
for (;;)
{
const size_t end = str.find('|', pos);
g_ime_texts.push_back(str.substr(pos, end == std::string::npos ? std::string::npos : end - pos));
if (end == std::string::npos)
break;
pos = end + 1;
}
}
}
g_ime_param = *p;
g_ime_polls = 0;
std::string title;
for (const uint16_t* t = p->title; t && *t; t++)
title += *t < 0x80 ? char(*t) : '?';
const std::string text = g_ime_next < g_ime_texts.size() ? g_ime_texts[g_ime_next++] : "!";
g_ime_cancel = text == "!";
if (!g_ime_cancel)
{
size_t i = 0;
for (; i < text.size() && i < p->maxTextLength; i++)
p->inputTextBuffer[i] = uint16_t((unsigned char)text[i]);
p->inputTextBuffer[i] = 0;
}
fprintf(stderr, "[host] keyboard \"%s\" (type %d, max %u): %s\n", title.c_str(), p->type, p->maxTextLength,
g_ime_cancel ? "canceled" : text.c_str());
return 0;
}
int sceImeDialogGetStatus(void)
{
if (g_ime_polls < 0)
return SCE_IME_DIALOG_STATUS_NONE;
return ++g_ime_polls > 3 ? SCE_IME_DIALOG_STATUS_FINISHED : SCE_IME_DIALOG_STATUS_RUNNING;
}
int sceImeDialogGetResult(SceImeDialogResult* r)
{
r->endstatus = g_ime_cancel ? SCE_IME_DIALOG_END_STATUS_USER_CANCELED : SCE_IME_DIALOG_END_STATUS_OK;
return 0;
}
int sceImeDialogTerm(void)
{
g_ime_polls = -1;
return 0;
}
int sceSysmoduleLoadModule(uint16_t id)
{
return id == SCE_SYSMODULE_IME_DIALOG ? 0 : (int)0x805a1000; // not found
}
int sceSystemServiceHideSplashScreen(void)
{
fprintf(stderr, "[host] splash screen hidden\n");
@@ -209,6 +281,34 @@ int sceSystemServiceLoadExec(const char* path, const char**)
fprintf(stderr, "[host] sceSystemServiceLoadExec(%s)\n", path ? path : "(null)");
return -1;
}
// Launching a title: logged; N64PS5_HOST_LAUNCH_FAILS=1 makes every call fail (no title ever comes up).
static bool g_launched = false;
static int HostLaunch(const char* what, const char* title_id, SceLncAppParam* param)
{
const char* fails = getenv("N64PS5_HOST_LAUNCH_FAILS");
const int rc = fails && atoi(fails) ? int(0x80940002u) : 0;
fprintf(stderr, "[host] %s(%s, user %d) -> 0x%08x\n", what, title_id ? title_id : "(null)",
param ? int(param->user_id) : -1, unsigned(rc));
g_launched = g_launched || rc == 0;
return rc;
}
int sceLncUtilLaunchApp(const char* title_id, const char*[], SceLncAppParam* param)
{
return HostLaunch("sceLncUtilLaunchApp", title_id, param);
}
int sceSystemServiceLaunchApp(const char* title_id, const char*[], SceLncAppParam* param)
{
return HostLaunch("sceSystemServiceLaunchApp", title_id, param);
}
uint32_t sceLncUtilGetAppIdOfRunningBigApp(void)
{
return g_launched ? 0x60000100u : 0xffffffffu;
}
int sceLncUtilGetAppTitleId(uint32_t, char* title_id)
{
strcpy(title_id, "PPSA99064");
return 0;
}
// N64PS5_HOST_VIDEO_BUSY=N: the first N opens find the screen held by another process (0x80290009)
int sceVideoOutOpen(int, int, int, const void*)
+2 -1
View File
@@ -86,8 +86,9 @@ int main()
g_ready_message = msg;
#endif
// an update the app staged before closing (ProsperoUpdateJob.h)
// an update the app staged before closing, or the app to start again (ProsperoUpdateJob.h)
updatejob::RunIfPending();
updatejob::RelaunchIfPending();
// the helper: runs until the console is turned off, or returns at once when one already runs
if (!jailbreak::ServeHelper(OnReady))
+29
View File
@@ -0,0 +1,29 @@
// Mupen64Plus PS5: the N64's RAM for the cheat search (n64_bridge.h).
// SPDX-License-Identifier: MIT
#include "n64_bridge.h"
#include "device/device.h"
#include "main/main.h"
#include "osal/preproc.h"
const uint32_t* n64ps5_ram(uint32_t* size)
{
*size = (uint32_t)g_dev.rdram.dram_size;
return g_dev.rdram.dram;
}
void n64ps5_ram_write(uint32_t address, uint32_t value, int bytes)
{
address &= 0x00ffffff;
if (address + (uint32_t)bytes > g_dev.rdram.dram_size)
return;
uint8_t* dram = (uint8_t*)g_dev.rdram.dram;
switch (bytes)
{
case 1: dram[address ^ S8] = (uint8_t)value; break;
case 2: *(uint16_t*)(dram + (address ^ S16)) = (uint16_t)value; break;
default: *(uint32_t*)(dram + address) = value; break;
}
invalidate_r4300_cached_code(&g_dev.r4300, 0x80000000u | address, (size_t)bytes);
}
+63
View File
@@ -52,24 +52,87 @@ void n64ps5_rsp_set_hle(bool on);
// ---- audio (plugins/audio_ps5.cpp) ---------------------------------------------------------------------
void n64ps5_audio_set_mute(bool mute);
// Settings "Volume": the game's sound scaled to 0..100 percent.
void n64ps5_audio_set_volume(int percent);
// Fast forward: the audio plugin drops what doesn't fit instead of slowing the game down.
void n64ps5_audio_set_fast_forward(bool on);
int n64ps5_audio_game_rate(void); // the game's current output rate in Hz (0 before it sets one)
int n64ps5_audio_target(void); // the AudioOut ring level the rate control aims at (48 kHz frames)
// ---- input (plugins/input_ps5.cpp) ---------------------------------------------------------------------
// The controller layout (Settings / pause menu "Controller layout"): for each DualSense control, the N64
// button it presses. Several controls may press the same one (L2 and R2 are both Z by default). The left
// stick is always the N64's analog stick.
enum
{
N64PS5_PAD_CROSS,
N64PS5_PAD_CIRCLE,
N64PS5_PAD_SQUARE,
N64PS5_PAD_TRIANGLE,
N64PS5_PAD_L1,
N64PS5_PAD_R1,
N64PS5_PAD_L2,
N64PS5_PAD_R2,
N64PS5_PAD_L3,
N64PS5_PAD_R3,
N64PS5_PAD_OPTIONS,
N64PS5_PAD_UP,
N64PS5_PAD_DOWN,
N64PS5_PAD_LEFT,
N64PS5_PAD_RIGHT,
N64PS5_PAD_RS_UP, // the right stick tilted (half way)
N64PS5_PAD_RS_DOWN,
N64PS5_PAD_RS_LEFT,
N64PS5_PAD_RS_RIGHT,
N64PS5_PAD_COUNT
};
enum
{
N64PS5_N64_NONE,
N64PS5_N64_A,
N64PS5_N64_B,
N64PS5_N64_Z,
N64PS5_N64_L,
N64PS5_N64_R,
N64PS5_N64_START,
N64PS5_N64_C_UP,
N64PS5_N64_C_DOWN,
N64PS5_N64_C_LEFT,
N64PS5_N64_C_RIGHT,
N64PS5_N64_D_UP,
N64PS5_N64_D_DOWN,
N64PS5_N64_D_LEFT,
N64PS5_N64_D_RIGHT,
N64PS5_N64_COUNT
};
typedef struct
{
int deadzone; // percent of the stick's travel ignored around the centre (0..30)
int range; // N64 stick range at full tilt (the real stick reaches about 80)
int pak; // 0 none, 1 Controller Pak (memory), 2 Rumble Pak
int invert; // the analog stick's (left stick's) axes turned round: bit 0 X, bit 1 Y
int invert_c; // the right stick turned round: bit 0 X (left/right), bit 1 Y (up/down)
unsigned char layout[N64PS5_PAD_COUNT]; // N64PS5_N64_* for each N64PS5_PAD_*
} n64ps5_input_options;
// The layout of the N64 pad by position: Cross A, Square B, L2 / R2 Z, L1 / R1 L / R, OPTIONS Start, D-pad
// D-pad, right stick and Triangle / Circle the C buttons.
void n64ps5_input_default_layout(unsigned char layout[N64PS5_PAD_COUNT]);
void n64ps5_input_set_options(const n64ps5_input_options* opt);
// While the frontend uses the pad itself (a hotkey held, the pause menu just closed), the game sees no
// buttons.
void n64ps5_input_block(bool block);
// ---- the N64's RAM (core/memory_ps5.c), for the cheat search ---------------------------------------------
// The RDRAM as the core keeps it: 32-bit words in the host's byte order (byte n of the N64's memory is byte
// n ^ 3 here). `size` gets its size, 4 or 8 MiB (the Expansion Pak).
const uint32_t* n64ps5_ram(uint32_t* size);
// Writes `value` (`bytes` 1, 2 or 4, at an address of that alignment) the way a cheat code does, so recompiled
// code there is dropped too.
void n64ps5_ram_write(uint32_t address, uint32_t value, int bytes);
#ifdef __cplusplus
}
#endif
Binary file not shown.
+9 -4
View File
@@ -48,7 +48,7 @@ m64p_system_type g_system = SYSTEM_NTSC;
std::atomic<bool> g_mute{false};
std::atomic<bool> g_fast_forward{false};
std::atomic<int> g_rate{0};
int g_volume = 100;
std::atomic<int> g_volume{100};
// resampler state
double g_pos = 0.0; // position between g_prev and the next input sample
@@ -211,7 +211,7 @@ void AiLenChanged()
g_out.resize(size_t(max_out) * 2);
int16_t* out = g_out.data();
int n = 0;
const int vol = g_volume;
const int vol = g_volume.load(std::memory_order_relaxed);
// Linear interpolation between g_prev (position 0) and each next sample (position 1).
for (int i = 0; i < in_frames; i++)
{
@@ -262,7 +262,7 @@ void VolumeDown()
int VolumeGetLevel()
{
return g_mute.load() ? 0 : g_volume;
return g_mute.load() ? 0 : g_volume.load();
}
void VolumeSetLevel(int level)
@@ -276,7 +276,7 @@ const char* VolumeGetString()
if (g_mute.load())
snprintf(text, sizeof(text), "Mute");
else
snprintf(text, sizeof(text), "%d%%", g_volume);
snprintf(text, sizeof(text), "%d%%", g_volume.load());
return text;
}
@@ -309,6 +309,11 @@ extern "C" void n64ps5_audio_set_mute(bool mute)
g_mute.store(mute);
}
extern "C" void n64ps5_audio_set_volume(int percent)
{
g_volume.store(percent < 0 ? 0 : percent > 100 ? 100 : percent);
}
extern "C" void n64ps5_audio_set_fast_forward(bool on)
{
g_fast_forward.store(on);
+98 -2
View File
@@ -29,6 +29,7 @@ extern "C" {
#include "perf.h"
#include "static_dynlib.h"
#include <chrono>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
@@ -107,6 +108,88 @@ void LoadPipelineCache(Vulkan::Device& device)
ok ? "" : " (not usable, starting a new one)");
}
// The cache shipped with the app: assets/parallel-rdp-pipelines.bin in the app folder (n64/plugins/assets/ in the
// source), a console's own cache file. Every PS5 has the same GPU and runs the same RADV, built into eboot.bin,
// so its pipelines spare new players the first-time compiles (a short pause the first time a scene appears). It
// is merged into the player's cache once per shipped file -- cache/parallel-rdp-pipelines.seed remembers which --
// and saved with it. A console whose driver differs (another GPU) is told apart by the cache's UUID and keeps its
// own cache only. A file next to eboot.bin, not inside it: 0.6.3 to 0.7.2 can't update to an eboot.bin over 32 MiB.
std::string SeedMarkerPath()
{
return OrbisDir("cache") + "/parallel-rdp-pipelines.seed";
}
std::string ShippedPipelineCachePath()
{
std::vector<std::string> dirs = {"/app0"}; // the running app, where the sandbox mount is still visible
for (const std::string& d : OrbisAppFolders())
dirs.push_back(d);
for (const std::string& d : dirs)
{
const std::string path = d + "/assets/parallel-rdp-pipelines.bin";
if (OrbisIsFile(path))
return path;
}
return "";
}
// True when the shipped cache was merged now (the caller then saves the result).
bool MergeShippedPipelineCache(Vulkan::Device& device)
{
const std::string path = ShippedPipelineCachePath();
if (path.empty())
{
OrbisLog("[gpu] no pipeline cache shipped with the app (assets/parallel-rdp-pipelines.bin)");
return false;
}
std::vector<uint8_t> data;
if (FILE* f = fopen(path.c_str(), "rb"))
{
fseek(f, 0, SEEK_END);
const long size = ftell(f);
fseek(f, 0, SEEK_SET);
if (size > 24)
{
data.resize(size_t(size));
if (fread(data.data(), 1, data.size(), f) != data.size())
data.clear();
}
fclose(f);
}
if (data.empty())
return false;
// which shipped cache this is: the hash Granite writes after the driver's 16-byte UUID, and the size
uint64_t hash = 0;
memcpy(&hash, data.data() + 16, sizeof(hash));
char id[48];
snprintf(id, sizeof(id), "%016llx-%zu", (unsigned long long)hash, data.size());
char done[48] = "";
if (FILE* f = fopen(SeedMarkerPath().c_str(), "r"))
{
if (!fgets(done, sizeof(done), f))
done[0] = 0;
fclose(f);
}
if (strcmp(id, done) == 0)
{
OrbisLog("[gpu] pipeline cache shipped with the app (%s): merged before", path.c_str());
return false;
}
const auto t0 = std::chrono::steady_clock::now();
const bool ok = device.merge_pipeline_cache(data.data(), data.size());
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
OrbisLog("[gpu] pipeline cache shipped with the app (%s, %zu KiB): %s in %.0f ms", path.c_str(), data.size() >> 10,
ok ? "merged" : "made by another driver or GPU, not used", ms);
OrbisMkdirs(OrbisDir("cache"));
if (FILE* f = fopen(SeedMarkerPath().c_str(), "w"))
{
fputs(id, f);
fclose(f);
chmod(SeedMarkerPath().c_str(), 0666);
}
return ok;
}
void SavePipelineCache()
{
if (!g_gpu.device)
@@ -420,9 +503,13 @@ void UpdateScreen()
opts.vi.dither_filter = filtered;
opts.vi.gamma_dither = filtered;
opts.vi.scale = true;
opts.blend_previous_frame = true;
opts.upscale_deinterlacing = false;
// interlaced pictures (Indiana Jones, Resident Evil 2, hi-res modes): each field upscaled on its own with
// the field's line offset ("bob"), as RetroArch does. Weave (upscale_deinterlacing off, which needs
// blend_previous_frame) combed everything that moves (GitHub issue #2); paraLLEl-RDP keeps it for tests.
opts.blend_previous_frame = false;
opts.upscale_deinterlacing = true;
opts.crop_overscan_pixels = l_Options.hide_overscan ? 8 : 0;
const bool interlaced = (*l_Gfx.VI_STATUS_REG & RDP::VI_CONTROL_SERRATE_BIT) != 0;
l_Rdp->scanout_async_buffer(l_Scanout, opts);
if (l_Scanout.width && l_Scanout.height && l_Scanout.fence)
@@ -434,6 +521,12 @@ void UpdateScreen()
g_gpu.device->map_host_buffer(*l_Scanout.buffer, Vulkan::MEMORY_ACCESS_READ_BIT));
n64ps5_perf_add(N64PS5_PERF_VI, n64ps5_perf_now() - t0);
const n64ps5_frame frame = {px, int(l_Scanout.width), int(l_Scanout.height), int(l_Scanout.width)};
static int s_logged = -1; // the last picture mode logged: 0 progressive, 1 interlaced
if (s_logged != int(interlaced))
{
s_logged = int(interlaced);
OrbisLog("[gpu] picture: %s, %dx%d", interlaced ? "interlaced" : "progressive", frame.width, frame.height);
}
n64ps5_on_vi(&frame);
g_gpu.device->unmap_host_buffer(*l_Scanout.buffer, Vulkan::MEMORY_ACCESS_READ_BIT);
}
@@ -555,8 +648,11 @@ extern "C" bool n64ps5_gpu_available(void)
return false;
}
LoadPipelineCache(*device);
const bool merged = MergeShippedPipelineCache(*device);
g_gpu.context = std::move(context);
g_gpu.device = std::move(device);
if (merged)
SavePipelineCache();
OrbisLog("[gpu] Vulkan device ready for paraLLEl-RDP");
return true;
}
+85 -20
View File
@@ -1,13 +1,15 @@
// Mupen64Plus PS5: the input plugin, DualSense / DualShock through libScePad (ProsperoInput.h).
//
// The frontend polls the pads once per video interrupt (fe_emu.cpp); GetKeys turns that state into the
// N64 controller's buttons and stick. Mapping (by position, as on the N64 pad):
// N64 controller's buttons and stick. The left stick is the analog stick; every other control presses the
// N64 button the controller layout gives it (n64ps5_input_options.layout, per game). The default layout, by
// position as on the N64 pad:
//
// left stick -> analog stick D-pad -> D-pad
// Cross -> A Square -> B
// L2 or R2 -> Z L1 / R1 -> L / R
// right stick -> C buttons Triangle -> C-up, Circle -> C-down
// OPTIONS -> Start
// OPTIONS -> Start L3, R3 -> nothing
//
// Rumble: the core sends Rumble Pak writes through ControllerCommand; they drive the controller's motors.
//
@@ -33,7 +35,8 @@ constexpr int kNumControllers = 4;
bool g_init = false;
CONTROL* g_controls = nullptr;
n64ps5_input_options g_opt = {12, 80, 1};
n64ps5_input_options g_opt = {12, 80, 1, 0, 0, {}};
bool g_opt_set = false;
std::atomic<bool> g_block{false};
// One stick axis (0..255, centre 128) -> -range..range, with a dead zone and a linear ramp after it.
@@ -95,6 +98,8 @@ int PakPlugin()
void InitiateControllers(CONTROL_INFO info)
{
if (!g_opt_set) // before the frontend's first options: the default layout
n64ps5_input_default_layout(g_opt.layout);
g_controls = info.Controls;
if (!g_controls)
return;
@@ -122,25 +127,55 @@ void GetKeys(int control, BUTTONS* keys)
return;
const uint32_t b = p.raw_buttons; // the D-pad only (the stick is the analog stick here)
keys->R_DPAD = (b & SCE_PAD_BUTTON_RIGHT) != 0;
keys->L_DPAD = (b & SCE_PAD_BUTTON_LEFT) != 0;
keys->D_DPAD = (b & SCE_PAD_BUTTON_DOWN) != 0;
keys->U_DPAD = (b & SCE_PAD_BUTTON_UP) != 0;
keys->START_BUTTON = (b & SCE_PAD_BUTTON_OPTIONS) != 0;
keys->Z_TRIG = (b & (SCE_PAD_BUTTON_L2 | SCE_PAD_BUTTON_R2)) != 0;
keys->B_BUTTON = (b & SCE_PAD_BUTTON_SQUARE) != 0;
keys->A_BUTTON = (b & SCE_PAD_BUTTON_CROSS) != 0;
keys->L_TRIG = (b & SCE_PAD_BUTTON_L1) != 0;
keys->R_TRIG = (b & SCE_PAD_BUTTON_R1) != 0;
// what each DualSense control is doing; the right stick counts at half tilt. Settings "Invert left stick"
// and "Invert right stick" (GitHub issue #3) turn the sticks' axes round.
bool on[N64PS5_PAD_COUNT] = {};
on[N64PS5_PAD_CROSS] = (b & SCE_PAD_BUTTON_CROSS) != 0;
on[N64PS5_PAD_CIRCLE] = (b & SCE_PAD_BUTTON_CIRCLE) != 0;
on[N64PS5_PAD_SQUARE] = (b & SCE_PAD_BUTTON_SQUARE) != 0;
on[N64PS5_PAD_TRIANGLE] = (b & SCE_PAD_BUTTON_TRIANGLE) != 0;
on[N64PS5_PAD_L1] = (b & SCE_PAD_BUTTON_L1) != 0;
on[N64PS5_PAD_R1] = (b & SCE_PAD_BUTTON_R1) != 0;
on[N64PS5_PAD_L2] = (b & SCE_PAD_BUTTON_L2) != 0;
on[N64PS5_PAD_R2] = (b & SCE_PAD_BUTTON_R2) != 0;
on[N64PS5_PAD_L3] = (b & SCE_PAD_BUTTON_L3) != 0;
on[N64PS5_PAD_R3] = (b & SCE_PAD_BUTTON_R3) != 0;
on[N64PS5_PAD_OPTIONS] = (b & SCE_PAD_BUTTON_OPTIONS) != 0;
on[N64PS5_PAD_UP] = (b & SCE_PAD_BUTTON_UP) != 0;
on[N64PS5_PAD_DOWN] = (b & SCE_PAD_BUTTON_DOWN) != 0;
on[N64PS5_PAD_LEFT] = (b & SCE_PAD_BUTTON_LEFT) != 0;
on[N64PS5_PAD_RIGHT] = (b & SCE_PAD_BUTTON_RIGHT) != 0;
const bool rs_right = p.rx > 192, rs_left = p.rx < 64, rs_down = p.ry > 192, rs_up = p.ry < 64;
const bool rs_flip_x = (g_opt.invert_c & 1) != 0, rs_flip_y = (g_opt.invert_c & 2) != 0;
on[N64PS5_PAD_RS_RIGHT] = rs_flip_x ? rs_left : rs_right;
on[N64PS5_PAD_RS_LEFT] = rs_flip_x ? rs_right : rs_left;
on[N64PS5_PAD_RS_DOWN] = rs_flip_y ? rs_up : rs_down;
on[N64PS5_PAD_RS_UP] = rs_flip_y ? rs_down : rs_up;
// C buttons: the right stick (half tilt), plus Triangle / Circle
keys->R_CBUTTON = p.rx > 192;
keys->L_CBUTTON = p.rx < 64;
keys->D_CBUTTON = p.ry > 192 || (b & SCE_PAD_BUTTON_CIRCLE) != 0;
keys->U_CBUTTON = p.ry < 64 || (b & SCE_PAD_BUTTON_TRIANGLE) != 0;
// and what they press on the N64 pad
bool n64[N64PS5_N64_COUNT] = {};
for (int i = 0; i < N64PS5_PAD_COUNT; i++)
if (on[i] && g_opt.layout[i] < N64PS5_N64_COUNT)
n64[g_opt.layout[i]] = true;
keys->A_BUTTON = n64[N64PS5_N64_A];
keys->B_BUTTON = n64[N64PS5_N64_B];
keys->Z_TRIG = n64[N64PS5_N64_Z];
keys->L_TRIG = n64[N64PS5_N64_L];
keys->R_TRIG = n64[N64PS5_N64_R];
keys->START_BUTTON = n64[N64PS5_N64_START];
keys->U_CBUTTON = n64[N64PS5_N64_C_UP];
keys->D_CBUTTON = n64[N64PS5_N64_C_DOWN];
keys->L_CBUTTON = n64[N64PS5_N64_C_LEFT];
keys->R_CBUTTON = n64[N64PS5_N64_C_RIGHT];
keys->U_DPAD = n64[N64PS5_N64_D_UP];
keys->D_DPAD = n64[N64PS5_N64_D_DOWN];
keys->L_DPAD = n64[N64PS5_N64_D_LEFT];
keys->R_DPAD = n64[N64PS5_N64_D_RIGHT];
keys->X_AXIS = Axis(p.lx, g_opt.deadzone, g_opt.range);
keys->Y_AXIS = -Axis(p.ly, g_opt.deadzone, g_opt.range); // the N64's Y grows upwards
const int x = Axis(p.lx, g_opt.deadzone, g_opt.range);
const int y = -Axis(p.ly, g_opt.deadzone, g_opt.range); // the N64's Y grows upwards
keys->X_AXIS = (g_opt.invert & 1) ? -x : x;
keys->Y_AXIS = (g_opt.invert & 2) ? -y : y;
}
// Raw joybus commands for this controller. Only the Rumble Pak's motor write is looked at:
@@ -209,7 +244,37 @@ extern "C" const m64ps5_library m64ps5_input_lib = {"ps5-dualsense", kSymbols};
extern "C" void n64ps5_input_set_options(const n64ps5_input_options* opt)
{
if (opt)
{
g_opt = *opt;
g_opt_set = true;
}
}
extern "C" void n64ps5_input_default_layout(unsigned char layout[N64PS5_PAD_COUNT])
{
static const unsigned char kDefault[N64PS5_PAD_COUNT] = {
N64PS5_N64_A, // Cross
N64PS5_N64_C_DOWN, // Circle
N64PS5_N64_B, // Square
N64PS5_N64_C_UP, // Triangle
N64PS5_N64_L, // L1
N64PS5_N64_R, // R1
N64PS5_N64_Z, // L2
N64PS5_N64_Z, // R2
N64PS5_N64_NONE, // L3
N64PS5_N64_NONE, // R3
N64PS5_N64_START, // OPTIONS
N64PS5_N64_D_UP,
N64PS5_N64_D_DOWN,
N64PS5_N64_D_LEFT,
N64PS5_N64_D_RIGHT,
N64PS5_N64_C_UP, // right stick
N64PS5_N64_C_DOWN,
N64PS5_N64_C_LEFT,
N64PS5_N64_C_RIGHT,
};
for (int i = 0; i < N64PS5_PAD_COUNT; i++)
layout[i] = kDefault[i];
}
extern "C" void n64ps5_input_block(bool block)
@@ -1351,8 +1351,13 @@ Vulkan::ImageHandle VideoInterface::scanout(VkImageLayout target_layout, const S
divot_image = std::move(aa_image);
// Scale pass
bool is_final_pass = !downscale_steps || scaling_factor <= 1;
bool serrate = (regs.status & VI_CONTROL_SERRATE_BIT) != 0;
// Mupen64Plus PS5: upscale_deinterlace (each field offset by its quarter line) used to run only after a
// downscale pass, so without downscaling (downscale_steps 0) interlaced pictures jumped half a line every
// frame (Indiana Jones' title screen, GitHub issue #2). It now runs whenever bob deinterlacing applies.
bool deinterlace_pass = serrate && options.upscale_deinterlacing;
bool downscale = downscale_steps && scaling_factor > 1;
bool is_final_pass = !downscale && !deinterlace_pass;
auto scale_image = scale_stage(*cmd, divot_image.get(),
regs, lines,
@@ -1361,7 +1366,7 @@ Vulkan::ImageHandle VideoInterface::scanout(VkImageLayout target_layout, const S
auto src_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
if (!is_final_pass && scale_image)
if (downscale && scale_image)
{
cmd->image_barrier(*scale_image, src_layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
layout_to_stage(src_layout), layout_to_access(src_layout),
+35
View File
@@ -752,6 +752,41 @@ bool Device::init_pipeline_cache(const uint8_t *data, size_t size)
return table->vkCreatePipelineCache(device, &info, nullptr, &pipeline_cache) == VK_SUCCESS;
}
// Mupen64Plus PS5: the same checks as init_pipeline_cache, then vkMergePipelineCaches into the current cache.
bool Device::merge_pipeline_cache(const uint8_t *data, size_t size)
{
static const auto uuid_size = sizeof(gpu_props.pipelineCacheUUID);
static const auto hash_size = sizeof(Util::Hash);
if (pipeline_cache == VK_NULL_HANDLE || !data || size <= uuid_size + hash_size)
return false;
if (memcmp(data, gpu_props.pipelineCacheUUID, uuid_size) != 0)
{
LOGI("Pipeline cache to merge is from another driver or GPU.\n");
return false;
}
Util::Hash reference_hash;
memcpy(&reference_hash, data + uuid_size, sizeof(reference_hash));
VkPipelineCacheCreateInfo info = { VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO };
info.initialDataSize = size - uuid_size - hash_size;
info.pInitialData = data + uuid_size + hash_size;
Util::Hasher h;
h.data(static_cast<const uint8_t *>(info.pInitialData), info.initialDataSize);
if (h.get() != reference_hash)
{
LOGW("Pipeline cache to merge is corrupt.\n");
return false;
}
VkPipelineCache other = VK_NULL_HANDLE;
if (table->vkCreatePipelineCache(device, &info, nullptr, &other) != VK_SUCCESS)
return false;
const bool ok = table->vkMergePipelineCaches(device, pipeline_cache, 1, &other) == VK_SUCCESS;
table->vkDestroyPipelineCache(device, other, nullptr);
return ok;
}
void Device::init_pipeline_cache()
{
#ifdef GRANITE_VULKAN_SYSTEM_HANDLES
+3
View File
@@ -242,6 +242,9 @@ public:
size_t get_pipeline_cache_size();
bool get_pipeline_cache_data(uint8_t *data, size_t size);
bool init_pipeline_cache(const uint8_t *data, size_t size);
// Mupen64Plus PS5: adds a saved cache (as get_pipeline_cache_data writes it) to the current one.
// False if it was written by another driver or GPU, or is damaged.
bool merge_pipeline_cache(const uint8_t *data, size_t size);
// Frame-pushing interface.
void next_frame_context();
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -5,7 +5,8 @@
# The BC7 encoder is a small one: every 4x4 block in mode 6 (two RGBA endpoints, 4-bit indices), endpoints
# from the block's colour bounding box. Plenty for a smooth background.
#
# make_art.py <out-dir> [font.ttf bold.ttf] (needs Pillow and numpy)
# make_art.py <out-dir> [font.ttf bold.ttf] draws its own (needs Pillow and numpy)
# make_art.py <out-dir> --images <icon> <bg> from pictures (app/art/icon.png, app/art/background.png)
#
# SPDX-License-Identifier: MIT
import os
@@ -13,7 +14,7 @@ import struct
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps
ACCENT = (255, 196, 64)
BLUE = (64, 140, 255)
@@ -145,11 +146,17 @@ def dds_bc7(width, height, data):
def main():
out = sys.argv[1]
black = sys.argv[2] if len(sys.argv) > 2 else "C:/Windows/Fonts/seguibl.ttf"
bold = sys.argv[3] if len(sys.argv) > 3 else "C:/Windows/Fonts/segoeuib.ttf"
os.makedirs(out, exist_ok=True)
make_icon(black, bold).save(os.path.join(out, "icon0.png"), optimize=True)
bg = make_background(black, bold)
if len(sys.argv) > 4 and sys.argv[2] == "--images":
# pictures made elsewhere (app/art/): fitted to the icon's and the background's sizes
ImageOps.fit(Image.open(sys.argv[3]).convert("RGB"), (512, 512), Image.LANCZOS).save(
os.path.join(out, "icon0.png"), optimize=True)
bg = ImageOps.fit(Image.open(sys.argv[4]).convert("RGB"), (3840, 2160), Image.LANCZOS)
else:
black = sys.argv[2] if len(sys.argv) > 2 else "C:/Windows/Fonts/seguibl.ttf"
bold = sys.argv[3] if len(sys.argv) > 3 else "C:/Windows/Fonts/segoeuib.ttf"
make_icon(black, bold).save(os.path.join(out, "icon0.png"), optimize=True)
bg = make_background(black, bold)
bg.save(os.path.join(out, "background-source.png"), optimize=True)
dds = dds_bc7(bg.width, bg.height, bc7_encode(bg))
for name in ("pic0.dds", "pic1.dds"):
+81 -2
View File
@@ -7,7 +7,13 @@
# No-Intro style ones: dump tags ([!] [b1] [t1] ...) dropped, country codes spelled out ((U) -> (USA)),
# (V1.1) -> (Rev 1). It is not a full No-Intro table; files already named the No-Intro way are used as is.
#
# make_n64db.py mupen64plus.ini out.tsv
# GoodN64 names have no language tags and spell some titles differently, so a converted name often isn't
# libretro-thumbnails' file name ("Diddy Kong Racing (USA) (Rev 1)" is "... (USA) (En,Fr) (Rev 1)" there,
# and every such cover download was a 404). With a list of those file names (tools/libretro-n64-boxarts.txt)
# each name is matched against it, ignoring language tags, case and punctuation, and takes its spelling.
#
# make_n64db.py mupen64plus.ini out.tsv [boxarts.txt]
# make_n64db.py --fetch-boxarts boxarts.txt refresh the list (GitHub's file list of the repository)
#
# SPDX-License-Identifier: MIT
import re
@@ -36,6 +42,68 @@ def convert(good):
return re.sub(r"\s+", " ", name).strip()
# ---- libretro-thumbnails' names ------------------------------------------------------------------------------
BOXARTS_API = "https://api.github.com/repos/libretro-thumbnails/Nintendo_-_Nintendo_64/git/trees/master?recursive=1"
# characters a thumbnail's file name has as "_" (fe_covers.cpp ThumbnailName)
UNSAFE = '&*/:`<>?\\|"'
LANG = re.compile(r"\s*\((?:[A-Z][a-z](?:-[A-Z][a-z]+)?)(?:,[A-Z][a-z](?:-[A-Z][a-z]+)?)*\)") # (En,Fr) (Fr,De,Nl)
# GoodN64's own language tags: (Ita-Spa) (Eng-Spa-Fre-Ger)
GOOD_LANG = re.compile(r"\s*\((?:Eng|Fre|Ger|Spa|Ita|Dut|Swe|Jap|Por|Dan|Nor|Fin)(?:-(?:Eng|Fre|Ger|Spa|Ita|Dut|Swe|Jap|Por|Dan|Nor|Fin))*\)")
def fetch_boxarts(dst):
import datetime
import json
import urllib.request
req = urllib.request.Request(BOXARTS_API, headers={"User-Agent": "mupen64plus-ps5 make_n64db.py"})
tree = json.load(urllib.request.urlopen(req, timeout=60))
if tree.get("truncated"):
sys.exit("GitHub's file list came back truncated")
names = sorted(e["path"][len("Named_Boxarts/"):-4] for e in tree["tree"]
if e["path"].startswith("Named_Boxarts/") and e["path"].endswith(".png"))
with open(dst, "w", encoding="utf-8", newline="\n") as out:
out.write("# Named_Boxarts of github.com/libretro-thumbnails/Nintendo_-_Nintendo_64 (master, %s), for "
"tools/make_n64db.py\n" % datetime.date.today().isoformat())
out.write("".join(n + "\n" for n in names))
print("%s: %d box art names" % (dst, len(names)))
def thumb(name):
return "".join("_" if c in UNSAFE else c for c in name)
def loose(name):
# the same game whatever the language tags, case, punctuation or a (Z64) dump tag
n = LANG.sub("", GOOD_LANG.sub("", thumb(name)))
n = re.sub(r"\s*\((?:Z64|V64)\)", "", n)
n = n.lower().replace("_", " ")
n = re.sub(r"[^a-z0-9()]+", " ", n)
return re.sub(r"\s+", " ", n).strip()
class Boxarts:
def __init__(self, path):
names = [l.rstrip("\n") for l in open(path, encoding="utf-8") if l.strip() and not l.startswith("#")]
self.exact = set(names)
self.by_key = {}
for n in sorted(names, key=len): # the plainest variant first
self.by_key.setdefault(loose(n), n)
def match(self, name):
if thumb(name) in self.exact:
return name
found = self.by_key.get(loose(name))
if not found:
return name
# keep our readable characters where the file name has "_" ("Command & Conquer")
ours = [c for c in name if c in UNSAFE]
if found.count("_") == len(ours):
it = iter(ours)
found = "".join(next(it) if c == "_" else c for c in found)
return found
def rank(good):
# verified dumps first, then plain names, then bad/overdumps/hacks/trainers/translations
if "[!]" in good:
@@ -46,7 +114,11 @@ def rank(good):
def main():
if len(sys.argv) == 3 and sys.argv[1] == "--fetch-boxarts":
fetch_boxarts(sys.argv[2])
return
src, dst = sys.argv[1], sys.argv[2]
boxarts = Boxarts(sys.argv[3]) if len(sys.argv) > 3 else None
best = {}
good = crc = None
with open(src, encoding="latin-1") as f:
@@ -63,11 +135,18 @@ def main():
if crc not in best or r < best[crc][0]:
best[crc] = (r, convert(good))
good = crc = None
renamed = 0
if boxarts:
for crc, (r, name) in best.items():
m = boxarts.match(name)
if m != name:
best[crc] = (r, m)
renamed += 1
with open(dst, "w", encoding="utf-8", newline="\n") as out:
out.write("# CRC1\tname (generated by tools/make_n64db.py from mupen64plus.ini)\n")
for crc in sorted(best):
out.write("%s\t%s\n" % (crc, best[crc][1]))
print("%s: %d games" % (dst, len(best)))
print("%s: %d games, %d named as in libretro-thumbnails" % (dst, len(best), renamed))
if __name__ == "__main__":
+16 -11
View File
@@ -3,10 +3,11 @@
# 1. builds the app from scratch (build-native.bat Clean): build-native\PPSA99064\ and Mupen64PlusPS5.zip. Always
# clean: 0.6.1 was published from an incremental build that didn't start on the console;
# 2. checks that everything is committed and pushed, that release-notes\<contentVersion>.md exists and that
# the tag isn't taken;
# 3. creates the GitHub release: tag = contentVersion (NN.NNN.NNN, from VERSION in ps5\Makefile), asset
# Mupen64PlusPS5.zip, notes from release-notes\<contentVersion>.md (the app shows them when it offers the
# update);
# the version isn't released yet (under either tag style);
# 3. creates the GitHub release: tag = the version as people write it (0.7.4, VERSION in ps5\Makefile; up to
# 0.7.3 the tag was the contentVersion, 00.007.003), asset Mupen64PlusPS5.zip, notes from
# release-notes\<contentVersion>.md (the app shows them when it offers the update). Every app since 0.6.0
# reads both tag styles as numbers (fe_update.cpp ParseVersion), so they compare correctly;
# 4. reads the release back and compares GitHub's SHA-256 of the asset with the local zip (the app checks
# the download against that digest).
#
@@ -42,23 +43,27 @@ try {
$ahead = git rev-list --count origin/main..HEAD
if ([int]$ahead -gt 0) { throw "push your commits first (git push): $ahead not on GitHub" }
$tag = $version
# "release not found" on stderr is the expected answer: PowerShell 5.1 would make it fatal under 'Stop'
$ErrorActionPreference = 'Continue'
& $gh release view $cv --repo $repo 2>&1 | Out-Null
$exists = $LASTEXITCODE -eq 0
$exists = $false
foreach ($t in @($tag, $cv)) {
& $gh release view $t --repo $repo 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $exists = $true }
}
$ErrorActionPreference = 'Stop'
if ($exists) { throw "release $cv already exists: raise VERSION in ps5\Makefile" }
if ($exists) { throw "release $version already exists: raise VERSION in ps5\Makefile" }
Write-Host "Publishing Mupen64Plus PS5 $version (tag $cv) to $repo"
& $gh release create $cv $zip --repo $repo --target main --title "Mupen64Plus PS5 $version" --notes-file $notes
Write-Host "Publishing Mupen64Plus PS5 $version (tag $tag) to $repo"
& $gh release create $tag $zip --repo $repo --target main --title "Mupen64Plus PS5 $version" --notes-file $notes
if ($LASTEXITCODE -ne 0) { throw 'gh release create failed' }
$local = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
# parsed here: PowerShell 5.1 strips the quotes a --jq filter would need
$release = (& $gh api "repos/$repo/releases/tags/$cv") -join "`n" | ConvertFrom-Json
$release = (& $gh api "repos/$repo/releases/tags/$tag") -join "`n" | ConvertFrom-Json
$remote = ($release.assets | Where-Object { $_.name -eq $asset }).digest
if ($remote -ne "sha256:$local") { throw "GitHub's digest '$remote' doesn't match the local zip (sha256:$local)" }
Write-Host "Done: https://github.com/$repo/releases/tag/$cv (sha256 $local)"
Write-Host "Done: https://github.com/$repo/releases/tag/$tag (sha256 $local)"
Write-Host 'Consoles on 0.6.3 or newer will offer it the next time Mupen64Plus PS5 starts.'
}
finally { Pop-Location }
+3
View File
@@ -0,0 +1,3 @@
## Fixes
- GPU renderer: interlaced pictures (Indiana Jones and the Infernal Machine, high-resolution modes) no longer show comb-like lines on anything moving, and the picture no longer jitters or flickers at the top and bottom.
- The line under the title on the game list was out of date and ran behind the covers.
+7
View File
@@ -0,0 +1,7 @@
## New
- Game list views: besides the 3D shelf, a grid of covers or a list of titles with the cover beside it. Press the touchpad on the game list to switch (also Settings, Game list view).
- Settings: Invert left stick and Invert right stick (X, Y, or both).
## Fixes
- Covers for over 160 more games download now (Diddy Kong Racing, F-Zero X, the European versions of many games...): the app asked for them under the wrong name.
- After you add games, the app reopens by itself once it has closed to fetch their covers. Before, it only closed.
+8
View File
@@ -0,0 +1,8 @@
## New
- Continue where you left off: leaving a game from the pause menu saves where you were. The next time you start that game, the app asks whether to continue there (Cross), start over (Square) or go back (Circle). Settings, Continue where you left off, turns it off.
- Settings: CPU overclock (Off, 2x, 4x). Games that slowed down on the N64, like GoldenEye, Perfect Dark and Conker, run smoother. A few games misbehave with it, so it is off by default.
- ROM hacks bigger than an N64 cartridge (64 MB) load now, up to 252 MB, for example ECW Born to be Wired.
- The app comes with the GPU renderer's shaders already compiled, so most scenes no longer pause briefly the first time they appear.
## Fixes
- When a game can't start because its file is empty or too big, the message now says so, with the file's size.
+7
View File
@@ -0,0 +1,7 @@
## New
- A new icon and background on the home screen.
- A new look for the pause menu and Settings: one panel with switches, a volume slider and icons. In the pause menu, the touchpad resumes, Square resets the game (it asks first) and Triangle goes back to the game list; Save State, Load State and Quit are at the top and bottom of the list.
- Quick save and load: in a game, press R3 (the right stick) to save in the current state slot, and L3 (the left stick) to load it. The pause menu now opens with the touchpad only.
- Cheats: a Cheats page in the pause menu with the GameShark codes of mupen64plus's cheat list (about 560 game versions: infinite lives, unlock everything and more). Switch each one on or off, or pick one of its choices; the game remembers them.
- Controller layout (Settings): choose what each DualSense button does on the N64 controller. Cross, Circle, Square, Triangle, L1, R1, L2 and R2 (separately), OPTIONS, the D-pad and the right stick can each press any N64 button, or nothing. From the game list it changes every game's layout; from the pause menu, only the game you are playing, which keeps it.
- Settings: Volume (0 to 100%), also from the pause menu while you play.
+6
View File
@@ -0,0 +1,6 @@
## New
- Cheat search: find where a game keeps a number (lives, health, money) and make your own cheat from it. In the pause menu, Cheats → Search for a Cheat: search for the value the game shows, or without a value for things like a health bar. Then play, pause, and pick what happened to it (changed, went up, went down by the value...) until only a few addresses are left. In Results, keep one at the value you want as a cheat, or set it just once. Works on 8, 16 and 32-bit values; Undo takes back a step.
- Type in your own GameShark codes with the PS5's on-screen keyboard (Cheats → Type In Codes).
- Your own cheats come first in the Cheats list and are kept with the game. Square renames one, changes its codes or deletes it; on a cheat of the list, Square makes your own copy to fix. They are saved in /data/mupen64plus/cheats/<game>.custom.txt, where you can also add codes from a PC.
- Cheats is in the pause menu for every game now, also the ones the cheat list has nothing for.
- The touchpad goes back to the game from any page of the pause menu (Cheats, the cheat search, Controller Layout).