83 Commits
Author SHA1 Message Date
Pedro Santos b0181457b8 Merge pull request #34 from s0liton/readme-update
Plug the YT channel for teh gains... /s
2026-10-25 16:15:44 -04:00
Pedro Santos 69cb1c7ce3 Plug the YT channel for teh gains... /s
Added information about emulator profiles and development progress.
2026-10-25 11:18:31 -04:00
Pedro Santos 83ee938528 Merge pull request #33 from s0liton/recompstore-qr
README: RecompStore QR code
2026-10-25 01:26:08 -04:00
Pedro Santos 2c7b069290 Fixed recompStore qr code 2026-10-25 01:20:13 -04:00
Pedro Santos f2c0c9bbd1 Add files via upload 2026-10-25 01:16:59 -04:00
Pedro Santos 804dea1e50 Merge pull request #31 from s0liton/readme-optimization
Revised readme for clarity and simplification
2026-10-24 01:11:06 -04:00
Pedro Santos 833307403a Revised readme for clarity and simplification 2026-10-24 01:04:43 -04:00
Pedro Santos 63e5884f68 Merge pull request #26 from s0liton/release/v1.3.0
v1.3.0: PS4 memory fixes
2026-10-22 20:29:18 -04:00
Pedro Santos 37714135c5 PS4 memory: one CA store, covers on disk, PS4-only re-release
- The CA roots are parsed once and shared by every connection; given as a
  blob, libcurl parsed all of them again for each new connection, which
  ran the PS4's shared memory pool dry with a big library
- PS4: 3 pooled connections and one cover worker
- The full cover pass keeps its list on disk (cache/cover-pass) instead of
  in memory, carries on where it was after a restart, and waits while the
  library is being browsed; the PS4 gets the full pass back
- Cache pruning takes the same little memory however many covers there
  are; the cache limit is 2 GB on the consoles
- Running out of memory is logged as such, a sync that fails from it says
  so instead of "cannot reach RomM server", and covers and game lists back
  off instead of retrying straight away
- Release workflow: a manual run rebuilds only the PS4 payload and swaps
  it into an existing release; merging a release branch whose release is
  already out no longer tries to publish it again
2026-10-22 20:13:47 -04:00
Pedro Santos a1981fa5fb Merge pull request #25 from s0liton/release/v1.3.0
v1.3.0
2026-10-22 18:55:08 -04:00
Pedro Santos a35d80210b Merge remote-tracking branch 'origin/main' into release/v1.3.0 2026-10-22 18:42:38 -04:00
Pedro Santos 92237e603e Lean game lists for the PS4, interface sounds setting
- Game lists drop the members nothing here reads (RetroAchievements and
  scraper metadata, screenshots) as they stream in (src/jsonlean.c): a
  page of 16 PS1 games goes from 1.3 MB to 60 KB, so the PS4 payload,
  which shares GoldHEN's process, no longer runs out of memory browsing
- The emulator table waits up to 2 s for memory instead of giving up, and
  a download that still can't get it says so instead of "no emulator is
  set up"
- Free memory is logged at the end of each sync and with any shortage
- Settings: Interface sounds can be turned off (rumble stays)
- Unit tests: the JSON filter against 3000 random documents
2026-10-22 18:38:45 -04:00
Pedro Santos f78a02db27 Release review fixes, resumable downloads, accented names, Switch title IDs
- Saves: a blank or never-formatted memory card never replaces one with
  saves (shared cards and PS1 cards shared between emulators); a shared
  card's first sync goes by when each copy was last written, not when it
  reached RomM; shared cards never come down while a game runs
- PS2 BIOS tidy only counts whole BIOS images, so a dump's .nvm/.mec/rom1
  files can no longer cost the BIOS in use
- Old copies of an extra's games are cleared before its mount can show
  the main emulator's games in their place
- Web UI setup no longer breaks on the PS2 card choice
- PS4 launcher: stale payloads only go once the new one is in place, and
  the payload's own update in progress is left alone
- Downloads cut off halfway carry on from where they stopped; ones that
  failed with RomM away (refused or hung) start again once it answers;
  failed and cancelled ones can be retried from the Queue; a file RomM
  lists but no longer has says so
- Emulators found after setup are announced once
- RommPS fonts carry accented Latin, Cyrillic and curly quotes; text that
  still can't be drawn is folded instead of showing "?"
- Switch details show title IDs, and a collection's other games are kept
  apart from the game's own update and DLC
- Small things: "Cancelled" in the Queue, no stray dot on rows without a
  platform, .ico platform icons in the web UI, notification results logged
2026-10-22 15:28:52 -04:00
Pedro Santos 77d81e4476 Lighter connections to RomM, one card per Switch game, app polish
- Reuse connections to RomM. When it stops answering, fail fast and check
  back every 5-30 s instead of every request waiting; the top right says
  "Difficulties connecting to server" (app and web UI)
- Game lists come 60 at a time; the daily cover pass skips platforms whose
  game count didn't change
- Switch: duplicate entries of one game show as one card; no version labels
- Review fixes: cancelling a Switch download keeps files that were there,
  a folder's update/DLC is found when its update comes first, a stale hold
  can't delete a game, the web Downloads tab reloads only on change
- App: platform logos on library cards and in Configure Emulators, game
  descriptions scroll by holding Up/Down, a roomier details sheet, a star
  on the rating, bold user @ host:port with a lock for HTTPS, no "Save sync"
  tagline on Home, notifications kept clear of the button hints
2026-10-21 01:31:11 -04:00
Pedro Santos b738d7f470 Merge remote-tracking branch 'origin/ui/improvements' into emulator-mounts 2026-10-21 01:28:26 -04:00
Pedro Santos 5194001e91 Label the rating in game details; drop "In your RomM library" from games not on the console 2026-10-21 01:28:25 -04:00
jheyneman 217a9b6991 list tabs scroll smoothly with focus, remove badges on covers 2026-10-21 00:09:12 -04:00
Pedro Santos 3421c41003 Merge remote-tracking branch 'origin/ui/improvements' into emulator-mounts
# Conflicts:
#	platform/ps5/app/rommps/examples/rommps/kit/screen.cpp
#	src/library.c
#	ui/index.html
2026-10-20 22:47:47 -04:00
Pedro Santos 920fe9e819 Switch: games download with their update and DLC; archives are turned down
The library lists a Switch game once: its update and DLC, found by title
id, come down with it into update_dir/dlc_dir, and its details list them.
Folder downloads from before are unfolded where the emulator looks.
A .rar (any archive, for Switch) is refused with a reason.
2026-10-20 22:44:11 -04:00
Pedro Santos b37cd7f290 Working 1.3.0 milestone 2026-10-20 21:04:02 -04:00
Pedro Santos 224923aaae Merge pull request #22 from s0liton/s0liton-patch-1
Update issue templates
2026-10-19 16:36:37 -04:00
Pedro Santos 57c1d05847 Update issue templates 2026-10-19 16:34:03 -04:00
Pedro Santos 382e799a4a Merge pull request #20 from s0liton/customized-gh-issues
Update issue templates
2026-10-19 16:26:01 -04:00
Pedro Santos 6818392c80 Update issue templates 2026-10-19 16:22:58 -04:00
Pedro Santos acc15db685 First upgrade of ps4 build, bug fixes. 2026-10-17 22:53:50 -04:00
jheyneman b8748872b6 Merge remote-tracking branch 'origin/main' into ui/improvements
# Conflicts:
#	platform/ps5/app/rommps/examples/rommps/kit/library_page.cpp
#	platform/ps5/app/rommps/examples/rommps/kit/rommps_app.cpp
#	platform/ps5/app/rommps/examples/rommps/kit/screen.cpp
2026-10-17 13:56:09 -04:00
Pedro Santos 2d42c0578d Merge pull request #18 from s0liton/release/v1.2.1
v1.2.1 to fix changes in RetroArch 2.0.0 ROM folder naming and changed PS2 core.
2026-10-15 19:23:39 -04:00
Pedro Santos 5c63aa5352 1.2.1: RetroArch 2.0 folders, PS5SX2 memory cards, covers keep their shape
- RetroArch 2.0 (webui/version.json): each system goes to its libretro-named
  content folder when 2.0 has one, unless a folder was picked; games already
  in the old folders move once the user says so (RommPS prompt, web UI card),
  their records and RetroArch's playlists and history follow them
- PS2 on 2.0 plays with PS5SX2, whose two shared memory cards are backed up;
  a leftover LRPS2 core is ignored
- PS2 BIOS for RetroArch goes to system/pcsx2/bios; failed BIOS downloads say why
- RommPS: covers keep their own shape in the shelf, grids, search and details;
  the grid's columns follow its covers; the top row brightens as it scrolls in
- Fixed a log line reading freed memory when an emulator's save sync changes
- Tests: e2e case for the 2.0 move, PS5SX2 cards and the history paths
2026-10-15 19:09:19 -04:00
jheyneman 35ecb09a09 Queue cover URL: pass the game, not its cover path (coverUrl reads .cover) 2026-10-15 17:01:46 -04:00
jheyneman c65f6b178c Download queue rows show the game's cover, same centering as the installed list 2026-10-15 16:43:14 -04:00
jheyneman e87b3f82a6 Web UI Downloads list: center the cover and delete button 2026-10-15 16:12:54 -04:00
jheyneman 7e9ba03ccd Web UI Downloads list: cover on the left, title below the platform 2026-10-15 15:57:21 -04:00
jheyneman fa0b13b028 Web UI: Downloads tab renamed Queue; new Downloads tab lists installed games 2026-10-15 15:52:31 -04:00
jheyneman 1d8eeca542 Keep the degree sign: the baked fonts have its glyph 2026-10-15 15:24:42 -04:00
jheyneman 2e35b40138 Downloads tab: the games on this console as a list, Cross opens the sheet 2026-10-15 14:10:08 -04:00
jheyneman 9b8a7bd4b1 Library: drop the 'On this console' carousel; platform tiles move up 2026-10-15 14:08:04 -04:00
jheyneman 48f1db129f Name the transfer tab Queue internally: kDownloads -> kQueue, queue()-style App names 2026-10-15 14:05:45 -04:00
jheyneman 04007c2f25 Rename Downloads tab to Queue 2026-10-15 13:52:15 -04:00
jheyneman 17917e30f7 Panel and drop shadow behind the floating hint rows 2026-10-15 13:41:05 -04:00
Pedro Santos ceebe293aa Merge pull request #16 from s0liton/release/v1.2.0
v1.2.0 - tested and validated.
2026-10-14 18:30:09 -04:00
Pedro Santos 03998081fa 1.2.0: platform-first Emulators page, sync fixes, release notes
- Settings > Emulators: one row per platform with its emulator, folders
  (Preset or Manual), sync and platform settings; leave prompt; detection
  retries; default emulator is the one with games
- Sync: only this console's slots are written, older and other-device saves
  per kind, missing saves, states and bundles come back from RomM, per-game
  state folders (Porpoise), shared folders keep each system's games
- Notifications name the games; covers retry and the cache fetches this
  console's games first
- RommPS: socket leak fixed, long requests on their own worker, wording,
  spinner, shelf count
- Web UI: acrylic by default, per-platform sync kept, TLS wording
- Tests: upgrade test from every release, more e2e cases
2026-10-14 18:26:35 -04:00
Pedro Santos d2c8abc8e1 1.2.0 pre-release: GameCube saves in RomM's form, Porpoise, Emulators page
- GameCube saves stay in the form RomM has them: zip, one .gci, or a whole
  memory card (only the game's own saves on it change).
- Porpoise supported; two Dolphins for one system share the same saves.
- Emulators page: Triangle saves, leaving with changes asks, systems listed
  by name under their own section.
- Fixes: extras got games of the wrong system from shared folders; folder
  saves missing here come back; web UI stuck on "Connecting"; sync lists
  newest first.
2026-10-14 13:46:18 -04:00
Pedro Santos fe9276a617 1.2.0: smarter save and state sync, sync log, diagnostics
Save and state sync
- A game's saves and states sync the moment it finishes downloading.
- Save states only come down to an emulator that can load them; PSXS5 and
  PS5N64 states are converted to and from RetroArch's format.
- Save sync is optional (setup and Settings), and can be set per emulator:
  saves and states, saves only, or off.
- A save or state deleted from the console comes back from RomM.
- Saves from before RomM's save slots reach the console.
- A blank PS1 card from RomM never replaces one with saves: it's a conflict.
- While a game runs, a changing save goes up at most every 5 minutes.

See what synced
- Sync log and tracked saves by platform, in RommPS (Home: Conflicts,
  Activity, Tracked saves) and the web UI, plus each game's saves and states.
- Notifications say what moved; every finished download says so.
- Diagnostic bundle (Settings): logs and settings with nothing personal.

RommPS and web UI
- Settings in sections; emulators grouped by system with each system's
  folders (as found or picked); SwanStationPS5 preset; PS5NES and Porpoise
  offered again.
- Hold Options to close RommPS. Shorter texts throughout; "RommPS" in
  notifications and the web UI.
2026-10-14 12:30:08 -04:00
Pedro Santos ae29a28855 Merge remote-tracking branch 'origin/fix/folder-rom-download' into release/v1.2.0 2026-10-14 02:40:15 -04:00
Pedro Santos 109f471900 Merge pull request #15 from s0liton/readme-update-save-states
Document save states issues and compatibility
2026-10-14 01:47:01 -04:00
Pedro Santos e45be32af8 Document save states issues and compatibility
Added a section on save states, including issues and compatibility notes.
2026-10-14 01:46:49 -04:00
jheyneman 2e8e0d9c89 Fetch folder roms file by file, so games keep their extensions
Fixes #13
2026-10-13 23:57:54 -04:00
Pedro Santos 129b587558 WIP: per-system emulator choice and folders in setup (RommPS doesn't build yet) 2026-10-13 23:02:05 -04:00
Pedro Santos 59d8e31d43 Sync a game's saves and states as soon as its download finishes
The download stays open as "Syncing saves" until that game's saves and
states are in place, so it isn't started without them. A notification
says what came down. Shown in RommPS and the web UI.
2026-10-13 23:02:05 -04:00
Pedro Santos 569382b240 Add CODEOWNERS file to define code ownership 2026-10-13 15:37:13 -04:00
Pedro Santos 3f602735b8 Delete CODEOWNERS 2026-10-13 15:36:30 -04:00
Pedro Santos bb8b71b21d Add CODEOWNERS file with owner assignment 2026-10-13 15:35:39 -04:00
Pedro Santos 1d2462eb0d Reviewed symlink/hardlink support in fs, added ability to delete games from app for parity with web ui 2026-10-11 23:36:44 -04:00
Pedro Santos 5421f7b09d Fixed missing mem card notifications and log 2026-10-11 15:51:20 -04:00
Pedro Santos 78d0c98a38 Fix wording in Porpoise emulator description
Grammar....
2026-10-11 14:22:49 -04:00
Pedro Santos ac725f3458 Clarified Porpoise Support 2026-10-11 14:22:18 -04:00
Pedro Santos b2bd33a9c4 Clarified mem card behavior in PS5SX2 vs LRPS2.
Added information about PS5SX2 an d LRPS2 support and how to best work with either or both.
2026-10-11 13:07:14 -04:00
Pedro Santos d8a595e50b Update README with Web UI and emulator support details
Added information about RommPS Web UI and updated emulator support details.
2026-10-11 12:02:22 -04:00
Pedro Santos 024e610cfc Fix formatting of ProsperoStore QR code image
I amaze myself sometimes…
2026-10-10 00:24:39 -04:00
Pedro Santos 68275e8740 Fix image syntax in README for ProsperoStore QR code 2026-10-10 00:21:25 -04:00
Pedro Santos 43c8d94c15 ProsperoCode QR PNG 2026-10-10 00:20:20 -04:00
Pedro Santos cbfc79ba1e Added ProsperoStore QR code 2026-10-10 00:16:36 -04:00
Pedro Santos 659005ce58 Merge pull request #9 from s0liton/fix/library-platforms
Library sees emulator changes without restarting RommPS
2026-10-09 21:23:07 -04:00
Pedro Santos a1446f4706 Reload the platforms when emulators change, so the library sees a new folder 2026-10-09 21:18:52 -04:00
Pedro Santos b5d3e954f8 Merge pull request #8 from s0liton/release/v1.1.1
v1.1.1: clean up after 1.1.0
2026-10-09 18:41:52 -04:00
Pedro Santos 9066fb38ac Shorter release notes for v1.1.1 2026-10-09 18:34:42 -04:00
Pedro Santos 46aa1710d2 Release v1.1.1 2026-10-09 18:30:45 -04:00
Pedro Santos bf479d045b Clean up after 1.1.0: RommPS is the only thing on the console
1.1.0 left the old RomM Sync tile next to RommPS, autostart only in
etaHEN's folder, and old payloads that could replace newer ones.

- One copy runs at a time: a lock file decides, an older copy never
  replaces a newer one, and two starting together can't stop each other.
- Autostart goes into every HEN's folder (etaHEN and onionHEN), copies
  are brought up to the newest at startup, a pldmgr copy is kept current,
  and an older payload never overwrites a newer one on disk.
- The old tile is removed once RommPS is installed, and kept as the only
  way in when it isn't.
- On the PS5, updates come with RommPS; the payload no longer updates
  itself there, and says when RommPS is out of date.
- RommPS starts the payload only when none is running, swaps in its own
  once idle, keeps the copies on disk current, and turns autostart on by
  default (once for 1.1.0 setups that never chose).
- RomM Sync stays quiet while RommPS is installed.
- RommPS: real last sync time, conflict hint, update and autostart rows,
  the payload's port from its config, new home screen art.
- Web UI: settings only send what changed, setup starts from the current
  settings, no tile button, requests from other sites are refused.
- tests/autostart.sh and docs/release-checklist.md.
2026-10-09 18:30:42 -04:00
Pedro Santos 7a5a07a13f Another README revision
I'll eventually get this right.
2026-10-09 11:09:11 -04:00
Pedro Santos 7b426b8273 Cleaned up readme some more.
It still feels disorganized but maybe I'm overthinking it. We'll see what users say.
2026-10-09 08:15:34 -04:00
Pedro Santos d309c16cb2 Release v1.1.0 (RommPS carries RomM Sync) 2026-10-09 02:32:32 -04:00
Soliton c632774407 RommPS carries RomM Sync and starts it: install from the store and go
RommPS bundles the payload from its own release (assets/rommps/romm-sync.elf)
and sends it to the HEN's loader when RomM Sync isn't running, or when the
running one is older. Setup's autostart step installs it into the HEN's
folder when it isn't there yet. tools/release-app.sh bundles the release's
own romm-sync.elf, checked against its SHA256SUMS.
2026-10-09 02:30:36 -04:00
Pedro Santos 754d4d60f2 Release v1.1.0
RomM Sync 1.1.0 and RommPS.
2026-10-09 01:54:11 -04:00
Soliton afd9406603 Shorter wording on the emulators step 2026-10-09 01:52:26 -04:00
Soliton f2a3871b4b Point the update check and the source link at s0liton/RommPS 2026-10-09 01:49:24 -04:00
Soliton 0894ec8df9 README picture: RommPS's library 2026-10-09 01:43:06 -04:00
Soliton 5b01d95ef5 Check the hard link portably in the e2e test (GNU stat -f means something else) 2026-10-09 01:42:33 -04:00
Soliton 87476147a0 Merge main: README title and cleanup 2026-10-09 01:37:03 -04:00
Soliton 694119b10f Cover caching, faster library, versions, and release prep for 1.1.0
- Every game's cover is kept on the console and refreshed at most once a
  day; RommPS asks for covers on screen first and caps the ones it keeps.
- Library pages are compressed and fetched ahead, list requests leave out
  RomM's id index and filter values, and RommPS only asks for the pages it
  lands on.
- Files RomM scans that aren't games are hidden and never downloaded, and
  manuals, soundtracks and screenshots inside a game's folder are skipped.
- Games with several versions say what sets each apart.
- Download speeds, hold Cross to cancel and delete, clear finished ones.
- Turning on an extra emulator links the games already there, with a
  notification; emulators with no preset (PS5NES, Porpoise) can be set up
  with folders the user picks.
- Fixes from the pre-release review: Mega Drive .md ROMs, cache pruning,
  the covers pass after a failed listing, unshared libcurl connections,
  the Downloads row bounds, the Home headline, hold-to-cancel state, and
  more.
- Release: hand-written notes in docs/releases, a contentVersion check,
  tools/release-app.sh for PPSA76677.zip, and the LLVM runtime licence.
2026-10-09 01:34:58 -04:00
Soliton a590964998 Add RommPS, the native PS5 app, and support for standalone PS5 emulators
RommPS (PPSA76677) is a native PS5 app for RomM Sync: library browsing by
platform with search and an A to Z bar, game details from RomM, downloads,
settings, the setup on the console, and themes.

The payload now handles standalone emulators on the PS5 (PSXS5 and
SwanStationPS5, PS5SX2, PS5N64), PS1 serials read from disc images (libchdr
for .chd), saves named by serial or file hash, several emulators per system
with one shared save where the format matches, shared memory card backups,
folders picked for systems without an emulator, and forgetting a previous
server's ids after pairing with another.

The PS4 build is marked experimental.
2026-10-08 22:05:23 -04:00
Pedro Santos 0ccf61ebd7 Change project name to RomM Server Client for PS4 and PS5
Updated project title and description to reflect support for PS4.
2026-10-08 14:34:04 -04:00
Pedro Santos 220f606256 Cleaned up README a bit....I gotta start being more concise.
Removed sections about release process and signing keys for clarity and conciseness.
2026-10-07 00:04:35 -04:00
Soliton 842e32cc73 Working prototype of PS4 build 2026-10-06 01:09:22 -04:00
721 changed files with 284884 additions and 1675 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
* @s0liton
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Firmware Version:**
Which firmware version is your console on? (e.g 13.62)
**PS5 Hardware Model:**
Which console revision do you have? (e.g CFI-1215A)
**Additional context**
Add any other context about the problem here.
**Diagnostic Log**
- Retrieve a diagnostic log from settings after replicating the issue and attach it to the ticket.
@@ -0,0 +1,38 @@
---
name: Emulator/App Submission
about: Request that RommPS includes an Emulator/App profile for your PS4/PS5 application
title: '[App Profile] Create Sync Profile for "Your App"'
labels: ''
assignees: s0liton
type: Task
---
# Application Details
### Application Name
- What do you call it?
### Title ID
- What is your app's title ID?
### GitHub URL
- The URL of your app in GitHub or other hosting platform.
### Describe Your App
- Give a brief description of what your app does.
### Logo URL
- We will soon start including a square icon for each profile.
# Directories
### ROMs Directory
- Where would ROMs/Games be synced to?
### Saves Directory
- Where game saves and memory cards should be synced to?
### Save States Directory
- Where save states should be synced to?
### BIOS/Required Files Directory
- Where to send any system files for the given platform?
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+1
View File
@@ -14,3 +14,4 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev zlib1g-dev
- run: make host tools
- run: tests/e2e.sh
- run: tests/autostart.sh
+145 -25
View File
@@ -1,8 +1,14 @@
# Builds the PS5 payload and publishes a GitHub release. Triggered either by:
# Builds the PS5 and PS4 payloads and publishes a GitHub release. Triggered either by:
#
# - merging a PR into main from a branch named with a version, e.g.
# release/v1.0.2 or v1.0.2-some-fix (the tag is created on the merge commit)
# - pushing a version tag manually: git tag v1.0.0 && git push origin v1.0.0
#
# Run by hand (Actions > Release > Run workflow, or
# gh workflow run release.yml -f tag=v1.3.0 -f ref=release/v1.3.0), it instead
# rebuilds only the PS4 payload from ref and swaps it into the existing tag's
# release: a PS4 fix that doesn't make the PS5 download anything. Those already
# on that version reinstall it by hand; the in-app update only offers newer ones.
name: Release
on:
@@ -11,6 +17,14 @@ on:
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
tag:
description: "Existing release whose PS4 payload is replaced (v1.3.0)"
required: true
ref:
description: "Branch or commit the PS4 payload is built from"
required: true
permissions:
contents: write
@@ -26,6 +40,7 @@ jobs:
- name: Resolve version
id: version
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "$GITHUB_EVENT_NAME" = push ]; then
@@ -39,6 +54,12 @@ jobs:
echo "Branch '$HEAD_REF' has no vX.Y.Z version; skipping release."
exit 0
fi
# A release branch merged again after its release went out (a fix
# shipped with the manual PS4 run below, say).
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $tag already exists; skipping."
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
@@ -47,29 +68,58 @@ jobs:
with:
ref: ${{ steps.version.outputs.sha }}
- uses: docker/setup-buildx-action@v3
if: steps.version.outputs.tag
# Same image tools/ps5-build.sh uses, cached between runs. The cache only
# saves time, so failing to write it must not fail the release.
- uses: docker/build-push-action@v6
if: steps.version.outputs.tag
with:
context: tools/docker
tags: romm-sync-ps5sdk:v0.43
load: true
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
- name: Build payload
# RommPS (built with tools/release-app.sh, outside this workflow) is
# offered as an update by the homebrew catalog only if its contentVersion
# goes up, so the tag has to match it: v1.1.0 is 01.001.000.
- name: Check RommPS's contentVersion
if: steps.version.outputs.tag
env:
TAG: ${{ steps.version.outputs.tag }}
run: tools/ps5-build.sh VERSION="${TAG#v}"
run: |
IFS=. read -r major minor patch <<< "${TAG#v}"
want=$(printf "%02d.%03d.%03d" "$major" "$minor" "$patch")
have=$(jq -r .contentVersion platform/ps5/app/rommps/ps5/sce_sys/param.json)
if [ "$have" != "$want" ]; then
echo "::error::RommPS's contentVersion is $have, $TAG needs $want (platform/ps5/app/rommps/ps5/sce_sys/param.json)"
exit 1
fi
# manifest.json + manifest.sig let the payload verify in-app updates
# (src/update.c). The secret is the 64-hex-character key from
# tools/release-sign.c; "check" fails unless the payload trusts that key.
- uses: docker/setup-buildx-action@v3
if: steps.version.outputs.tag
# Same images tools/ps5-build.sh and tools/ps4-build.sh use, cached between
# runs. The cache only saves time, so failing to write it must not fail the release.
- uses: docker/build-push-action@v6
if: steps.version.outputs.tag
with:
context: platform/ps5/docker
tags: romm-sync-ps5sdk:v0.43
load: true
cache-from: type=gha,scope=ps5
cache-to: type=gha,mode=max,scope=ps5,ignore-error=true
- uses: docker/build-push-action@v6
if: steps.version.outputs.tag
with:
context: platform/ps4/docker
tags: romm-sync-ps4sdk:v0.9-crt1352-oo0.5.4
load: true
cache-from: type=gha,scope=ps4
cache-to: type=gha,mode=max,scope=ps4,ignore-error=true
- name: Build payloads
if: steps.version.outputs.tag
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
tools/ps5-build.sh VERSION="${TAG#v}"
tools/ps4-build.sh ps4-pkg VERSION="${TAG#v}"
# A manifest and signature per console let the payload verify in-app
# updates (src/update.c): manifest.json/.sig for romm-sync.elf (PS5),
# manifest-ps4.json/.sig for romm-sync-ps4.elf. The secret is the
# 64-hex-character key from tools/release-sign.c; "check" fails unless
# the payload trusts that key.
- name: Package and sign
if: steps.version.outputs.tag
env:
@@ -82,10 +132,14 @@ jobs:
fi
make tools
mkdir dist
cp build/ps5/romm-sync.elf dist/
build/host/release-sign manifest "$TAG" dist/romm-sync.elf > dist/manifest.json
build/host/release-sign sign dist/manifest.json > dist/manifest.sig
build/host/release-sign check dist/manifest.json dist/manifest.sig
cp build/ps5/romm-sync.elf build/ps4/romm-sync-ps4.elf build/ps4/romm-sync-ps4.pkg dist/
sign() {
build/host/release-sign manifest "$TAG" "dist/$1" > "dist/$2.json"
build/host/release-sign sign "dist/$2.json" > "dist/$2.sig"
build/host/release-sign check "dist/$2.json" "dist/$2.sig"
}
sign romm-sync.elf manifest
sign romm-sync-ps4.elf manifest-ps4
cd dist && sha256sum * > SHA256SUMS
# Creates the tag on the target commit if it doesn't exist yet. A tag pushed
@@ -96,6 +150,72 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
SHA: ${{ steps.version.outputs.sha }}
# The notes are written by hand, in docs/releases/<tag>.md: the homebrew
# catalog shows them as RommPS's release notes, and drops GitHub's
# generated list.
run: |
notes="docs/releases/$TAG.md"
if [ ! -s "$notes" ]; then
echo "::error::$notes is missing; write the release notes before tagging"
exit 1
fi
gh release create "$TAG" dist/* \
--target "$SHA" --title "$TAG" --generate-notes
--target "$SHA" --title "$TAG" --notes-file "$notes"
replace-ps4:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ inputs.tag }}
steps:
- name: Check the release
run: |
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::'$TAG' isn't a vX.Y.Z tag"
exit 1
fi
gh release view "$TAG" >/dev/null || { echo "::error::no release $TAG to update"; exit 1; }
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: platform/ps4/docker
tags: romm-sync-ps4sdk:v0.9-crt1352-oo0.5.4
load: true
cache-from: type=gha,scope=ps4
cache-to: type=gha,mode=max,scope=ps4,ignore-error=true
- name: Build the PS4 payload
run: tools/ps4-build.sh ps4-pkg VERSION="${TAG#v}"
- name: Sign
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
if [ -z "$RELEASE_SIGNING_KEY" ]; then
echo "::error::RELEASE_SIGNING_KEY secret is not set; in-app updates need a signed release."
exit 1
fi
make tools
mkdir dist
cp build/ps4/romm-sync-ps4.elf build/ps4/romm-sync-ps4.pkg dist/
build/host/release-sign manifest "$TAG" dist/romm-sync-ps4.elf > dist/manifest-ps4.json
build/host/release-sign sign dist/manifest-ps4.json > dist/manifest-ps4.sig
build/host/release-sign check dist/manifest-ps4.json dist/manifest-ps4.sig
# The PS5's files and their checksums stay as they are.
- name: Replace the PS4 files
run: |
cd dist
ps4="romm-sync-ps4.elf romm-sync-ps4.pkg manifest-ps4.json manifest-ps4.sig"
gh release download "$TAG" --pattern SHA256SUMS --output old-sums
grep -vE ' (romm-sync-ps4\.(elf|pkg)|manifest-ps4\.(json|sig))$' old-sums > SHA256SUMS || true
sha256sum $ps4 >> SHA256SUMS
gh release upload "$TAG" $ps4 SHA256SUMS --clobber
+1
View File
@@ -3,3 +3,4 @@ host-data/
*.part
.DS_Store
.vscode/
*.elf
+60 -46
View File
@@ -1,38 +1,66 @@
# romm-sync
#
# make host macOS/Linux build for development, in build/host/
# make ps5 PS5 payload, needs the SDK (see tools/ps5-build.sh)
# tools/ps5-build.sh "make ps5" inside the Docker SDK image
# make deploy PS5_HOST=<ip> send the payload to the loader on port 9021
# make install PS5_HOST=<ip> same, plus copy it into etaHEN's autostart over FTP
# make ps5 PS5 payload, needs the SDK (see platform/ps5/ps5.mk)
# make ps4 PS4 payload, needs the SDK (see platform/ps4/ps4.mk)
# tools/ps5-build.sh, tools/ps4-build.sh the same inside the Docker SDK image
# make deploy PS5_HOST=<ip> send the PS5 payload to the loader on port 9021
# make install PS5_HOST=<ip> same, plus copy it into the HEN's autostart over FTP (PS5_HEN_DIR)
# make ps4-pkg PS4 home screen app (OpenOrbis), with the payload inside
# make install-ps4 PS4_HOST=<ip> copy the PS4 payload to /data/payloads over FTP (run it from GoldHEN's payload menu)
# make ps5 VERSION=1.2.3 override the version baked into the build
# make ps4-pkg NETLOG=1 test build that broadcasts its log on the LAN (tools/netlog.py)
# make tools release-sign, for signing releases (tools/release-sign.c)
#
# Code shared by every console is in src/. Each console has its own folder in
# platform/: platform.c (src/platform.h), its make rules and its SDK image.
# libchdr (BSD-3-Clause, third_party/libchdr), for PS1 serials in .chd games:
# its CD codecs, with its LZMA and zstd decoders and the system's zlib.
CHDR_SRCS := $(filter-out %microflac.cpp,$(wildcard third_party/libchdr/src/*.c)) \
third_party/libchdr/deps/lzma/src/LzmaDec.c third_party/libchdr/deps/zstd/zstddeclib.c
SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
src/profiles.c src/sync.c src/watch.c src/detect.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gameid.c src/library.c src/pair.c src/web.c src/update.c \
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c
src/profiles.c src/sync.c src/watch.c src/detect.c src/ra2.c src/playlist.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gcmem.c src/statefmt.c src/events.c src/gameid.c src/covers.c src/library.c src/firmware.c src/pair.c src/web.c src/update.c src/jsonlean.c \
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c \
$(CHDR_SRCS)
HDRS := $(wildcard src/*.h) third_party/cJSON.h third_party/md5.h third_party/monocypher.h third_party/monocypher-ed25519.h
UI_HEADER := build/gen/ui_index.h build/gen/icon_png.h
COMMON_CFLAGS := -std=gnu11 -O2 -Wall -Wextra -Wno-unused-parameter -Wno-unknown-warning-option -Wno-unreachable-code-generic-assoc -Isrc -Ithird_party -Ibuild/gen
GEN_HEADERS := build/gen/ui_index.h build/gen/retroarch_cores_json.h
COMMON_CFLAGS := -std=gnu11 -O2 -Wall -Wextra -Wno-unused-parameter -Wno-unknown-warning-option -Wno-unreachable-code-generic-assoc -Isrc -Ithird_party -Ibuild/gen \
-Ithird_party/libchdr/include -DCHDR_SYSTEM_ZLIB
ifdef VERSION
COMMON_CFLAGS += -DAPP_VERSION='"$(VERSION)"'
endif
# Shown in logs and diagnostics. The Docker builds pass it in, as git can't
# read the repo from inside the container.
BUILD_ID ?= $(shell git describe --always --dirty 2>/dev/null)
ifneq ($(BUILD_ID),)
COMMON_CFLAGS += -DAPP_BUILD='"$(BUILD_ID)"'
endif
# Test builds: NETLOG=1 also broadcasts the log on the LAN, curl's details
# included. tools/netlog.py shows it. Never in a release.
ifdef NETLOG
COMMON_CFLAGS += -DROMM_NETLOG
endif
COMMON_CFLAGS += $(EXTRA_CFLAGS)
PS5_HOST ?= ps5
PS5_PORT ?= 9021
.PHONY: all host ps5 tools deploy install run clean
.PHONY: unit all host ps5 ps4 tools deploy install install-ps4 run clean
all: host
build/gen/ui_index.h: ui/index.html third_party/qrcode.js tools/embed.py
build/gen/ui_index.h: ui/index.html ui/style.css $(wildcard ui/themes/*.css) build/gen/inter-font.css third_party/qrcode.js tools/embed.py
@mkdir -p $(dir $@)
python3 tools/embed.py $< $@ UI_INDEX_HTML
build/gen/icon_png.h: assets/icon0.png tools/embed.py
# What RetroArch's cores need beyond the defaults (src/detect.c), on every console.
build/gen/retroarch_cores_json.h: platform/retroarch_cores.json tools/embed.py
@mkdir -p $(dir $@)
python3 tools/embed.py $< $@ ICON_PNG
python3 -c "import json,sys; json.load(open(sys.argv[1]))" $<
python3 tools/embed.py $< $@ RETROARCH_CORES_JSON
# Inter (SIL Open Font License, third_party/Inter-LICENSE.txt), inlined into the UI.
build/gen/inter-font.css: third_party/inter-latin.woff2 tools/font_css.py
@mkdir -p $(dir $@)
python3 tools/font_css.py $< $@ Inter
# host build
HOST_CC ?= cc
@@ -41,9 +69,22 @@ HOST_BIN := build/host/romm-sync
host: $(HOST_BIN)
# Host builds also trust the test key in tests/, so the e2e test can sign updates.
$(HOST_BIN): $(SRCS) src/platform_host.c $(HDRS) $(UI_HEADER)
$(HOST_BIN): $(SRCS) platform/host/platform.c $(HDRS) $(GEN_HEADERS)
@mkdir -p $(dir $@)
$(HOST_CC) $(COMMON_CFLAGS) -DROMM_UPDATE_TEST_KEY -o $@ $(SRCS) src/platform_host.c -lcurl -lz -lpthread
$(HOST_CC) $(COMMON_CFLAGS) -DROMM_UPDATE_TEST_KEY -o $@ $(SRCS) platform/host/platform.c -lcurl -lz -lpthread
# Unit tests for the parts with file formats of their own.
unit: build/host/gcmem_test build/host/jsonlean_cli
build/host/gcmem_test
python3 tests/jsonlean_test.py build/host/jsonlean_cli
build/host/jsonlean_cli: tests/jsonlean_cli.c src/jsonlean.c src/jsonlean.h
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/jsonlean_cli.c src/jsonlean.c
build/host/gcmem_test: tests/gcmem_test.c src/gcmem.c src/gcmem.h
@mkdir -p $(dir $@)
$(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/gcmem_test.c src/gcmem.c
tools: build/host/release-sign
@@ -54,35 +95,8 @@ build/host/release-sign: tools/release-sign.c src/update_keys.h third_party/mono
run: host
ROMM_SYNC_DATA=$${ROMM_SYNC_DATA:-./host-data} $(HOST_BIN)
# PS5 build
PS5_PAYLOAD_SDK ?= /opt/ps5-payload-sdk
PS5_CC := $(PS5_PAYLOAD_SDK)/bin/prospero-clang
PS5_PKGCONF := $(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config
PS5_ELF := build/ps5/romm-sync.elf
# The SDK's Mozilla CA bundle, built into the payload so HTTPS works out of the box.
PS5_CA := $(PS5_PAYLOAD_SDK)/target/user/homebrew/etc/ca-bundle.crt
ps5: $(PS5_ELF)
build/gen/cacert_pem.h: $(PS5_CA) tools/embed.py
@mkdir -p $(dir $@)
python3 tools/embed.py $< $@ CACERT_PEM
$(PS5_ELF): $(SRCS) src/platform_ps5.c $(HDRS) $(UI_HEADER) build/gen/cacert_pem.h
@mkdir -p $(dir $@)
$(PS5_CC) $(COMMON_CFLAGS) -DROMM_EMBED_CA $$($(PS5_PKGCONF) --cflags libcurl) -DCURL_STATICLIB \
-o $@ $(SRCS) src/platform_ps5.c \
$$($(PS5_PKGCONF) --static --libs libcurl) -pthread -lSceSystemService -lSceAppInstUtil
deploy: $(PS5_ELF)
nc -w 1 $(PS5_HOST) $(PS5_PORT) < $(PS5_ELF)
# Copy the payload into etaHEN's autostart folder over FTP (port 2121), then run it.
PS5_FTP ?= ftp://$(PS5_HOST):2121
install: $(PS5_ELF)
curl -sS --ftp-create-dirs -T $(PS5_ELF) $(PS5_FTP)/data/etaHEN/payloads/romm-sync.elf
printf '' | curl -sS -T - $(PS5_FTP)/data/etaHEN/payloads/romm-sync.elf.auto_start
nc -w 1 $(PS5_HOST) $(PS5_PORT) < $(PS5_ELF)
include platform/ps5/ps5.mk
include platform/ps4/ps4.mk
clean:
rm -rf build
+108 -56
View File
@@ -1,52 +1,84 @@
# RomM Sync for PS5
# RomM Server Client for PS4 and PS5
![Project Screenshot](assets/library.png)
A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN + kstuff). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates which all my devices sync with, and the PS5 needs similar treatment.
A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN or onionHEN, with kstuff) and PS4 consoles (GoldHEN). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates, which all my devices sync with, and the PS5 needs similar treatment.
- **Background save sync**
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS5 always has the latest saves and savestates.
- **Save and state sync**
- Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS4/5 always has the latest saves and save states.
- A game's saves come down the moment it finishes downloading, so it's ready to play.
- Save states only come down to an emulator that can load them (see [Save states](#save-states)).
- Optional: turn save sync off in setup or Settings to use RomM just for games.
- **See what synced**
- An activity log and every save and state kept in sync, by platform, in the app and the Web UI. Settle conflicts and restore older versions from either.
- Settings > Diagnostic bundle saves the logs for a bug report, with your server, names and addresses left out.
- **Interactive library**
- Browse your RomM library from the installed Romm Sync app (or some other device), download games and BIOS files into the right emulator folders, and resolve save conflicts.
- **Pluggable emulator profiles**
- RetroArch is supported out of the box. Mednafen and a PS2-ISO profile are included but disabled. I'll make sure we update as more emulators become available on the console.
## Supported Emulators and Games
Technically, this RomM client will work with all emulators, but the PS5 is currently limited on emulators available. This is simply a matter of time, and the client will be updated frequently as new emulators are released. Currently, we have focused on using mihawk-99's unofficial implementation of RetroArch. It works beautifully!
## PS4/PS5 Games
If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion.
- Browse your RomM library from the RommPS app (or some other device via the Web UI), download games and BIOS files into the right emulator folders, and resolve save conflicts.
- **Pluggable emulator profiles and multiple emulators per platform**
- Fully customizable sync destinations, but includes frequently updated preset profiles for existing homebrew emulators.
- Can sync games and saves and states for multiple emulators per platform (PS2 on RetroArch and PS5SX2 as an example) using hardlinks.
- **Beautiful and Responsive UI**
- Based on mihawk-99's Vulkan homebrew apps and BlackBearReloaded's UI kit, we get a nice, responsive feel. So worth it, it looks 1000x better. 15+ different themes to pick from.
- **Background Service**
- All operations are handled by a backend payload, which means syncing, downloading, and updating keep working in the background, so you can keep **gaming**.
- **RomM API Compliant**
- RommPS is meant to strictly stick to RomM's API and method of syncing saves and states, ensuring we don't break anything and support RomM's latest features.
- **Secure and Human-Coded**
- Each release is signed. The payload checks the signature before it updates itself, and ProsperoStore checks every download against the release's checksum. This app was primarily coded by a **human**, and will be frequently updated and maintained. AI was primarily used for explaining concepts, creating documentation, and converting data. Ya know, the tedious stuff.
- **Easter Eggs**
- Check in the Settings, scroll the settings list, see if you find the X. This **hint** won't be here forever.
## Requirements
- A PS5 running etaHEN
- RomM 5.0 or newer. (5.3.0 if you want to do shared memory card syncing instead of per-game)
- RetroArch installed as a homebrew app
- A PS5 running etaHEN or onionHEN, or a PS4 running GoldHEN
- RomM 5.2.0 or newer (may work on older versions)
- Whichever other emulators you want
## Install
### PS5
| ProsperoStore | RecompStore |
| :---: | :---: |
| <img src="/assets/prospero-store-rommps.png" width="300" alt="ProsperoStore Install QR"> | <img src="/assets/recomp-store-rommps.png" width="300" alt="RecompStore Install QR"> |
### Manual Install
Download `PPSA76677.zip` from the releases page, unzip it and copy the `PPSA76677` folder to `/data/homebrew/` on the console. Launch RommPS and it will deploy the background service, and set itself to autostart with etaHen or onionHen.
If you'd rather run just the payload, without the app:
1. Download the latest `romm-sync.elf` from the releases page.
2. Send it to the console (or use whichever favorite tool you wish):
```sh
nc -w 1 <ps5-ip> 9021 < romm-sync.elf
```
3. A RomM Sync tile appears on the home screen after about 30 seconds. Open it, or go to `http://<ps5-ip>:8780` from another device.
3. Set it up at `http://<ps5-ip>:8780` from another device.
To start RomM Sync with the console, copy the payload to etaHEN's autostart folder or use the autoloader. You can do this from the last setup step, or with `make install PS5_HOST=<ps5-ip>` if you build it yourself.
### PS4
HTTP and HTTPS servers both work. Common certificate authorities (the Mozilla list) are built in, so there's nothing to copy for HTTPS. If your server uses a self-signed certificate, tick _Skip certificate checks_ during setup, or turn off _Verify TLS certificates_ in Settings. If it uses a certificate from your own CA, you can instead put that CA's certificate in `/data/romm-sync/cacert.pem`; it's trusted in addition to the built-in list.
> **Experimental.** The PS4 is currently considered experimental
Needs GoldHEN 2.4b18.5 or newer, whose payload menu runs ELF payloads.
R
1. Download the latest `romm-sync-ps4.pkg` from the releases page and install it with your package installer.
2. Open **RommPS** from the home screen. It copies its payload, `romm-sync-ps4.elf`, to `/data/payloads/`.
3. In GoldHEN Settings, open the payloader, pick `romm-sync-ps4.elf` and run it. Add it to the AutoRun queue there and it starts on every jailbreak.
4. Open **RommPS** again: it shows the web UI. It's also at `http://<ps4-ip>:8780` from another device.
The app only installs the payload and shows the web UI; the payload does the syncing, in the background, whether or not the app is open. Without the package, copy `romm-sync-ps4.elf` to `/data/payloads/` yourself (GoldHEN's FTP server, or `make install-ps4 PS4_HOST=<ps4-ip>`).
**Note:** Don't send the payload to GoldHEN's network loader on port 9090: GoldHEN 2.4 crashes on ELFs sent there. Make sure the console's date and time are set (GoldHEN can keep them updated), or HTTPS won't work.
### How it runs
RommPS starts with your HEN, and setup turns this on for you. Both HENs start `payloads/romm-sync.elf` when `romm-sync.elf.auto_start` sits next to it, and RommPS puts both in every HEN's folder on the console (`/data/etaHEN/payloads` and `/data/OnionHEN/payloads`), so switching HENs keeps working. If a payload manager like pldmgr has its own copy, that copy is kept up to date too. If you build it yourself, `make install PS5_HOST=<ps5-ip>` copies it over FTP (add `PS5_FTP=ftp://<ps5-ip>:1337 PS5_HEN_DIR=/data/OnionHEN` for onionHEN's FTP plugin).
## Updates
Settings shows when a new version is out (it checks GitHub once a day, which you can turn off) and can install it for you. Nothing installs on its own. The update waits until no sync, download or game is running, replaces the payload in etaHEN's autostart folder (keeping the old one as `romm-sync.elf.bak`), and restarts RomM Sync.
- On the PS5, just update RommPS: from ProsperoStore (or other homebrew storefronts eventually), or by copying the new zip's folder over the old one. The background service comes with it, and RommPS swaps it in the next time you open it, once nothing is syncing or downloading. An older version never replaces a newer one.
- On the PS4, the payload updates itself from Settings.
Each release is signed. RomM Sync only installs an update whose signature matches a key built into it and whose file matches the signed checksum, so a modified download is rejected.
## Setup Wizard
## Setup
The setup walks you through five steps:
The setup wizard walks you through five steps:
1. Connecting to your server.
2. Approving the console in RomM, by scanning a QR code or entering a code.
@@ -54,58 +86,78 @@ The setup walks you through five steps:
4. Checking what the first sync will do.
5. Choosing when to sync in the background.
**Back up your saves before the first sync.** Copy your emulator save folders to a computer or USB drive. RomM Sync keeps a local copy before replacing any file, but your own backup is the safest option.
**Back up your saves before the first sync.** Copy your emulator save folders to a computer or USB drive. RommPS keeps a local copy before replacing any file, but your own backup is the safest option.
## PS2 Memory Cards
## Emulators
RommPS has built-in profiles for most known emulators on the PS5, and will set the correct default game, save/save state, and bios directories for them. Since there is a huge influx of new emulators popping up, I will be doing my very best to keep up with them and add profiles accordingly.
LRPS2 keeps every game on one shared memory card by default. RomM stores saves per game, so pick one of these during setup:
Note: RommPS relies on your emulator **NOT** being sandboxed. Some emulators require a separate payload that must be launched to be able to access outside of the sandbox, so make sure you've followed their instructions to properly install. Sandboxed emulators will not be able to read games or save files.
- **One card per game.** Works with any RomM version. In RetroArch, open the LRPS2 core options and turn off _Shared Memory Cards_. After that, each game's card syncs like any other save. Saves already on the shared card stay there.
- **Back up the shared card.** Needs RomM 5.3.0 or newer. The whole card is uploaded when it changes, and you can restore it from Settings. It isn't merged with other devices.
> The emulators listed below are those I've tested and have created profiles for to auto-detect them. You can also manually set the directories for each emulator/platform via the PS5 app or or the Web UI; you can use whichever emulators you wish.
PSP, GameCube and Wii saves are synced as one zip per game, the same format other RomM clients use. Depending on the client, they may or may not support unzipping the saves (I don't truly know), but this client does!
### PS5
My opinion? One card per game is better since 8MB memory cards fill up quick.
| Emulator | Systems | Game Downloads | Saves | Comments |
| --- | --- | --- | --- | --- |
| [RetroArch](https://github.com/mihawk-99/PS5_RetroArch) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Full Support | Full Support | - |
| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | States work with RomM's web player (PCSX-ReARMed or Beetle, whichever PSXS5 is set to) |
| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | Plays PSXS5's games, keeps its own saves in `/data/SwanStationPS5`. Its states only load in SwanStationPS5 |
| [PS5SX2](https://github.com/Swordpdf/PS5SX2) | PS2 | Full Support | Its two shared memory cards, backed up to RomM (5.3 or newer) | - |
| [PS5N64](https://github.com/Globaleliteee/PS5N64) | N64 | Full Support | Full Support | - |
| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Untested | Untested | Preset uses `/data/PS5NES/games` and `/data/PS5NES/saves`. GH page gone |
| [Porpoise](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5) | Wii/GameCube | Full Support | N/A | Couldn't exit sandbox w/o crash. [Issue](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5/issues/18) reported. Downloading games should work if the emulator is actually working for you.|
| [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Manual Support | Researching | - |
| [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - |
### PS4
| Emulator | Systems |
| --- | --- |
| RetroArch | The RetroArch systems above, wherever the PS4 build has the core |
| pSNES, pNES, pGEN, pGBA, pFBN | SNES; NES; Genesis, Master System, Game Gear, Sega CD, SG-1000; GBA, GBC, GB; arcade |
### Save states
Some users have recently reported issues with being able to pull down save states from their servers. They don't see them on the destination save folder for that emulator, or if they do, they don't work. Save states typically cannot be used across different emulators (cores), and even within the same core, the definition of a save state may different with the file version, and version of the app. In my opinion, save states should be for quick resume or to save you when you forgot to save your game, so they truly don't need to be synced across devices.
## PS4/PS5 Games
If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion.
## Web UI
RommPS exposes a separate Web UI at http://your-ps5-ip:8780 where you can access all the same features present in the native app.
## Emulator Profiles
Settings has a JSON editor for emulator profiles, and a table showing which folders are used for each platform. A profile lists an emulator's folders and the RomM platforms it plays:
```json
{
"id": "retroarch",
"name": "RetroArch",
"root": "/data/homebrew/PPSA99169",
"retroarch_cfg": "{root}/config/retroarch.cfg",
"rom_dir": "{root}/content/{platform}",
"save_dir": "{root}/savefiles",
"state_dir": "{root}/savestates",
"bios_dir": "{root}/system",
"save_exts": ".srm,.sav",
"platforms": [{ "romm": ["snes", "sfam"], "dir": "snes", "emulator": "snes9x", "core_name": "Snes9x" }]
}
```
For RetroArch, it reads save and state folders from its own config, including its "sort by core" and "sort by content" options. RomM Sync never changes the emulator's settings. A save found outside the folder the emulator reads is listed on the status page and left alone.
I've included direct support for most of the emulators that are out right now, except for the ones that won't even launch... you can sync games, states, and saves anywhere you want, but the presets should do it for most people. You can also configure the paths for all platforms manually. The web UI has a JSON editor for your emulator profiles.
## Building
Both consoles build from the same code in `src/`. What differs lives in `platform/<console>/`: `platform.c` (the `src/platform.h` interface), the make rules and the Docker SDK image. `platform/console/` has the parts the PS4 and PS5 share, and `platform/host/` is the development build.
```sh
make host tools # macOS or Linux build for development, plus tools/release-sign
tools/ps5-build.sh # PS5 build in Docker, output in build/ps5/
tools/ps4-build.sh # PS4 payload in Docker, output in build/ps4/
tools/ps4-build.sh ps4-pkg # plus the PS4 home screen app (platform/ps4/app)
tests/e2e.sh # end-to-end tests against a mock RomM server
tests/autostart.sh # autostart, version checks and HEN folders
```
## Releasing
## Watch Development Progress
Merge a pull request from a branch named with the version, such as `release/v1.0.2`. The release workflow tags the merge commit, builds the payload, signs it and publishes the release. You can also push a `v1.2.3` tag yourself.
I've started streaming the development of this app as well as other side projects on my YouTube channel. Nothing serious, but you can catch me live streaming. ;)
Signing needs the `RELEASE_SIGNING_KEY` repository secret: the 64-character hex key printed by `build/host/release-sign keygen`. Its public half must be in `src/update_keys.h`, which also holds a backup key that is kept offline. If the release key is ever lost or leaked, sign the next release with the backup key, and ship a new key pair in that release.
[My Channel](https://www.youtube.com/@soliton_radar)
## P.S to the industry
If buying isn't **owning**, then piracy **isn't** stealing. 🖕
## Shameless Plug
<a href='https://ko-fi.com/M7G128GENT' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi4.png?v=6' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
## License
GPL-3.0. Parts of the PS5 code are adapted from [ftpsrv](https://github.com/ps5-payload-dev/ftpsrv) and [websrv](https://github.com/ps5-payload-dev/websrv) by John Törnblom. [cJSON](https://github.com/DaveGamble/cJSON) and [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) are MIT licensed. [Monocypher](https://monocypher.org) is CC0 or BSD-2-Clause.
GPL-3.0. Parts of the console code are adapted from [ftpsrv](https://github.com/ps5-payload-dev/ftpsrv) and [websrv](https://github.com/ps5-payload-dev/websrv) by John Törnblom. [cJSON](https://github.com/DaveGamble/cJSON) and [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) are MIT licensed. [Monocypher](https://monocypher.org) is CC0 or BSD-2-Clause.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

+240
View File
@@ -0,0 +1,240 @@
# Emulators
Generated by `tools/emulators-doc.py` from `platform/*/emulators.json` and `platform/retroarch_cores.json`: edit those, then run it again.
`{root}` is the emulator's folder, `{game_id}` the disc's id, `{serial}` a PS1 disc's serial, `{rom_stem}` the game file's name without its extension.
Memory cards: one method per system, never both. A card of each game's own syncs as that game's save. Shared cards hold every game's saves at once: they sync whole, as RomM memory cards, between the consoles that keep them (RetroArch's cores on cards of their own); when both sides changed, the newer wins and the other is kept.
## PS5
### PSXS5
One memory card per game, named after the game file (after the disc with PCSX-ReARMed).
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| psx, ps1, playstation | `{root}/games` | `{root}/saves` | `.mcd` | `{serial}_1{ext}` | `{root}/states` | `.state*` | one per game |
### SwanStationPS5
Plays the games in PSXS5's folder. One memory card per game, named after the game file. Its save states load only in SwanStationPS5.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| psx, ps1, playstation | `/data/PSXS5/games` | `{root}/saves` | `.mcd` | `{rom_stem}_1{ext}` | `{root}/states` | `.state*` | one per game |
### PSSwanStation
One memory card per game, named after the game file. Its save states load only in PSSwanStation.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| psx, ps1, playstation | `{root}/games` | `{root}/data/saves` | `.mcd` | `{rom_stem}_1{ext}` | `{root}/data/states` | `.state` | one per game |
### PS64
Games save into their own disk images. Save Disks sync; there are no save states.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| c64 | `{root}/games` | `{root}/emulator/saves` | `.d64` | `{rom_stem}.save0{ext}` | `{root}/emulator/saves` | `.vsf` | |
### PS5SX2
Two memory cards for every game, backed up to RomM (5.3 or newer).
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| ps2 | `{root}/games` | not synced | | | not synced | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{root}/Mcd001.ps2`, `{root}/Mcd002.ps2` |
### PS5N64
Saves and states are named after a hash of the game file.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| n64 | `{root}/games` | `{root}/saves` | `.sav` | `{rom_fnv64}{ext}` | `{root}/saves` | `.state` | one per game |
### PS5NES
Untested: v7 may look for games in PS5SX2's folder.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| nes, famicom | `{root}/games` | `{root}/saves` | `.sav` | `{rom_fnv64}{ext}` | `{root}/saves` | `.state*` | |
### Porpoise
Dolphin, with its user folder in {root}/saves/User.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| ngc, gamecube | `{root}/games` | `{root}/saves/User` | a zip per game (gci), from `{root}/saves/User/GC` | | `{root}/states/{game_id}` | `.state` | one per game |
| wii | `{root}/games` | `{root}/saves/User` | a zip per game (wii), from `{root}/saves/User/Wii` | | `{root}/states/{game_id}` | `.state` | |
### PSFlyCast
Each game's own memory card syncs as its save; the shared ones (Own memory card for each game off) are backed up as RomM memory cards. Save states don't sync yet.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| dc, dreamcast | `{root}/games` | `{root}/data` | `.bin` | `{game_id}_vmu_save_A1{ext}` | not synced | | one per game; can be shared by every game. One per game: Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs) (read from `{root}/emu.cfg`, `PerGameVmu`). Shared cards (synced whole, while the system uses shared cards): `{root}/data/vmu_save_A1.bin`, `{root}/data/vmu_save_B1.bin`, `{root}/data/vmu_save_C1.bin`, `{root}/data/vmu_save_D1.bin` |
### Seedee5
Games and BIOS files only: saved games are kept as folders, and only after a clean exit.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| philips-cd-i | `{root}/roms` | not synced | | | not synced | | |
### XPSemu
Games and BIOS files only: saves live inside its hard disk image.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| xbox | `{root}/games` | not synced | | | not synced | | |
### PS5CEMU-HAR
Wii U (Cemu), 3DS (Azahar) and DS (melonDS). Wii U and 3DS saves sync; 3DS save states load only in PS5CEMU-HAR. DS: games and BIOS files only for now.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| wiiu | `{root}/games` | `{rom_dir}` | a zip per game (wiiu), from `{root}/mlc01/usr/save` | | `{rom_dir}` | `.state*` | |
| 3ds | `{root}/azahar/games` | `{rom_dir}` | a zip per game (3ds), from `{root}/azahar/sdmc/Nintendo 3DS/00000000000000000000000000000000/00000000000000000000000000000000/title` | | `{root}/azahar/states` | `.cst` | |
| nds | `{root}/melonds/games` | not synced | | | not synced | | |
### ProsperoEden
Games (with their updates and DLC) and keys only: saves are kept per Switch user profile. Its Switch UI app shares the same folders.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| switch | `{root}/roms` | not synced | | | not synced | | |
## PS4
### pSNES
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| snes, sfam | `{root}/roms` | `{root}/sram` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
### pNES
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| nes, famicom | `{root}/roms` | `{root}/save` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| fds | `{root}/roms` | `{root}/save` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
### pGEN
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| genesis, genesis-slash-megadrive, megadrive | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| sms, mastersystem | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| gamegear, gg | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| segacd, sega-cd | `{root}/{platform}` | `{root}/rams` | `.brm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| sg1000 | `{root}/{platform}` | `{root}/rams` | `.srm` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
### pGBA
Saves and states share the saves folder.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| gba | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| gbc | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| gb | `{root}/{platform}` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
### pFBN
Arcade saves are the games' own memory (eeproms), backed up as they are.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| arcade, fbneo, neogeoaes, neogeomvs, cps1, cps2, cps3 | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| neo-geo-pocket-color, neo-geo-pocket, ngpc, ngp | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| colecovision | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| msx | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| zxs, zx-spectrum | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| supergrafx | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
| channel-f, fairchild-channel-f | `{root}/{platform}` | `{root}/eeproms` | `.fs` | `{rom_stem}{ext}` | `{root}/saves` | `.state` | |
### EmuSuNi
Set EmuSuNi's N64 folder to /data/EmuSuNi/n64 in its settings.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| snes, sfam | `{root}/games` | `{root}/saves` | `.srm` | `{rom_stem}{ext}` | `{root}/states` | `.state*` | |
| nes, famicom | `{root}/nes` | `{root}/saves/nes` | `.srm` | `{rom_stem}{ext}` | `{root}/states/nes` | `.state*` | |
| n64 | `{root}/n64` | `{root}/saves/n64` | `.srm` | `{rom_stem}{ext}` | `{root}/states/n64` | `.state*` | one per game |
### DolphinPS4
Dolphin, with its user folder in {root}/User. States are named after the disc's id.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| ngc, gamecube | `{root}/games` | `{root}/User` | a zip per game (gci), from `{root}/User/GC` | | `{root}/User/StateSaves` | `.s0*` | one per game |
| wii | `{root}/games` | `{root}/User` | a zip per game (wii), from `{root}/User/Wii` | | `{root}/User/StateSaves` | `.s0*` | |
### Resonance
Each game's own memory card syncs as its save; the shared ones are backed up as RomM memory cards.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| dc, dreamcast | `{root}/games` | `{root}/data` | `.bin` | `{game_id}_vmu_save_A1{ext}` | `{root}/data` | `.state` | one per game; can be shared by every game. One per game: Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs) (read from `{root}/emu.cfg`, `PerGameVmu`). Shared cards (synced whole, while the system uses shared cards): `{root}/data/vmu_save_A1.bin`, `{root}/data/vmu_save_B1.bin`, `{root}/data/vmu_save_C1.bin`, `{root}/data/vmu_save_D1.bin` |
### PS4SX2
Two memory cards for every game, backed up to RomM (5.3 or newer). No save states yet.
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| ps2 | `{root}/roms` | not synced | | | not synced | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{root}/memcards/Mcd001.ps2`, `{root}/memcards/Mcd002.ps2` |
### NPS64
Saves and states are named after a hash of the game file (.z64 dumps).
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| n64 | `{root}/games` | `{root}/saves` | `.srm` | `rom-{rom_fnv64}{ext}` | `{root}/saves` | `.state` | one per game |
### mGBA
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| gba | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
| gbc | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
| gb | `{root}/roms` | `{root}/saves` | `.sav` | `{rom_stem}{ext}` | `{root}/states` | `.ss*` | |
### SNESUI
| System | Games | Saves | Save files | Saved as | States | State files | Memory cards |
|---|---|---|---|---|---|---|---|
| snes, sfam | `{root}/juegos` | `{root}/guardados` | `.srm` | `{rom_stem}{ext}` | `{root}/guardados` | `.st0,.st1,.st2,.st3` | |
## RetroArch (both consoles)
From `platform/retroarch_cores.json`. Saves default to `<game>.srm` (`.sav`, `.rtc`) in RetroArch's save folder and states to `<game>.state*`, unless a rule says otherwise.
| Core | System | Save files | Saved as | Memory cards | Notes |
|---|---|---|---|---|---|
| LRPS2, PCSX2 | ps2 | `.ps2` | | shared by every game; can be one per game. One per game: Turn off Shared Memory Cards in LRPS2's core options. Shared cards (synced whole, while the system uses shared cards): `{bios}/pcsx2/memcards/Mcd001.ps2`, `{bios}/pcsx2/memcards/Mcd002.ps2` | Per game: <save folder>/<game>.ps2 (slot 1 only). Shared (the default): <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2. |
| PS5SX2 | ps2 | | | shared by every game. Shared cards (synced whole, while the system uses shared cards): `{bios}/pcsx2/memcards/Mcd001.ps2`, `{bios}/pcsx2/memcards/Mcd002.ps2` | Shared only: <system>/pcsx2/memcards/Mcd001.ps2 and Mcd002.ps2. |
| SwanStation | psx | `.srm,.mcd` | | one per game; can be shared by every game. One per game: Set Memory Card 1 Type to Libretro in SwanStation's core options | Card 1: <game>.srm (Libretro, the default), <serial>_1.mcd (per game by code) or <game>_1.mcd (by title). Shared: duckstation_shared_card_1.mcd. |
| *Beetle PSX* | psx | | | one per game; can be shared by every game. One per game: Turn off Shared Memory Cards in the core options | Card 1: <game>.srm. Shared cards (mednafen_psx_libretro_shared.N.mcr) need the Mednafen card method. |
| PCSX-ReARMed | psx | | | one per game | Card 1: <game>.srm. |
| Flycast | dc | `.bin` | `{game_id}.A1{ext}` | shared by every game; can be one per game. One per game: Set Per-Game VMUs to VMU A1 in Flycast's core options. Shared cards (synced whole, while the system uses shared cards): `{bios}/dc/vmu_save_A1.bin`, `{bios}/dc/vmu_save_B1.bin`, `{bios}/dc/vmu_save_C1.bin`, `{bios}/dc/vmu_save_D1.bin` | Shared (the default): <system>/dc/vmu_save_<port>.bin. Per-Game VMUs on VMU A1 or All VMUs: <save folder>/<disc id>.A1.bin (<game>.A1.bin before late 2024), the same raw 128 KB card as PSFlyCast's. |
| any | saturn | `.srm,.bcr,.bkr` | | one per game; can be shared by every game. One per game: Turn off Shared Internal and Backup Memory in Beetle Saturn's core options | Beetle Saturn: internal backup memory as <game>.srm (its Libretro save method, the default) or <game>.bkr; the cartridge's as <game>.bcr. |
| *ParaLLEl*, *Mupen64Plus* | n64 | | | one per game | Controller paks live in each game's .srm (EEPROM, four paks, SRAM, flash): there's no shared one. |
| PPSSPP | any | a zip per game (psp) | | | |
| Dolphin | wii | a zip per game (wii) | | | |
| Dolphin | any | a zip per game (gci) | | one per game | A GCI folder: one file per save, so each game's own; synced as a zip of the game's .gci files. |
| Azahar, Citra | any | a zip per game (3ds) | | | |
+49
View File
@@ -0,0 +1,49 @@
# Release checklist
Run all of this on a real console before tagging. Tests in CI (`tests/e2e.sh`, `tests/autostart.sh`) cover the logic, not the console.
## Before
- `kMinPayload` in `rommps_app.hpp`, `contentVersion` in RommPS's `param.json` and `APP_VERSION` in `src/util.h` all match the tag.
- `docs/releases/<tag>.md` is written. ProsperoStore shows it as the release notes.
- If this release changes who owns something on the console (the home screen icon, starting the payload, the autostart files, updates, the data folder), the old one gets removed or migrated in this same release.
## Upgrades
For each one, start from the old version actually installed and set up, then update. Check afterwards:
- one icon on the home screen (RommPS), no "RomM Sync" tile
- one RomM Sync running, the new version (`/api/status`), lock file says the same
- every HEN folder that exists has the new `romm-sync.elf` and `.auto_start` (`/api/autostart` lists the copies and their versions)
- a pldmgr copy, if there was one, is the new version
- no `.bak` files left in the payload folders
- a sync runs and finishes without errors
- no RomM Sync notifications while RommPS is installed
| From | How | Also check |
| --- | --- | --- |
| Nothing | ProsperoStore install, open RommPS | autostart is on, with no switch to turn it off |
| Nothing | zip copied to `/data/homebrew` | same as above |
| 1.0.2 payload only, tile, etaHEN folder | in-app update from the web UI | tile stays (no RommPS), "Install RommPS" notice |
| 1.0.2 as above | install RommPS, open it | tile goes away |
| previous RommPS + payload | store update of RommPS, open it | payload swapped once idle, copies updated |
| previous RommPS + payload | payload's own update first, RommPS later | "RommPS is out of date" notice |
| any, autostart off or its flag renamed to `.off` | update, reboot | autostart is back on in every HEN folder, no `.off` left |
| any, old copy in pldmgr's autoload | update | the pldmgr copy is updated, its autoload list untouched |
## Boot and rest mode
- Reboot, jailbreak with onionHEN only, don't open RommPS: RomM Sync starts by itself, one copy.
- Same with etaHEN.
- Rest mode, then wake: same process keeps running (uptime carries on), web UI answers.
- Open RommPS right after a jailbreak: it waits for RomM Sync instead of starting a second copy.
## Never downgrade
- Send an older `romm-sync.elf` to port 9021 while a newer one runs: it exits, the newer one keeps running.
- Upload an older payload in the web UI's autostart: the newer copies on disk stay.
## After
- The release workflow published the payloads, then `tools/release-app.sh <tag> --upload` attached `PPSA76677.zip`.
- The catalog bot's pull request for the new version shows up the next day.
+31
View File
@@ -0,0 +1,31 @@
Install RommPS from ProsperoStore, open it, and you're set up: it carries RomM Sync and starts it for you. Everything below is new since 1.0.2.
### What's new
- RommPS, a native PS5 app. Your whole RomM library on the TV, browsable with the controller: platforms, an A to Z bar, search across everything, game details (year, genre, play time, How Long to Beat) and one-press downloads. It runs on the same Vulkan homebrew UI as mihawk-99's apps, so it's fast and looks great.
- Standalone PS5 emulators. PSXS5 and SwanStationPS5 for PS1, PS5SX2 for PS2 and PS5N64 for N64 now sync saves and states, not just RetroArch.
- More than one emulator per system. Pick a main one, and the others get the same games. Where the save format matches, they share one save.
- Your own folders. If setup doesn't recognise an emulator (or doesn't know a system), point it at the folders yourself.
- Covers are kept on the console, so the library opens fast and scrolling through hundreds of games doesn't wait on the server.
- Download speeds per game and in total, and you can cancel a download (hold Cross).
- Games with more than one version in RomM now say what sets each one apart, like the region or the disc.
### Fixed and changed
- Junk your RomM scan picked up, like a frontend's metadata.txt, no longer shows up as a game or downloads. Neither do manuals and soundtracks inside a game's folder.
- Downloads show the game's name right away instead of "Game #id".
- Pairing with a different RomM server no longer mixes up the old server's games and saves.
- The PS4 build is experimental. I've only tested it with RetroArch so far.
### Good to know
- PS5NES and Porpoise don't have presets yet, since neither works right on my console. If yours does, set it up with your own folders.
- You need RomM 5.2.0 or newer (5.3.0 to back up shared PS2 memory cards), and a PS5 with etaHEN or onionHEN plus kstuff.
- RommPS starts RomM Sync by itself and keeps it up to date with the app. Setup's last step makes it start with every jailbreak.
### Which file do I need?
- PS5: install RommPS from ProsperoStore. Or unzip PPSA76677.zip and copy the PPSA76677 folder to /data/homebrew/. That's all you need.
- PS5 without the app: send romm-sync.elf to your HEN's payload loader (port 9021) and set it up from a browser at port 8780.
- PS4 (experimental): install romm-sync-ps4.pkg and open RomM Sync, then run romm-sync-ps4.elf from GoldHEN's payloader.
- The manifest files are for the in-app updater, and SHA256SUMS is for checking downloads. You don't need either.
+20
View File
@@ -0,0 +1,20 @@
Update RommPS and open it once. It takes care of the rest.
### Fixed
- The extra "RomM Sync" icon is gone.
- RomM Sync now starts with onionHEN too.
- Old copies of RomM Sync no longer start or replace newer ones.
- RommPS shows the right time for the last sync.
- Saving settings in the web UI no longer undoes changes made in RommPS.
- The library picks up emulator changes right away, without restarting RommPS.
### Added
- RomM Sync starts with the console by default.
- New home screen art.
### Changed
- RomM Sync updates come with RommPS.
- No more RomM Sync notifications while RommPS is installed, except sync results.
+25
View File
@@ -0,0 +1,25 @@
Update RommPS and open it once. It updates everything else.
### New
- Saves come down as soon as a game finishes downloading, so it's ready to play.
- Save states only come down to an emulator that can load them.
- Save sync is optional. Use RomM just for games, or pick per platform: saves and states, saves only, or off.
- Home has Conflicts, Activity (what synced and why) and Tracked saves. Each game has its own saves view, with restore.
- Settings > Emulators is organized by platform: its emulator, folders, save sync and settings in one place.
- GameCube saves keep the form RomM has them in: a zip, a single .gci, or a whole memory card. Other saves on a card are left alone.
- Porpoise is supported, including its save states. SwanStationPS5 and PS5NES are offered too.
- Settings > Diagnostic bundle, for bug reports and troubleshooting.
- Hold Options to close RommPS gracefully, avoiding any weird behaviors that you often see when hard closing homebrew apps.
### Fixed
- A save or state deleted from the console comes back from RomM.
- Older saves from before RomM's save slots now reach the console.
- A blank PS1 memory card from RomM never replaces one with saves on it.
- Games in folders download with their file names.
- Notifications say what moved and for which games, and every download says when it's done.
### Good to know
- RomM's web player and the PS5 emulators don't always use the same core. A state only comes down where it can load.
+15
View File
@@ -0,0 +1,15 @@
Update RommPS and open it once. It updates everything else.
### New
- RetroArch 2.0 support. Its games live in new folders, named after each system (`content/Nintendo - Game Boy Advance`). New downloads go there, and RommPS offers to move the games already in the old folders. Saves and states stay where they are.
- RetroArch 2.0 plays PS2 with PS5SX2. Its two shared memory cards are backed up to RomM.
- Covers keep their own shape. Square and wide boxes (SNES, N64 and others) are no longer squeezed into portrait cards.
### Fixed
- PS2 BIOS files for RetroArch go to `system/pcsx2/bios`, where its PS2 cores look for them.
### Good to know
- PS2 save states made with LRPS2 don't load in PS5SX2. Save in-game before upgrading RetroArch.
+52
View File
@@ -0,0 +1,52 @@
Update RommPS and open it once. It updates everything else.
### New
- Extra emulators no longer get their own copy of every game. Their games folder is mounted from the system's main emulator while they run (PSSwanStation, PSXS5, SwanStationPS5), and the old copies are removed.
- Groundwork for keeping all your games, saves and states in one RommPS folder, mounted into every emulator.
- Changing a system's main emulator moves its RomM games to the new one. If other games are in the old one's folder, RommPS asks what to do with them.
- BIOS files sync from RomM to every emulator that needs them, with the name each one expects. PS2 emulators get a single BIOS. Your own BIOS files are never replaced.
- Shared memory cards (PS2, Dreamcast) now sync both ways between your consoles. Each system uses either shared cards or one card per game.
- New emulators: PS64 (C64), PSSwanStation, PSFlyCast (Dreamcast), Seedee5 (CD-i), XPSemu (Xbox), PS5CEMU-HAR (Wii U, 3DS, DS) and ProsperoEden (Switch).
- Switch games download with their latest update and DLC, into the folders ProsperoEden reads. Each game gets one card in the library, and its details show the title ID of the game, its update and each DLC.
- New saves that sync: Wii U and 3DS (with 3DS save states), Dreamcast VMUs in PSFlyCast and RetroArch's Flycast core, Beetle Saturn cartridge saves and SwanStation .mcd cards.
- RetroArch recognizes 14 more systems, including 3DS, Jaguar, 3DO, PC-FX and Neo Geo CD.
- Redesigned connection handling: RommPS keeps its connections to RomM open instead of opening a new one for every cover and page, so it's much lighter on your server. When RomM stops answering, RommPS says so and waits instead of retrying over and over.
- Downloads cut off halfway carry on where they stopped, and ones that failed while RomM was away start again once it's back. Failed or cancelled downloads can be retried from the Queue.
- Emulators installed after setup are announced once. Turn them on in Emulator Settings.
- Archived games (.rar, or any archive for Switch) aren't downloaded. RommPS asks you to unpack them on the server.
- The Downloads tab is now Queue. A new Downloads tab lists the games on the console and replaces the library's "On this console" row. Same in the web UI.
- Game details show the rating, and button hints stay readable over any cover.
- Interface sounds can be turned off in Settings.
- Activity shows BIOS files and moved games.
### PS4
- Works on firmware 13.52 and 14.00.
- New emulators: pSNES, pNES, pGEN, pGBA and pFBN (now with save states), EmuSuNi, DolphinPS4, NPS64, SNESUI, PS4SX2, Resonance and RArch.
- GameCube saves are shared with the PS5. N64 saves are shared between NPS64, EmuSuNi and RetroArch.
- Resonance's memory cards sync.
- When the sync service isn't running, RommPS says why.
- Big libraries no longer run the sync service out of memory while browsing, which could fail downloads.
- The PS4 app is now called RommPS and installs over the old one.
- The web UI is faster.
### Fixed
- PSXS5 2.0 save states and memory cards sync again.
- Large save states (GameCube) sync both ways.
- PS1 and PS2 game lists with lots of info load again.
- Turning on many emulators at once no longer stops the sync service.
- A game deleted outside RommPS no longer shows as downloaded.
- Each console shows on RomM under its own name.
- Accented letters in names (Pokémon) show as they should, instead of "?".
- A game whose file was moved or renamed on the RomM server says so, instead of a bare HTTP error.
- General fixes and improvements.
### Good to know
- Update all your consoles. A console still on 1.2 can't read Wii saves made with 1.3.
- Shared memory cards become one card on RomM for all your consoles. The first sync keeps the card played last, and a blank card never replaces one with saves. The other stays in your backups.
- RommPS now always starts with the console, and the setting to turn that off is gone.
- Re-run setup wizard moved to Settings > RomM.
- 3DS save states only load in PS5CEMU-HAR.
+375
View File
@@ -0,0 +1,375 @@
/* platform.h parts shared by the PS4 and PS5 builds (console.h).
*
* Adapted from John Törnblom's GPLv3 ftpsrv and websrv: process lookup and the
* single-instance check (ftpsrv main-orbis.c and main-prospero.c, which use the
* same kinfo_proc offsets), notifications and the LAN address (websrv). */
#include "console.h"
#include "util.h"
#include <arpa/inet.h>
#include <errno.h>
#include <pthread.h>
#include <fcntl.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/file.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/sysctl.h>
#include <sys/syscall.h>
#include "platform.h"
/* libkernel: same layout as ftpsrv/websrv notify.c, on both consoles */
typedef struct notify_request {
char useless1[45];
char message[3075];
} notify_request_t;
int sceKernelSendNotificationRequest(int, notify_request_t *, size_t, int);
int console_mkdir_p(const char *path) {
char tmp[256];
size_t len = strlen(path);
if (len == 0 || len >= sizeof tmp) return -1;
memcpy(tmp, path, len + 1);
for (char *p = tmp + 1; *p; p++) {
if (*p != '/') continue;
*p = 0;
if (mkdir(tmp, 0755) && errno != EEXIST) return -1;
*p = '/';
}
if (mkdir(tmp, 0755) && errno != EEXIST) return -1;
return 0;
}
/* sysctl(KERN_PROC, KERN_PROC_PROC), read with the offsets ftpsrv uses for the
* console kinfo_proc: ki_structsize @0, ki_pid @72, ki_tdname @447
* (TDNAMLEN+1 = 17 bytes). */
int console_for_each_proc(console_proc_cb cb, void *ctx) {
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PROC, 0};
size_t buf_size = 0;
uint8_t *buf;
int ret = 0;
if (sysctl(mib, 4, NULL, &buf_size, NULL, 0) || buf_size == 0) return -1;
buf_size += buf_size / 4; /* processes may appear between the two calls */
if (!(buf = malloc(buf_size))) return -1;
if (sysctl(mib, 4, buf, &buf_size, NULL, 0)) {
free(buf);
return -1;
}
for (uint8_t *ptr = buf; ptr + 464 <= buf + buf_size;) {
int ki_structsize = *(int *)ptr;
pid_t ki_pid = *(pid_t *)&ptr[72];
char tdname[17];
if (ki_structsize < 464 || ptr + ki_structsize > buf + buf_size) break;
memcpy(tdname, &ptr[447], 16);
tdname[16] = 0;
ptr += ki_structsize;
if ((ret = cb(ki_pid, tdname, ctx))) break;
}
free(buf);
return ret;
}
struct find_ctx { const char *name; pid_t self; pid_t found; };
static int find_cb(pid_t pid, const char *tdname, void *vctx) {
struct find_ctx *c = vctx;
if (pid != c->self && !strcmp(tdname, c->name)) {
c->found = pid;
return 1;
}
return 0;
}
static pid_t find_pid(const char *name) {
struct find_ctx c = {name, getpid(), -1};
if (console_for_each_proc(find_cb, &c) < 0) return -1;
return c.found;
}
/* Kills every other process called CONSOLE_PROC_NAME, giving up after 10 s. */
static void kill_by_name(void) {
pid_t pid;
int tries = 0;
while ((pid = find_pid(CONSOLE_PROC_NAME)) > 0) {
if (kill(pid, SIGKILL) && errno != ESRCH) break;
sleep(1);
if (++tries >= 10) break;
}
}
struct pid_ctx { pid_t pid; };
static int has_pid_cb(pid_t pid, const char *tdname, void *vctx) {
struct pid_ctx *c = vctx;
return pid == c->pid && !strcmp(tdname, CONSOLE_PROC_NAME);
}
/* "1.2.3" into numbers; anything after the patch is ignored. */
static int parse_version(const char *s, int v[3]) {
v[0] = v[1] = v[2] = 0;
if (*s == 'v') s++;
return sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]) >= 2 ? 0 : -1;
}
/* Whether this copy should take over from one running `have`. A version that
* doesn't parse (a hand-made build) is sent on purpose, so it takes over. */
static int takes_over(const char *mine, const char *have) {
int x[3], y[3];
if (parse_version(mine, x) || parse_version(have, y)) return 1;
for (int i = 0; i < 3; i++)
if (x[i] != y[i]) return x[i] > y[i];
return 0;
}
/* Held for as long as this copy runs; the kernel lets go when the process
* ends, however it ends. The file says which version holds it and its pid. */
static int g_lock_fd = -1;
/* One line in the log, written before the log is set up: a copy that quits
* here would otherwise leave no trace. */
static void note(const char *fmt, ...) {
char line[256], when[32];
time_t now = time(NULL);
struct tm tm;
localtime_r(&now, &tm);
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm);
int n = snprintf(line, sizeof line, "%s [INFO] ", when);
va_list ap;
va_start(ap, fmt);
n += vsnprintf(line + n, sizeof line - (size_t)n - 1, fmt, ap);
va_end(ap);
if (n > (int)sizeof line - 2) n = (int)sizeof line - 2;
line[n++] = '\n';
int fd = open(CONSOLE_DATA_DIR "/romm-sync.log", O_WRONLY | O_APPEND | O_CREAT, 0666);
if (fd < 0) return;
write(fd, line, (size_t)n);
close(fd);
}
static int lock_wait(int fd, int seconds) {
for (int i = 0; i <= seconds * 4; i++) {
if (!flock(fd, LOCK_EX | LOCK_NB)) return 0;
usleep(250 * 1000);
}
return -1;
}
int console_single_instance(const char *version) {
console_mkdir_p(CONSOLE_DATA_DIR);
int fd = open(CONSOLE_LOCK_FILE, O_RDWR | O_CREAT, 0644);
if (fd < 0) {
/* No lock to be had: run, as versions before the lock did. */
kill_by_name();
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
return 0;
}
if (flock(fd, LOCK_EX | LOCK_NB)) {
char have[32] = "";
pid_t holder = 0;
int alive = 0;
/* A copy that took the lock a moment ago may not have said who it is
* yet; until it does, the file still names the copy before it. */
for (int tries = 0; tries < 8 && !alive; tries++) {
char buf[64] = "";
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
if (n > 0) buf[n] = 0;
have[0] = 0;
holder = 0;
sscanf(buf, "%31s %d", have, &holder);
struct pid_ctx c = {holder};
alive = holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1;
if (!alive) usleep(250 * 1000);
}
/* Held, but by no copy of ours: RommPS checking whether one runs.
* It lets go at once. */
if (!alive && !lock_wait(fd, 3)) goto locked;
if (!takes_over(version, have)) {
note("romm-sync %s not started: %s is already running (pid %d)", version, have, (int)holder);
close(fd);
return 1;
}
/* Older: stop it, but only if that pid is still RomM Sync. */
note("romm-sync %s replacing %s (pid %d)", version, have, (int)holder);
struct pid_ctx c = {holder};
if (holder > 0 && console_for_each_proc(has_pid_cb, &c) == 1) kill(holder, SIGKILL);
if (lock_wait(fd, 10)) {
note("romm-sync %s not started: %s didn't stop", version, have);
close(fd);
return 1;
}
}
locked:;
/* Say who holds the lock first, for any copy starting right now. */
char line[64];
int len = snprintf(line, sizeof line, "%s %d\n", version, (int)getpid());
if (!ftruncate(fd, 0) && len > 0) pwrite(fd, line, (size_t)len, 0);
fchmod(fd, 0644);
g_lock_fd = fd;
syscall(SYS_thr_set_name, -1, CONSOLE_PROC_NAME);
/* Copies from before the lock don't hold it: find them by name. A copy
* still deciding hasn't named itself yet, so it's safe from this. */
kill_by_name();
return 0;
}
int console_send_elf(int port, const void *elf, size_t len) {
struct sockaddr_in sa;
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)port);
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) != 0) {
int e = errno;
close(fd);
errno = e;
return -1;
}
const char *p = elf;
while (len) {
ssize_t w = write(fd, p, len);
if (w < 0 && errno == EINTR) continue;
if (w <= 0) {
int e = w < 0 ? errno : EPIPE;
close(fd);
errno = e;
return -1;
}
p += w;
len -= (size_t)w;
}
shutdown(fd, SHUT_WR);
close(fd);
return 0;
}
int console_valid_title_id(const char *s) {
for (int i = 0; i < 9; i++) {
char ch = s[i];
if (i < 4 ? !(ch >= 'A' && ch <= 'Z') : !(ch >= '0' && ch <= '9')) return 0;
}
return 1;
}
/* kern.sdk_version is 0xMMmmxxxx with the firmware version in BCD: 0x09008031 is 9.00. */
void console_describe(char *buf, size_t n, const char *host_title) {
uint32_t sdk = 0;
size_t len = sizeof sdk;
char fw[32] = "unknown";
if (!sysctlbyname("kern.sdk_version", &sdk, &len, NULL, 0) && sdk)
snprintf(fw, sizeof fw, "%x.%02x (0x%08x)", sdk >> 24, (sdk >> 16) & 0xff, sdk);
snprintf(buf, n, "%s firmware %s, pid %d, uid %d, host process %s", plat_info()->console, fw,
(int)getpid(), (int)getuid(), host_title && host_title[0] ? host_title : "none");
}
/* Notifications go through the shell, and a request waits until the shell
* takes it: with the shell hung (it has been, with the HEN's hooks in it), the
* caller would hang too. So they're handed to a thread of their own, a few
* at a time; when the shell isn't taking them, the newest are dropped, and
* nothing else ever waits on it (the mount guard least of all). */
#define NOTIFY_QUEUE 8
static pthread_mutex_t g_notify_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_notify_cond = PTHREAD_COND_INITIALIZER;
static char g_notify_queue[NOTIFY_QUEUE][256];
static int g_notify_head, g_notify_count, g_notify_started;
static void *notify_thread(void *arg) {
(void)arg;
for (;;) {
notify_request_t req;
memset(&req, 0, sizeof req);
pthread_mutex_lock(&g_notify_lock);
while (!g_notify_count) pthread_cond_wait(&g_notify_cond, &g_notify_lock);
snprintf(req.message, sizeof req.message, "%s", g_notify_queue[g_notify_head]);
g_notify_head = (g_notify_head + 1) % NOTIFY_QUEUE;
g_notify_count--;
pthread_mutex_unlock(&g_notify_lock);
/* The shell can turn one down; without this a lost notice leaves no trace. */
const int rc = sceKernelSendNotificationRequest(0, &req, sizeof req, 0);
if (rc) LOGW("notification not shown (0x%08x): %s", (unsigned)rc, req.message);
else LOGD("notification shown: %s", req.message);
}
return NULL;
}
void plat_notify(const char *fmt, ...) {
char msg[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(msg, sizeof msg, fmt, ap);
va_end(ap);
pthread_mutex_lock(&g_notify_lock);
if (!g_notify_started) {
pthread_t t;
g_notify_started = pthread_create(&t, NULL, notify_thread, NULL) == 0;
if (g_notify_started) pthread_detach(t);
}
if (g_notify_started && g_notify_count < NOTIFY_QUEUE) {
snprintf(g_notify_queue[(g_notify_head + g_notify_count) % NOTIFY_QUEUE], sizeof g_notify_queue[0], "%s", msg);
g_notify_count++;
pthread_cond_signal(&g_notify_cond);
}
pthread_mutex_unlock(&g_notify_lock);
}
const char *plat_data_dir(void) {
console_mkdir_p(CONSOLE_DATA_DIR);
return CONSOLE_DATA_DIR;
}
const char *plat_ca_bundle(void) {
struct stat st;
if (!stat(CONSOLE_CA_BUNDLE, &st) && st.st_size > 0) return CONSOLE_CA_BUNDLE;
return NULL;
}
const char *plat_name(void) { return plat_info()->name; }
/* libSceSystemService, the same on both consoles (OpenOrbis SystemService.h). */
#define SYSTEM_SERVICE_PARAM_ID_SYSTEM_NAME 6
int sceSystemServiceParamGetString(int param_id, char *buf, size_t n);
void plat_console_name(char *buf, size_t n) {
char name[128] = "";
buf[0] = 0;
if (sceSystemServiceParamGetString(SYSTEM_SERVICE_PARAM_ID_SYSTEM_NAME, name, sizeof name) != 0) return;
name[sizeof name - 1] = 0;
for (const char *c = name; *c; c++)
if ((unsigned char)*c < 0x20) return; /* not a name */
snprintf(buf, n, "%s", name);
}
void plat_local_ip(char *buf, size_t n) {
struct ifaddrs *ifaddr = NULL;
char ip[INET_ADDRSTRLEN];
if (!buf || !n) return;
buf[0] = 0;
if (getifaddrs(&ifaddr) == -1) return;
/* websrv sys.c: first AF_INET that is not loopback and not 0.x */
for (struct ifaddrs *ifa = ifaddr; ifa; ifa = ifa->ifa_next) {
if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue;
if (ifa->ifa_name && !strncmp("lo", ifa->ifa_name, 2)) continue;
struct sockaddr_in *in = (struct sockaddr_in *)ifa->ifa_addr;
if (!inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip)) continue;
if (!strncmp("0.", ip, 2)) continue;
snprintf(buf, n, "%s", ip);
break;
}
freeifaddrs(ifaddr);
}
+39
View File
@@ -0,0 +1,39 @@
/* Shared by platform/ps4 and platform/ps5. Both consoles run the same kind of
* FreeBSD userland, so notifications, the process list, the data folder and
* the ELF loader protocol work the same way; console.c implements those parts
* of platform.h. Only plain libc here, so it builds on its own. */
#ifndef ROMM_CONSOLE_H
#define ROMM_CONSOLE_H
#include <stddef.h>
#include <sys/types.h>
#define CONSOLE_PROC_NAME "romm-sync.elf"
#define CONSOLE_DATA_DIR "/data/romm-sync"
#define CONSOLE_CA_BUNDLE CONSOLE_DATA_DIR "/cacert.pem"
/* Held by the running copy; holds "<version> <pid>". RommPS reads it too. */
#define CONSOLE_LOCK_FILE CONSOLE_DATA_DIR "/romm-sync.lock"
int console_mkdir_p(const char *path);
/* Visits every process. cb returns nonzero to stop; that value is returned,
* or -1 if the list can't be read. */
typedef int (*console_proc_cb)(pid_t pid, const char *tdname, void *ctx);
int console_for_each_proc(console_proc_cb cb, void *ctx);
/* Makes this the only copy running. A copy holding the lock keeps running if
* it's the same version or newer, and 1 is returned: this copy should quit.
* Otherwise it's stopped, as is any copy from before the lock, and this one
* takes the lock and the name CONSOLE_PROC_NAME. */
int console_single_instance(const char *version);
/* Sends an ELF to a loader listening on 127.0.0.1:port. -1 with errno set. */
int console_send_elf(int port, const void *elf, size_t len);
/* PS4/PS5 title ids: 4 uppercase letters + 5 digits (CUSA00001, PPSA01234). */
int console_valid_title_id(const char *s);
/* plat_describe() with the title id of the process we run in ("" if none). */
void console_describe(char *buf, size_t n, const char *host_title);
#endif
+143
View File
@@ -0,0 +1,143 @@
/* platform.h for macOS and Linux, used for development and tests.
* ROMM_SYNC_DATA sets the data folder (default ./host-data). Writing a title id
* to <data>/foreground pretends a game is running. It presents itself as a PS5,
* so the end-to-end tests cover the PS5 paths and release assets.
* ROMM_SYNC_MOUNTS=<folder> makes it a console that can mount, with apps in
* that folder; plat_mounts_set writes its list to <data>/mounts.txt. */
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/utsname.h>
#include "platform.h"
#include "util.h"
static char g_data[PATH_MAX_LEN];
static const char *HOMEBREW_DIRS[] = {NULL, NULL}; /* ROMM_SYNC_HOMEBREW; ROMM_SYNC_MOUNTS */
static const char *const EMULATOR_DIRS[] = {NULL};
static plat_info_t INFO = {
.name = "host",
.console = "PlayStation 5",
.client = "romm-sync-ps5",
.loader = "etaHEN",
.payload = "romm-sync.elf",
.manifest = "manifest.json",
.manifest_sig = "manifest.sig",
.autostart_dir = "./host-data/etaHEN/payloads", /* ROMM_SYNC_AUTOSTART in tests */
.autostart_flag = "romm-sync.elf.auto_start",
.homebrew_dirs = HOMEBREW_DIRS,
.emulator_dirs = EMULATOR_DIRS,
.retroarch_root = "/data/homebrew/PPSA99169",
.mednafen_root = "/data/homebrew/Mednafen",
.can_relaunch = 1,
.self_update = 1, /* ROMM_SYNC_SELF_UPDATE=0 tests the PS5's way */
};
const plat_info_t *plat_info(void) { return &INFO; }
int plat_init(void) {
signal(SIGPIPE, SIG_IGN);
const char *su = getenv("ROMM_SYNC_SELF_UPDATE");
if (su && *su) INFO.self_update = atoi(su);
const char *mounts = getenv("ROMM_SYNC_MOUNTS");
if (mounts && *mounts) {
INFO.can_mount = 1;
HOMEBREW_DIRS[0] = mounts;
}
const char *d = getenv("ROMM_SYNC_DATA");
str_copy(g_data, sizeof g_data, d && *d ? d : "./host-data");
mkdir_p(g_data);
return 0;
}
void plat_notify(const char *fmt, ...) {
char msg[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(msg, sizeof msg, fmt, ap);
va_end(ap);
fprintf(stderr, "[NOTIFY] %s\n", msg);
}
int plat_running_title(char *title_id, size_t n) {
char p[PATH_MAX_LEN];
path_join(p, sizeof p, g_data, "foreground");
char *txt = read_file(p, NULL);
if (!txt) return 0;
txt[strcspn(txt, "\r\n")] = 0;
str_copy(title_id, n, txt);
free(txt);
return title_id[0] ? 1 : 0;
}
const char *plat_data_dir(void) { return g_data; }
const char *plat_ca_bundle(void) {
const char *c = getenv("ROMM_SYNC_CA");
return c && *c ? c : NULL;
}
void plat_remove_old_tile(void) {}
int plat_app_installed(void) {
const char *e = getenv("ROMM_SYNC_APP_INSTALLED");
return e && *e == '1';
}
void plat_app_version(char *out, size_t n) {
const char *e = getenv("ROMM_SYNC_APP_VERSION");
str_copy(out, n, e ? e : "");
}
int plat_launch_elf(const void *elf, size_t len) {
char p[PATH_MAX_LEN];
path_join(p, sizeof p, g_data, "launched.elf");
return write_file_atomic(p, elf, len);
}
int plat_is_mount(const char *path) { (void)path; return 0; }
void plat_mounts_set(const plat_mount *mounts, int n) {
char p[PATH_MAX_LEN], tmp[PATH_MAX_LEN + 8];
path_join(p, sizeof p, g_data, "mounts.txt");
snprintf(tmp, sizeof tmp, "%s.tmp", p);
FILE *f = fopen(tmp, "w");
if (!f) return;
for (int i = 0; i < n; i++) fprintf(f, "%s|%s|%s\n", mounts[i].title, mounts[i].from, mounts[i].into);
fclose(f);
rename(tmp, p);
}
const char *plat_name(void) { return INFO.name; }
void plat_describe(char *buf, size_t n) {
struct utsname u;
if (uname(&u)) snprintf(buf, n, "host build");
else snprintf(buf, n, "host build on %s %s %s", u.sysname, u.release, u.machine);
}
void plat_console_name(char *buf, size_t n) {
const char *env = getenv("ROMM_SYNC_CONSOLE_NAME");
snprintf(buf, n, "%s", env ? env : "");
}
void plat_local_ip(char *buf, size_t n) {
buf[0] = 0;
struct ifaddrs *ifs, *i;
if (getifaddrs(&ifs) != 0) return;
for (i = ifs; i; i = i->ifa_next) {
if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue;
const struct sockaddr_in *a = (const struct sockaddr_in *)i->ifa_addr;
if (ntohl(a->sin_addr.s_addr) >> 24 == 127) continue;
inet_ntop(AF_INET, &a->sin_addr, buf, (socklen_t)n);
break;
}
freeifaddrs(ifs);
}
+635
View File
@@ -0,0 +1,635 @@
/* RommPS for PS4: the home screen app. The sync itself is the payload,
* which GoldHEN runs from /data/payloads (platform/ps4/platform.c); this app
* only installs that payload and shows its web UI, so it never needs to stay
* running. Built with OpenOrbis (platform/ps4/ps4.mk, "make ps4-pkg").
*
* When opened it:
* 1. copies the payload bundled in the package to /data/payloads when it's
* missing, older, or the same version with different bytes (a test
* build). Versions come from the ROMM_SYNC_VERSION= tag in each payload
* (src/main.c), so an older app never undoes a newer payload from an
* update or a manual copy. Other romm-sync ELFs in /data/payloads (old
* names, PS5 builds copied over by mistake) are removed, and an AutoRun
* entry for one moves to this payload. This app runs as a regular user,
* so it only relies on /data/payloads being writable. Files are read in
* full: stat() is unreliable in the sandbox;
* 2. checks that RomM Sync answers on its web port;
* 3. shows the web UI in the system web browser dialog, or the Browser app
* if the dialog won't open; if RomM Sync isn't running, says why, from
* what it left in its logs, and how to start it.
*
* Each step goes to /data/romm-sync/launcher.log, for bug reports. NETLOG=1
* builds also broadcast it, with the payload's own logs, to tools/netlog.py.
*
* The web browser dialog has no OpenOrbis types; its layout is Sony's
* SceWebBrowserDialogParam as used by Nuvio-PS5 and EVO Player. */
#include <arpa/inet.h>
#include <dirent.h>
#include <netinet/in.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <orbis/CommonDialog.h>
#include <orbis/MsgDialog.h>
#include <orbis/Sysmodule.h>
#include <orbis/SystemService.h>
#include <orbis/UserService.h>
#ifndef APP_VERSION
#define APP_VERSION "dev"
#endif
#define BUNDLED_PAYLOAD "/app0/romm-sync-ps4.elf"
#define PAYLOAD_DIR "/data/payloads"
#define PAYLOAD_PATH PAYLOAD_DIR "/romm-sync-ps4.elf"
#define DATA_DIR "/data/romm-sync"
#define LOG_PATH DATA_DIR "/launcher.log"
#define BOOT_LOG DATA_DIR "/boot.log" /* the payload's start steps, from its last start */
#define PAYLOAD_LOG DATA_DIR "/romm-sync.log"
#define AUTORUN_INI "/data/GoldHEN/payloads.ini" /* "[AutoRun]" then "<payload path> = 1" */
#define CONFIG_PATH DATA_DIR "/config.json"
#define VERSION_TAG "ROMM_SYNC_VERSION="
#define DEFAULT_PORT 8780
#define NETLOG_PORT 8781 /* ROMM_NETLOG_PORT in src/util.h */
/* libSceWebBrowserDialog */
typedef struct {
OrbisCommonDialogBaseParam base;
uint64_t size;
int32_t mode; /* 1: the browser's own layout, 2: the rectangle below */
int32_t user_id;
const char *url;
void *callback_init;
uint16_t width, height, pos_x, pos_y;
uint32_t parts;
uint16_t header_width, header_x, header_y, pad0;
uint32_t control;
void *ime_param;
void *webview_param;
uint32_t animation;
uint8_t reserved[202];
uint16_t tail_pad;
} web_dialog_param;
_Static_assert(sizeof(web_dialog_param) == 328, "SceWebBrowserDialogParam is 328 bytes");
int sceWebBrowserDialogInitialize(void);
int sceWebBrowserDialogOpen(web_dialog_param *param);
int sceWebBrowserDialogUpdateStatus(void);
int sceWebBrowserDialogClose(void);
int sceWebBrowserDialogTerminate(void);
/* OpenOrbis declares it without parameters; this is the real signature. */
int launch_web_browser(const char *uri, void *param) __asm__("sceSystemServiceLaunchWebBrowser");
#ifdef ROMM_NETLOG
/* A UDP broadcast per line, as the payload does (src/util.c). */
static void netlog(const char *tag, const char *text, size_t len) {
static int fd = -1;
static struct sockaddr_in to;
char pkt[1200];
if (fd < 0) {
int on = 1;
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) return;
setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof on);
memset(&to, 0, sizeof to);
to.sin_family = AF_INET;
to.sin_port = htons(NETLOG_PORT);
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
}
int n = snprintf(pkt, sizeof pkt, "[%s] %.*s\n", tag, (int)(len > 1100 ? 1100 : len), text);
sendto(fd, pkt, (size_t)n, 0, (struct sockaddr *)&to, sizeof to);
usleep(2000); /* a burst of broadcasts gets dropped */
}
#endif
static void logf_(const char *fmt, ...) {
FILE *f = fopen(LOG_PATH, "a");
char ts[32], line[1024];
time_t now = time(NULL);
va_list ap;
va_start(ap, fmt);
vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
strftime(ts, sizeof ts, "%Y-%m-%d %H:%M:%S", localtime(&now));
#ifdef ROMM_NETLOG
char out[1100];
snprintf(out, sizeof out, "%s %s", ts, line);
netlog("launcher", out, strlen(out));
#endif
if (!f) return;
fprintf(f, "%s %s\n", ts, line);
fclose(f);
}
static int user_id(void) {
int id = -1;
if (sceUserServiceGetForegroundUser(&id) == 0 && id != -1 && id != 0xff) return id;
if (sceUserServiceGetInitialUser(&id) == 0) return id;
return -1;
}
/* Whole file, by reading: stat() sizes can't be trusted in the sandbox. */
static unsigned char *slurp(const char *path, size_t *len) {
FILE *f = fopen(path, "rb");
unsigned char *buf = NULL;
size_t cap = 0, n = 0, r;
if (!f) return NULL;
do {
if (n == cap) {
unsigned char *nb = realloc(buf, cap = cap ? cap * 2 : 64 * 1024);
if (!nb) {
free(buf);
fclose(f);
return NULL;
}
buf = nb;
}
r = fread(buf + n, 1, cap - n, f);
n += r;
} while (r > 0);
fclose(f);
*len = n;
return buf;
}
/* The version in a payload's ROMM_SYNC_VERSION= tag, or "" if it has none
* (payloads older than the tag). Read in chunks: a payload is megabytes, and
* this app's heap doesn't take a buffer that big well. 0 if it can't be read. */
static int payload_tag(const char *path, char *out, size_t n) {
static unsigned char buf[64 * 1024 + 128];
size_t tl = strlen(VERSION_TAG), keep = 0, got;
FILE *f = fopen(path, "rb");
out[0] = 0;
if (!f) return 0;
while ((got = fread(buf + keep, 1, sizeof buf - keep, f)) > 0) {
size_t len = keep + got;
for (size_t i = 0; i + tl < len; i++) {
if (buf[i] != 'R' || memcmp(buf + i, VERSION_TAG, tl) != 0) continue;
size_t j = 0;
for (const unsigned char *v = buf + i + tl; v < buf + len && *v != '\n' && *v && j + 1 < n; v++)
out[j++] = (char)*v;
out[j] = 0;
fclose(f);
return 1;
}
/* The tail again, in case the tag straddles two reads. */
keep = len < 128 ? len : 128;
memmove(buf, buf + len - keep, keep);
}
fclose(f);
return 1;
}
/* The two files hold the same bytes. */
static int same_file(const char *a, const char *b) {
static unsigned char x[64 * 1024], y[64 * 1024];
FILE *fa = fopen(a, "rb"), *fb = fopen(b, "rb");
int same = fa && fb;
size_t na, nb;
while (same) {
na = fread(x, 1, sizeof x, fa);
nb = fread(y, 1, sizeof y, fb);
if (na != nb || memcmp(x, y, na) != 0) same = 0;
else if (na == 0) break;
}
if (fa) fclose(fa);
if (fb) fclose(fb);
return same;
}
/* Copies src to dst; 0 when every byte got there. */
static int copy_to(const char *src, const char *dst) {
static unsigned char buf[64 * 1024];
FILE *in = fopen(src, "rb"), *out = in ? fopen(dst, "wb") : NULL;
size_t got;
int ok = in && out;
while (ok && (got = fread(buf, 1, sizeof buf, in)) > 0) ok = fwrite(buf, 1, got, out) == got;
if (in) fclose(in);
if (out && fclose(out) != 0) ok = 0;
return ok ? 0 : -1;
}
/* "1.2.3" or "1.2.3-ps4-prototype.6": the three numbers, and the last number
* of the pre-release part (-1 for a release). 0 if it isn't a version. */
static int parse_version(const char *s, int v[4]) {
v[3] = -1;
if (sscanf(s, "%d.%d.%d", &v[0], &v[1], &v[2]) != 3) return 0;
const char *pre = strchr(s, '-');
if (pre) {
v[3] = 0;
for (const char *c = pre; *c; c++)
if (*c >= '0' && *c <= '9' && (c == pre || c[-1] < '0' || c[-1] > '9')) v[3] = atoi(c);
}
return 1;
}
/* >0 if a is newer than b. A release is newer than its pre-releases. */
static int compare_versions(const int a[4], const int b[4]) {
for (int i = 0; i < 3; i++)
if (a[i] != b[i]) return a[i] - b[i];
if ((a[3] < 0) != (b[3] < 0)) return a[3] < 0 ? 1 : -1;
return a[3] - b[3];
}
/* 1 if installed or replaced, 0 if left as it is, -1 on failure. */
static int install_payload(void) {
char want_ver[64], have_ver[64];
int wv[4], hv[4], rc = -1;
if (!payload_tag(BUNDLED_PAYLOAD, want_ver, sizeof want_ver)) {
logf_("can't read the bundled payload %s", BUNDLED_PAYLOAD);
return -1;
}
const int have = payload_tag(PAYLOAD_PATH, have_ver, sizeof have_ver);
int replace;
if (!have) replace = 1;
else if (same_file(PAYLOAD_PATH, BUNDLED_PAYLOAD)) replace = 0;
else if (!parse_version(want_ver, wv)) replace = 0; /* an untagged test build: never overwrite */
else if (!parse_version(have_ver, hv)) replace = 1; /* older than version tags */
else replace = compare_versions(wv, hv) >= 0; /* the same version can be a different build */
if (!replace) {
logf_("payload %s left as it is (installed %s, this app has %s)", PAYLOAD_PATH, have_ver[0] ? have_ver : "?",
want_ver[0] ? want_ver : "?");
return 0;
}
mkdir(PAYLOAD_DIR, 0777);
if (copy_to(BUNDLED_PAYLOAD, PAYLOAD_PATH ".tmp") == 0) rc = rename(PAYLOAD_PATH ".tmp", PAYLOAD_PATH) == 0 ? 1 : -1;
logf_("%s %s with %s (was %s): %s", have ? "replaced" : "installed", PAYLOAD_PATH, want_ver[0] ? want_ver : "?",
have ? (have_ver[0] ? have_ver : "untagged") : "missing", rc == 1 ? "ok" : "failed");
return rc;
}
/* Points an AutoRun entry for a removed copy at this payload, unless it's
* already queued. GoldHEN only reads the file at jailbreak, so this takes
* effect on the next one. */
static void move_autorun(const char *name) {
size_t len = 0;
char *ini = (char *)slurp(AUTORUN_INI, &len), *at, path[300];
if (!ini) return;
ini = realloc(ini, len + 1);
ini[len] = 0;
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", name);
if ((at = strstr(ini, path)) && !strstr(ini, "romm-sync-ps4.elf")) {
FILE *f = fopen(AUTORUN_INI ".tmp", "wb");
int ok = f != NULL;
if (f) {
ok = fwrite(ini, 1, (size_t)(at - ini), f) == (size_t)(at - ini) && fputs(PAYLOAD_PATH, f) >= 0 &&
fputs(at + strlen(path), f) >= 0;
if (fclose(f) != 0) ok = 0;
}
ok = ok && rename(AUTORUN_INI ".tmp", AUTORUN_INI) == 0;
logf_("AutoRun entry for %s moved to %s: %s", path, PAYLOAD_PATH, ok ? "ok" : "failed");
}
free(ini);
}
/* Other romm-sync ELFs in /data/payloads: older names, a PS5 build, a test
* build's leftovers. GoldHEN lists them all in its payload menu, and running
* the wrong one looks just like RomM Sync failing to start. The payload's own
* update in progress (romm-sync-ps4.elf.tmp) is left alone. */
static void remove_stale_payloads(void) {
DIR *d = opendir(PAYLOAD_DIR);
struct dirent *e;
char names[16][256];
int n = 0;
if (!d) {
logf_("can't list %s", PAYLOAD_DIR);
return;
}
while ((e = readdir(d)) && n < 16) {
size_t l = strlen(e->d_name);
if (strncmp(e->d_name, "romm-sync", 9) != 0 || !strcmp(e->d_name, "romm-sync-ps4.elf") ||
!strcmp(e->d_name, "romm-sync-ps4.elf.tmp"))
continue;
if (!(l > 4 && !strcmp(e->d_name + l - 4, ".elf")) && !(l > 8 && !strcmp(e->d_name + l - 8, ".elf.tmp"))) continue;
snprintf(names[n++], sizeof names[0], "%s", e->d_name);
}
closedir(d);
for (int i = 0; i < n; i++) {
char path[300];
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", names[i]);
int rc = unlink(path);
logf_("removed %s: %s", path, rc == 0 ? "ok" : "failed");
if (rc == 0) move_autorun(names[i]);
}
}
#ifdef ROMM_NETLOG
/* What a bug report would carry, sent line by line: the payload's last start,
* its log, GoldHEN's AutoRun queue and what's in /data/payloads. */
static char *slurp_text(const char *path);
static void netlog_file(const char *path, size_t tail) {
char *t = slurp_text(path), *p, *end;
size_t len = t ? strlen(t) : 0;
char head[300];
snprintf(head, sizeof head, "----- %s (%s) -----", path, t ? "" : "missing");
netlog("file", head, strlen(head));
if (!t) return;
p = len > tail ? (char *)memchr(t + len - tail, '\n', tail) : t;
p = p ? (p == t ? t : p + 1) : t;
for (end = t + len; p < end;) {
char *nl = memchr(p, '\n', (size_t)(end - p));
size_t l = nl ? (size_t)(nl - p) : (size_t)(end - p);
if (l) netlog("file", p, l);
p += l + 1;
}
free(t);
}
static void netlog_report(void) {
char line[400], ver[64];
DIR *d = opendir(PAYLOAD_DIR);
struct dirent *e;
netlog("file", "----- " PAYLOAD_DIR " -----", strlen("----- " PAYLOAD_DIR " -----"));
while (d && (e = readdir(d))) {
char path[300];
if (e->d_name[0] == '.') continue;
snprintf(path, sizeof path, PAYLOAD_DIR "/%s", e->d_name);
payload_tag(path, ver, sizeof ver);
snprintf(line, sizeof line, "%s %s", e->d_name, ver[0] ? ver : "(no version tag)");
netlog("file", line, strlen(line));
}
if (d) closedir(d);
netlog_file(AUTORUN_INI, 4096);
netlog_file(BOOT_LOG, 8192);
netlog_file(PAYLOAD_LOG, 64 * 1024);
}
#endif
/* The web_port from RomM Sync's config.json, if it was changed. */
static int web_port(void) {
size_t len = 0;
char *cfg = (char *)slurp(CONFIG_PATH, &len), *p;
int port = DEFAULT_PORT;
if (cfg) {
cfg = realloc(cfg, len + 1);
cfg[len] = 0;
if ((p = strstr(cfg, "\"web_port\"")) && (p = strchr(p, ':'))) port = atoi(p + 1);
free(cfg);
}
return port > 0 && port < 65536 ? port : DEFAULT_PORT;
}
/* The running payload's version from /api/status, or 0 if nothing answers. */
static int payload_version(int port, char *version, size_t n) {
struct sockaddr_in sa;
char buf[2048], req[128], *v;
int fd, got = 0, r;
version[0] = 0;
if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) return 0;
memset(&sa, 0, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)port);
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr *)&sa, sizeof sa) != 0) {
close(fd);
return 0;
}
snprintf(req, sizeof req, "GET /api/status HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
write(fd, req, strlen(req));
while (got < (int)sizeof buf - 1 && (r = (int)read(fd, buf + got, sizeof buf - 1 - got)) > 0) got += r;
close(fd);
buf[got] = 0;
if ((v = strstr(buf, "\"version\":\"")) || (v = strstr(buf, "\"version\": \""))) {
v = strchr(v + 9, '"') + 1;
snprintf(version, n, "%.*s", (int)strcspn(v, "\""), v);
}
return 1;
}
/* The end of a text file, at most TEXT_TAIL bytes of it (RomM Sync's log
* only grows, and its last start is near the end). Read straight through:
* sizes can't be trusted in the sandbox. */
#define TEXT_TAIL (256 * 1024)
static char *slurp_text(const char *path) {
FILE *f = fopen(path, "rb");
char *t = f ? malloc(TEXT_TAIL + 64 * 1024 + 1) : NULL;
size_t len = 0, got;
if (!t) {
if (f) fclose(f);
return NULL;
}
while ((got = fread(t + len, 1, 64 * 1024, f)) > 0) {
len += got;
if (len > TEXT_TAIL) { /* keep the newest TEXT_TAIL bytes */
memmove(t, t + len - TEXT_TAIL, TEXT_TAIL);
len = TEXT_TAIL;
}
}
fclose(f);
t[len] = 0;
return t;
}
/* GoldHEN runs the payload on every jailbreak: it's in the AutoRun queue. */
static int in_autorun(void) {
char *ini = slurp_text(AUTORUN_INI), *line;
int on = 0;
for (line = ini ? strstr(ini, "romm-sync-ps4.elf") : NULL; line; line = strstr(line + 1, "romm-sync-ps4.elf")) {
char *eq = strchr(line, '='), *eol = strchr(line, '\n');
if (eq && (!eol || eq < eol) && atoi(eq + 1) == 1) on = 1;
}
free(ini);
return on;
}
/* The part of RomM Sync's log since it last started, or NULL. */
static char *last_start(char *log) {
char *since = NULL;
for (char *p = log; p && (p = strstr(p, " starting (")); p++) since = p;
if (since)
while (since > log && since[-1] != '\n') since--;
return since;
}
/* RomM Sync started and hasn't opened its web page yet, nor failed: it's
* still starting (or stuck doing so). */
static int starting(void) {
char *boot = slurp_text(BOOT_LOG), *log = slurp_text(PAYLOAD_LOG), *since = last_start(log);
int yes = boot && strstr(boot, "platform ready") && !strstr(boot, "already running") && since &&
!strstr(since, "web UI on") && !strstr(since, "crashed with signal") && !strstr(since, "cannot listen on port");
free(boot);
free(log);
return yes;
}
/* Why RomM Sync isn't answering, from what it left behind: its start steps
* (boot.log, rewritten at each start) and its log since it last started. */
static void diagnose(int installed, int port, char *out, size_t n) {
char *boot = slurp_text(BOOT_LOG), *log = slurp_text(PAYLOAD_LOG), *since = last_start(log), when[20] = "";
const int autorun = in_autorun();
const char *how = autorun ? "In GoldHEN Settings > Payloader, run romm-sync-ps4.elf, then open RommPS again."
: "In GoldHEN Settings > Payloader, run romm-sync-ps4.elf and add it to the AutoRun queue. "
"Then open RommPS again.";
logf_("in GoldHEN's AutoRun queue: %s", autorun ? "yes" : "no");
if (since) snprintf(when, sizeof when, "%.19s", since); /* the line's "2026-10-16 09:30:00" */
if (boot && strstr(boot, "already running")) {
logf_("diagnosis: a copy holds the lock but doesn't answer");
snprintf(out, n, "RommPS's sync service is stuck. Restart the console, jailbreak, and open RommPS again.");
} else if (since && strstr(since, "crashed with signal")) {
logf_("diagnosis: crashed after starting at %s", when);
snprintf(out, n,
"RommPS's sync service stopped with an error (started %s).\n\nRestart the console and jailbreak again. "
"If it keeps happening, send /data/romm-sync/romm-sync.log with a bug report.",
when);
} else if (since && strstr(since, "cannot listen on port")) {
logf_("diagnosis: port %d is taken", port);
snprintf(out, n,
"RommPS's sync service is running, but another app uses port %d.\n\nClose other homebrew servers, "
"or change web_port in /data/romm-sync/config.json.",
port);
} else if (since && !strstr(since, "web UI on")) {
const char *last = since + strlen(since);
while (last > since && last[-1] == '\n') last--;
const char *bol = last;
while (bol > since && bol[-1] != '\n') bol--;
logf_("diagnosis: started at %s, no web page yet; it last logged: %.*s", when, (int)(last - bol), bol);
snprintf(out, n, "RommPS's sync service started (%s) but hasn't opened its web page yet.\n\nGive it a moment "
"and open RommPS again. If this keeps happening, send /data/romm-sync/romm-sync.log with a bug "
"report.", when);
} else if (since) {
logf_("diagnosis: last started at %s, not running now", when);
if (autorun)
snprintf(out, n, "RommPS's sync service isn't running. It's in GoldHEN's AutoRun queue but didn't start "
"this time (last start %s).\n\n%s", when, how);
else
snprintf(out, n, "RommPS's sync service isn't running. It last started %s, and won't start after a reboot "
"unless it's in GoldHEN's AutoRun queue.\n\n%s", when, how);
} else if (boot) {
logf_("diagnosis: started but stopped before its log opened");
snprintf(out, n, "RommPS's sync service started but stopped right away.\n\nRestart the console and jailbreak "
"again. If it keeps happening, send /data/romm-sync/boot.log with a bug report.");
} else {
logf_("diagnosis: never started");
snprintf(out, n, "RommPS's sync service hasn't been started yet.\n\n%s%s",
installed > 0 ? "It's now in /data/payloads. " : "", how);
}
free(boot);
free(log);
}
static void message(const char *text) {
OrbisMsgDialogParam param;
OrbisMsgDialogUserMessageParam user;
memset(&param, 0, sizeof param);
memset(&user, 0, sizeof user);
param.baseParam.size = sizeof param.baseParam;
param.baseParam.magic = (uint32_t)(ORBIS_COMMON_DIALOG_MAGIC_NUMBER + (uint64_t)(uintptr_t)&param.baseParam);
param.size = sizeof param;
param.mode = ORBIS_MSG_DIALOG_MODE_USER_MSG;
param.userMsgParam = &user;
param.userId = user_id();
user.buttonType = ORBIS_MSG_DIALOG_BUTTON_TYPE_OK;
user.msg = text;
if (sceMsgDialogInitialize() < 0 && sceMsgDialogGetStatus() == ORBIS_COMMON_DIALOG_STATUS_NONE) {
logf_("message dialog unavailable: %s", text);
return;
}
if (sceMsgDialogOpen(&param) < 0) {
logf_("message dialog didn't open: %s", text);
return;
}
while (sceMsgDialogUpdateStatus() == ORBIS_COMMON_DIALOG_STATUS_RUNNING) usleep(16 * 1000);
sceMsgDialogTerminate();
}
/* The dialog's magic encodes this block's address, so it must not move. */
static web_dialog_param g_web __attribute__((aligned(16)));
static int open_web_dialog(const char *url, int mode) {
memset(&g_web, 0, sizeof g_web);
g_web.base.size = sizeof g_web.base;
g_web.base.magic = (uint32_t)(ORBIS_COMMON_DIALOG_MAGIC_NUMBER + (uint64_t)(uintptr_t)&g_web.base);
g_web.size = sizeof g_web;
g_web.mode = mode;
g_web.user_id = user_id();
g_web.url = url;
if (mode == 2) {
g_web.width = 1920;
g_web.height = 1080;
g_web.header_width = 1920;
}
return sceWebBrowserDialogOpen(&g_web);
}
/* Full screen, then the browser's own layout, then the Browser app. Returns
* when the dialog is closed, or straight away for the Browser app. */
static void show_web_ui(const char *url) {
int rc = sceSysmoduleLoadModule(ORBIS_SYSMODULE_WEB_BROWSER_DIALOG);
logf_("web browser dialog module: 0x%08x", (unsigned)rc);
rc = sceWebBrowserDialogInitialize();
logf_("web browser dialog init: 0x%08x", (unsigned)rc);
if ((rc = open_web_dialog(url, 2)) != 0) {
logf_("full-screen web dialog: 0x%08x, trying the default layout", (unsigned)rc);
rc = open_web_dialog(url, 1);
}
if (rc == 0) {
logf_("web dialog open on %s", url);
int status;
while ((status = sceWebBrowserDialogUpdateStatus()) == ORBIS_COMMON_DIALOG_STATUS_RUNNING ||
status == ORBIS_COMMON_DIALOG_STATUS_INITIALIZED)
usleep(16 * 1000);
logf_("web dialog closed (status %d)", status);
sceWebBrowserDialogClose();
sceWebBrowserDialogTerminate();
return;
}
logf_("web dialog: 0x%08x, opening the Browser app instead", (unsigned)rc);
rc = launch_web_browser(url, NULL);
logf_("Browser app: 0x%08x", (unsigned)rc);
if (rc != 0) {
char text[256];
snprintf(text, sizeof text, "Open %s in the web browser to use RommPS.", url);
message(text);
}
}
int main(void) {
char url[64], running[32], text[640];
int installed, port;
sceSystemServiceHideSplashScreen();
mkdir(DATA_DIR, 0777);
logf_("RommPS launcher %s", APP_VERSION);
sceUserServiceInitialize(NULL);
sceSysmoduleLoadModule(ORBIS_SYSMODULE_MESSAGE_DIALOG);
sceCommonDialogInitialize();
installed = install_payload();
/* Only with ours in place: AutoRun entries move to it. */
if (installed >= 0 && access(PAYLOAD_PATH, F_OK) == 0) remove_stale_payloads();
#ifdef ROMM_NETLOG
netlog_report();
#endif
port = web_port();
snprintf(url, sizeof url, "http://127.0.0.1:%d/", port);
int up = payload_version(port, running, sizeof running);
/* Opened just after GoldHEN launched it: give it time to open its web page. */
for (int i = 0; !up && i < 20 && starting(); i++) {
if (i == 0) logf_("RomM Sync is starting; waiting for it");
usleep(500 * 1000);
up = payload_version(port, running, sizeof running);
}
logf_("RomM Sync on port %d: %s%s", port, up ? "running " : "not running", running);
if (installed < 0) {
message("RommPS couldn't copy its sync service to /data/payloads. Copy romm-sync-ps4.elf there over FTP, then run it from GoldHEN Settings > Payloader.");
} else if (!up) {
diagnose(installed, port, text, sizeof text);
message(text);
} else {
if (installed)
message("A new version of RommPS's sync service is in /data/payloads. It takes over after the next jailbreak.");
show_web_ui(url);
}
logf_("launcher done");
sceSystemServiceLoadExec("exit", NULL);
return 0;
}
+80
View File
@@ -0,0 +1,80 @@
# Build image for the PS4: the ps4-payload-dev SDK plus its pacbrew static
# libraries (curl, OpenSSL, zlib) for the payload, and the OpenOrbis toolchain
# for the launcher package (platform/ps4/app). amd64 only, as OpenOrbis's
# packaging tools are x86-64 binaries; Docker emulates it on arm64.
FROM --platform=linux/amd64 ubuntu:24.04
ARG SDK_VERSION=v0.9
# The SDK's startup code (crt1.o) from a later commit: v0.9 can't start on
# firmware 13.52 or 14.00, whose kernel offsets came after it (crt/kernel.c,
# crt/patch.c). Drop this once a release has them.
ARG SDK_CRT_COMMIT=573b4a0d0d5fa8695cfeee5f5e2f0447c550035d
ARG PACBREW_VERSION=v0.3.3
ARG OPENORBIS_VERSION=v0.5.4
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
bash ca-certificates wget unzip xz-utils make pkg-config python3 git \
clang-18 lld-18 llvm-18 \
&& rm -rf /var/lib/apt/lists/*
# SDK
RUN wget -q -O /tmp/sdk.zip \
https://github.com/ps4-payload-dev/sdk/releases/download/${SDK_VERSION}/ps4-payload-sdk.zip \
&& unzip -q /tmp/sdk.zip -d /opt \
&& rm /tmp/sdk.zip \
&& chmod +x /opt/ps4-payload-sdk/bin/*
RUN git clone -q --filter=blob:none https://github.com/ps4-payload-dev/sdk /tmp/sdk-src \
&& git -C /tmp/sdk-src checkout -q ${SDK_CRT_COMMIT} \
&& make -C /tmp/sdk-src/crt LLVM_CONFIG=llvm-config-18 >/dev/null \
&& install -m 644 /tmp/sdk-src/crt/crt1.o /opt/ps4-payload-sdk/target/lib/crt1.o \
&& rm -rf /tmp/sdk-src
# Libraries land in the SDK's target/user/homebrew, next to the SDK itself.
RUN wget -q -O /tmp/pb.tar.gz \
https://github.com/ps4-payload-dev/pacbrew/releases/download/${PACBREW_VERSION}/ps4-payload-dev.tar.gz \
&& tar -xzf /tmp/pb.tar.gz -C / opt/ps4-payload-sdk/target/user \
&& rm /tmp/pb.tar.gz \
&& cp /opt/ps4-payload-sdk/target/user/homebrew/etc/ca-bundle.crt /opt/ca-bundle.crt
# OpenOrbis, for the launcher package. PkgTool.Core is .NET Core 3.0: it wants
# ICU unless told not to, and OpenSSL 1.1, which only Ubuntu 20.04 still has.
RUN wget -q -O /tmp/libssl.deb \
http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb \
&& dpkg -i /tmp/libssl.deb \
&& rm /tmp/libssl.deb
RUN wget -q -O /tmp/oo.tar.gz \
https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/download/${OPENORBIS_VERSION}/toolchain-llvm-18.tar.gz \
&& mkdir -p /opt/openorbis \
&& tar -xzf /tmp/oo.tar.gz -C /opt/openorbis --strip-components=2 \
&& rm /tmp/oo.tar.gz \
&& chmod +x /opt/openorbis/bin/linux/*
ENV OO_PS4_TOOLCHAIN=/opt/openorbis
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
# Every package carries sce_module/libc.prx, sce_module/libSceFios2.prx and
# sce_sys/about/right.sprx. OpenOrbis builds them from its source tree rather
# than shipping them, so build them here into /opt/openorbis/modules.
RUN ln -sf /usr/bin/clang-18 /usr/local/bin/clang \
&& ln -sf /usr/bin/clang++-18 /usr/local/bin/clang++ \
&& ln -sf /usr/bin/ld.lld-18 /usr/local/bin/ld.lld \
&& git clone -q --depth 1 --branch ${OPENORBIS_VERSION} --filter=blob:none --sparse \
https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain /tmp/oo-src \
&& git -C /tmp/oo-src sparse-checkout set src/modules \
&& mkdir -p /opt/openorbis/modules \
&& for m in libc libSceFios2 right; do \
make -C /tmp/oo-src/src/modules/$m >/dev/null && \
cp /tmp/oo-src/src/modules/$m/$m.*prx /opt/openorbis/modules/; \
done \
&& rm -rf /tmp/oo-src \
&& ls -l /opt/openorbis/modules
ENV PS4_PAYLOAD_SDK=/opt/ps4-payload-sdk
ENV PATH=/opt/ps4-payload-sdk/bin:${PATH}
ENV LLVM_CONFIG=/usr/bin/llvm-config-18
# Fail early if static libcurl does not resolve.
RUN orbis-pkg-config --static --libs libcurl \
&& orbis-pkg-config --cflags libcurl
WORKDIR /src
+160
View File
@@ -0,0 +1,160 @@
[
{
"id": "psnes", "name": "pSNES", "title_ids": ["PSNE00001"], "detect": ["/data/psnes"],
"profile": {
"root": "/data/psnes", "rom_dir": "{root}/roms", "save_dir": "{root}/sram", "bios_dir": "{root}/bios",
"save_exts": ".srm",
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
"firmware": {"from": {"satellaview": ["BS-X.bin"], "sufami-turbo": ["STBIOS.bin"]}},
"platforms": [{"romm": ["snes", "sfam"], "dir": "snes"}]
}
},
{
"id": "pnes", "name": "pNES", "title_ids": ["PNES00001"], "detect": ["/data/pnes"],
"profile": {
"root": "/data/pnes", "rom_dir": "{root}/roms", "save_dir": "{root}/save", "bios_dir": "{root}/bios",
"save_exts": ".sav",
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
"firmware": {"dir": "{root}"},
"platforms": [{"romm": ["nes", "famicom"], "dir": "nes"}, {"romm": ["fds"], "dir": "fds"}]
}
},
{
"id": "pgen", "name": "pGEN", "title_ids": ["PGEN00001"], "detect": ["/data/pgen"],
"profile": {
"root": "/data/pgen", "rom_dir": "{root}/{platform}", "save_dir": "{root}/rams", "bios_dir": "{root}/bios",
"save_exts": ".srm",
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
"platforms": [
{"romm": ["genesis", "genesis-slash-megadrive", "megadrive"], "dir": "megadrive"},
{"romm": ["sms", "mastersystem"], "dir": "mastersystem"},
{"romm": ["gamegear", "gg"], "dir": "gamegear"},
{"romm": ["segacd", "sega-cd"], "dir": "megacd", "save_exts": ".brm"},
{"romm": ["sg1000"], "dir": "sg1000"}
]
}
},
{
"id": "pgba", "name": "pGBA", "title_ids": ["PGBA00001"], "detect": ["/data/pgba"],
"note": "Saves and states share the saves folder.",
"profile": {
"root": "/data/pgba", "rom_dir": "{root}/{platform}", "save_dir": "{root}/saves", "bios_dir": "{root}/bios",
"save_exts": ".sav",
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
"platforms": [
{"romm": ["gba"], "dir": "gba"},
{"romm": ["gbc"], "dir": "gbc"},
{"romm": ["gb"], "dir": "gb"}
]
}
},
{
"id": "pfbn", "name": "pFBN", "title_ids": ["PFBN00001"], "detect": ["/data/pfba"],
"note": "Arcade saves are the games' own memory (eeproms), backed up as they are.",
"profile": {
"root": "/data/pfba", "rom_dir": "{root}/{platform}", "save_dir": "{root}/eeproms", "bios_dir": "{root}/arcade",
"save_exts": ".fs",
"state_dir": "{root}/saves", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}-0,{rom_stem}-1,{rom_stem}-2,{rom_stem}-3", "state_name": "{rom_stem}-0{ext}",
"firmware": {"dir": "{rom_dir}"},
"platforms": [
{"romm": ["arcade", "fbneo", "neogeoaes", "neogeomvs", "cps1", "cps2", "cps3"], "dir": "arcade"},
{"romm": ["neo-geo-pocket-color", "neo-geo-pocket", "ngpc", "ngp"], "dir": "ngp"},
{"romm": ["colecovision"], "dir": "coleco"},
{"romm": ["msx"], "dir": "msx"},
{"romm": ["zxs", "zx-spectrum"], "dir": "spectrum"},
{"romm": ["supergrafx"], "dir": "sgx"},
{"romm": ["channel-f", "fairchild-channel-f"], "dir": "channelf"}
]
}
},
{
"id": "emusuni", "name": "EmuSuNi", "title_ids": ["EMSN00001"], "detect": ["/data/EmuSuNi"],
"note": "Set EmuSuNi's N64 folder to /data/EmuSuNi/n64 in its settings.",
"profile": {
"root": "/data/EmuSuNi", "bios_dir": "{root}/system",
"save_exts": ".srm",
"state_exts": ".state*", "state_format": "raw", "state_slot0": ".state1",
"platforms": [
{"romm": ["snes", "sfam"], "dir": "snes", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states"},
{"romm": ["nes", "famicom"], "dir": "nes", "rom_dir": "{root}/nes", "save_dir": "{root}/saves/nes", "state_dir": "{root}/states/nes"},
{"romm": ["n64"], "dir": "n64", "rom_dir": "{root}/n64", "save_dir": "{root}/saves/n64", "state_dir": "{root}/states/n64", "save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}
]
}
},
{
"id": "dolphinps4", "name": "DolphinPS4", "title_ids": ["DLPH00010"], "detect": ["/data/DolphinPS4"],
"note": "Dolphin, with its user folder in {root}/User. States are named after the disc's id.",
"profile": {
"root": "/data/DolphinPS4", "rom_dir": "{root}/games", "save_dir": "{root}/User",
"state_dir": "{root}/User/StateSaves", "state_exts": ".s0*", "state_format": "raw",
"state_stems": "{game_id}", "state_name": "{game_id}{ext}",
"firmware": {"dir": "{root}/User/GC", "only_named": true, "names": {"b17148254a5799684c7d783206504926": "USA/IPL.bin", "fc924a7c879b661abc37cec4f018fdf3": "USA/IPL.bin", "019e39822a9ca3029124f74dd4d55ac4": "USA/IPL.bin", "db92574caab77a7ec99d4605fd6f2450": "EUR/IPL.bin", "339848a0b7c2124cf155276c1e79cbd0": "EUR/IPL.bin", "0cdda509e2da83c85bfe423dd87346cc": "EUR/IPL.bin"}},
"platforms": [
{"romm": ["ngc", "gamecube"], "dir": "gamecube", "emulator": "dolphin", "save_layout": "gci", "bundle_dirs": "{root}/User/GC", "cards": {"default": "per_game", "modes": "per_game"}},
{"romm": ["wii"], "dir": "wii", "emulator": "dolphin", "save_layout": "wii", "bundle_dirs": "{root}/User/Wii"}
]
}
},
{
"id": "resonance", "name": "Resonance", "title_ids": ["DCST00001"], "detect": ["/data/Resonance"],
"note": "Each game's own memory card syncs as its save; the shared ones are backed up as RomM memory cards.",
"profile": {
"root": "/data/Resonance", "rom_dir": "{root}/games", "save_dir": "{root}/data", "bios_dir": "{root}/data",
"save_exts": ".bin", "disc_id": "dreamcast",
"save_stems": "{game_id}_vmu_save_A1", "save_name": "{game_id}_vmu_save_A1{ext}",
"memcards": "{root}/data/vmu_save_A1.bin,{root}/data/vmu_save_B1.bin,{root}/data/vmu_save_C1.bin,{root}/data/vmu_save_D1.bin",
"state_dir": "{root}/data", "state_exts": ".state", "state_format": "raw",
"state_stems": "{rom_stem}_1,{rom_stem}_2,{rom_stem}_3", "state_name": "{rom_stem}{ext}",
"firmware": {"names": {"e10c53c2f8b90bab96ead2d368858623": "dc_boot.bin"}, "from": {"arcade": ["naomi.zip", "naomi2.zip", "awbios.zip"]}},
"platforms": [{"romm": ["dc", "dreamcast"], "dir": "dreamcast", "save_format": "dc-vmu", "cards": {"default": "per_game", "modes": "per_game,shared", "change": "Turn on Own memory card for each game in its settings (players 2 to 4 always share theirs)", "setting": {"file": "{root}/emu.cfg", "key": "PerGameVmu", "per_game": "yes,true", "shared": "no,false"}}}]
}
},
{
"id": "ps4sx2", "name": "PS4SX2", "title_ids": ["PCSX20007"], "detect": ["/data/PS4SX2"],
"note": "Two memory cards for every game, backed up to RomM (5.3 or newer). No save states yet.",
"profile": {
"root": "/data/PS4SX2", "rom_dir": "{root}/roms", "bios_dir": "{root}/bios",
"sync_saves": false, "memcards": "{root}/memcards/Mcd001.ps2,{root}/memcards/Mcd002.ps2",
"firmware": {"pick_one": ["21038400dc633070a78ad53090c53017", "dc69f0643a3030aaa4797501b483d6c4", "80ac46fa7e77b8ab4366e86948e54f83", "93ea3bcee4252627919175ff1b16a1d9", "d3e81e95db25f5a86a7b7474550a2155"]},
"platforms": [{"romm": ["ps2"], "dir": "ps2", "cards": {"default": "shared", "modes": "shared"}}]
}
},
{
"id": "nps64", "name": "NPS64", "title_ids": ["NPSX00064"], "detect": ["/data/NPS64"],
"note": "Saves and states are named after a hash of the game file (.z64 dumps).",
"profile": {
"root": "/data/NPS64", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
"bios_dir": "{root}/system", "fixed": ["rom_dir", "save_dir", "state_dir"],
"save_exts": ".srm", "save_name": "rom-{rom_fnv64}{ext}", "save_stems": "rom-{rom_fnv64}",
"state_exts": ".state", "state_stems": "rom-{rom_fnv64}", "state_name": "rom-{rom_fnv64}.slot1{ext}", "state_format": "raw",
"firmware": {"skip": true},
"platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm", "cards": {"default": "per_game", "modes": "per_game"}}]
}
},
{
"id": "mgbaps4", "name": "mGBA", "title_ids": ["MGBA00001"], "detect": ["/data/mgba"],
"profile": {
"root": "/data/mgba", "rom_dir": "{root}/roms", "save_dir": "{root}/saves", "state_dir": "{root}/states",
"save_exts": ".sav", "state_exts": ".ss*", "state_slot0": ".ss1", "state_format": "raw",
"platforms": [
{"romm": ["gba"], "dir": "gba"},
{"romm": ["gbc"], "dir": "gbc"},
{"romm": ["gb"], "dir": "gb"}
]
}
},
{
"id": "snesui", "name": "SNESUI", "title_ids": ["SNES00001", "SNES00009"], "detect": ["/data/snes-ps4"],
"profile": {
"root": "/data/snes-ps4", "rom_dir": "{root}/juegos", "save_dir": "{root}/guardados", "state_dir": "{root}/guardados",
"save_exts": ".srm", "state_exts": ".st0,.st1,.st2,.st3", "state_slot0": ".st0", "state_format": "raw",
"firmware": {"skip": true},
"platforms": [{"romm": ["snes", "sfam"], "dir": "snes"}]
}
}
]
+173
View File
@@ -0,0 +1,173 @@
/* platform.h for the PS4 (GoldHEN, ps4-payload-dev SDK). The parts shared with
* the PS5 are in platform/console/console.c.
*
* GoldHEN (2.4b18.5 or newer) runs romm-sync-ps4.elf from its payload menu,
* which reads /data/payloads and can AutoRun it on every jailbreak. The payload
* runs inside a GoldHEN system process (NPXS21002 on 2.4b18.9), as root, so
* main() hands that thread back (plat_info detach) and an older copy is never
* killed: it would take the host process with it. A lock file keeps it to one.
* The SDK's startup code escapes the sandbox, so there's nothing to raise here.
*
* The foreground app follows the SDK's samples/ps. Links against SceSystemService. */
#include "platform.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/file.h>
#include <sys/stat.h>
#include "console.h"
#include "util.h"
#include "emulators_json.h" /* generated from platform/ps4/emulators.json */
#define LOCK_FILE CONSOLE_LOCK_FILE
/* libkernel: layout from sdk/samples/ps/main.c (title_id is wider than the PS5's) */
typedef struct app_info {
uint32_t app_id;
uint64_t unknown1;
char title_id[14];
char unknown2[0x3c];
} app_info_t;
int sceKernelGetAppInfo(pid_t pid, app_info_t *info);
/* libSceSystemService: no GetAppIdOfRunningBigApp or GetAppTitleId on the PS4 */
int sceSystemServiceGetAppIdOfBigApp(void);
static const char *const HOMEBREW_DIRS[] = {
"/data/homebrew", "/mnt/usb0/homebrew", "/mnt/usb1/homebrew", NULL};
/* RetroArch keeps its config, saves and core .info files here; the cores
* themselves are in /data/self/retroarch/cores (see retroarch.cfg). */
/* RetroArch builds: OsirizX's (/data/retroarch) and gen04177's RArch (/data/rarch). */
static const char *const EMULATOR_DIRS[] = {"/data/retroarch", "/data/rarch", NULL};
static const plat_info_t INFO = {
.name = "ps4",
.console = "PlayStation 4",
.client = "romm-sync-ps4",
.loader = "GoldHEN",
.payload = "romm-sync-ps4.elf",
.manifest = "manifest-ps4.json",
.manifest_sig = "manifest-ps4.sig",
/* GoldHEN's payload menu lists this folder; the user turns on AutoRun
* for the payload there once (the queue is the menu's own). */
.autostart_dir = "/data/payloads",
.autostart_flag = NULL,
.autostart_hint = "Turn on AutoRun for it in GoldHEN Settings, Payloader, AutoRun queue.",
.loader_dir = "/data/GoldHEN",
.homebrew_dirs = HOMEBREW_DIRS,
.emulator_dirs = EMULATOR_DIRS,
.retroarch_root = "/data/retroarch",
.mednafen_root = "/data/mednafen",
.emulator_catalog = EMULATORS_JSON,
/* GoldHEN runs the payload on a thread of its own process. */
.detach = 1,
.self_update = 1,
};
const plat_info_t *plat_info(void) { return &INFO; }
/* Startup steps go to <data>/boot.log before they run, so a payload that
* hangs or dies inside the loader still says how far it got. */
static void boot_step(const char *fmt, ...) {
FILE *f = fopen(CONSOLE_DATA_DIR "/boot.log", "a");
char line[256] = "boot: ";
va_list ap;
va_start(ap, fmt);
vsnprintf(line + 6, sizeof line - 6, fmt, ap);
va_end(ap);
netlog_send(line);
if (!f) return;
fprintf(f, "%s\n", line + 6);
fclose(f);
}
/* Held for as long as this copy runs. flock is per open file, so a second copy
* in the same process (the usual case under GoldHEN) is refused too. */
static int g_lock_fd = -1;
int plat_init(void) {
int rc = console_mkdir_p(CONSOLE_DATA_DIR);
/* The home screen app runs as a regular user and logs here. */
chmod(CONSOLE_DATA_DIR, 0777);
unlink(CONSOLE_DATA_DIR "/boot.log");
boot_step("start: pid %d uid %d", getpid(), getuid());
g_lock_fd = open(LOCK_FILE, O_RDWR | O_CREAT, 0666);
if (g_lock_fd >= 0 && flock(g_lock_fd, LOCK_EX | LOCK_NB) != 0 && errno == EWOULDBLOCK) {
boot_step("already running");
close(g_lock_fd);
g_lock_fd = -1;
return 1;
}
signal(SIGPIPE, SIG_IGN);
boot_step("platform ready");
return rc ? -1 : 0;
}
struct appid_ctx { int app_id; char title_id[16]; };
static int appid_cb(pid_t pid, const char *tdname, void *vctx) {
struct appid_ctx *c = vctx;
app_info_t info;
(void)tdname;
memset(&info, 0, sizeof info);
if (sceKernelGetAppInfo(pid, &info)) return 0;
if ((int)info.app_id != c->app_id) return 0;
memcpy(c->title_id, info.title_id, 9);
c->title_id[9] = 0;
return 1;
}
/* The big app's id, then the process that carries it (sdk samples/ps). */
int plat_running_title(char *title_id, size_t n) {
struct appid_ctx c;
if (!title_id || n < 10) return -1;
title_id[0] = 0;
memset(&c, 0, sizeof c);
c.app_id = sceSystemServiceGetAppIdOfBigApp();
if (c.app_id <= 0) return 0; /* < 0: no big app running */
if (console_for_each_proc(appid_cb, &c) != 1 || !console_valid_title_id(c.title_id)) return -1;
snprintf(title_id, n, "%s", c.title_id);
return 1;
}
void plat_describe(char *buf, size_t n) {
app_info_t info;
char host[16] = "";
memset(&info, 0, sizeof info);
if (!sceKernelGetAppInfo(getpid(), &info)) snprintf(host, sizeof host, "%.9s", info.title_id);
console_describe(buf, n, host);
}
void plat_remove_old_tile(void) {}
int plat_is_mount(const char *path) { (void)path; return 0; }
void plat_mounts_set(const plat_mount *mounts, int n) { (void)mounts; (void)n; }
int plat_app_installed(void) { return 0; }
void plat_app_version(char *out, size_t n) {
if (n) out[0] = 0;
}
/* Not used (can_relaunch is 0): GoldHEN's network loader (9090) crashes on
* ELFs in 2.4b18.x, so a new version starts with the next jailbreak. */
int plat_launch_elf(const void *elf, size_t len) {
(void)elf;
(void)len;
errno = ENOTSUP;
return -1;
}
+94
View File
@@ -0,0 +1,94 @@
# PS4 payload (GoldHEN), built with the ps4-payload-dev SDK. Included by the
# top-level Makefile; platform/ps4/docker/Dockerfile is the SDK image.
#
# GoldHEN 2.4b18.5+ runs ELF payloads from /data/payloads through its payload
# menu, which can AutoRun them on every jailbreak. Its network loader (9090)
# crashes on ELFs, so "make install-ps4" copies the payload there over FTP and
# it's started from the menu.
#
# "make ps4-pkg" builds the home screen app (platform/ps4/app) with OpenOrbis:
# a package that copies the bundled payload to /data/payloads and shows the
# web UI. It needs OO_PS4_TOOLCHAIN, which the Docker image sets.
PS4_HOST ?= ps4
PS4_FTP ?= ftp://$(PS4_HOST):2121
PS4_PAYLOAD_SDK ?= /opt/ps4-payload-sdk
PS4_CC := $(PS4_PAYLOAD_SDK)/bin/orbis-clang
PS4_PKGCONF := $(PS4_PAYLOAD_SDK)/bin/orbis-pkg-config
PS4_ELF := build/ps4/romm-sync-ps4.elf
# pacbrew's Mozilla CA bundle, built into the payload so HTTPS works out of the box.
PS4_CA := $(PS4_PAYLOAD_SDK)/target/user/homebrew/etc/ca-bundle.crt
PS4_PLAT := platform/ps4/platform.c platform/console/console.c
ps4: $(PS4_ELF)
build/ps4/gen/cacert_pem.h: $(PS4_CA) tools/embed.py
@mkdir -p $(dir $@)
python3 tools/embed.py $< $@ CACERT_PEM
# Standalone emulators setup can offer (src/detect.c).
build/ps4/gen/emulators_json.h: platform/ps4/emulators.json tools/embed.py
@mkdir -p $(dir $@)
python3 -c "import json,sys; json.load(open(sys.argv[1]))" $<
python3 tools/embed.py $< $@ EMULATORS_JSON
$(PS4_ELF): $(SRCS) $(PS4_PLAT) platform/console/console.h $(HDRS) $(GEN_HEADERS) build/ps4/gen/cacert_pem.h build/ps4/gen/emulators_json.h
@mkdir -p $(dir $@)
$(PS4_CC) $(COMMON_CFLAGS) -Ibuild/ps4/gen -Iplatform/console -DROMM_EMBED_CA $$($(PS4_PKGCONF) --cflags libcurl) -DCURL_STATICLIB \
-o $@ $(SRCS) $(PS4_PLAT) \
$$($(PS4_PKGCONF) --static --libs libcurl) -pthread -lSceSystemService
install-ps4: $(PS4_ELF)
curl -sS --ftp-create-dirs -T $(PS4_ELF) $(PS4_FTP)/data/payloads/romm-sync-ps4.elf
# Home screen app (OpenOrbis)
OO ?= $(OO_PS4_TOOLCHAIN)
PS4_TITLE_ID := ROMM00002
PS4_CONTENT_ID := IV0000-$(PS4_TITLE_ID)_00-ROMMSYNCPS400000
# The package's version follows the release, so a newer one installs as an
# update: 1.2.2 is 01.22 (one digit each for minor and patch), dev builds 01.00.
PS4_PKG_VER := $(shell echo '$(or $(VERSION),1.0.0)' | awk -F'[.-]' '{ if ($$2 > 9 || $$3 > 9) exit 1; printf "%02d.%d%d", $$1, $$2, $$3 }')
ifeq ($(PS4_PKG_VER),)
$(error VERSION $(VERSION) doesn't fit the package's NN.NN version)
endif
PS4_PKG_DIR := build/ps4/pkg
PS4_PKG := build/ps4/romm-sync-ps4.pkg
PS4_APP_LIBS := -lc -lkernel -lSceSysmodule -lSceCommonDialog -lSceMsgDialog -lSceWebBrowserDialog \
-lSceSystemService -lSceUserService
PKGTOOL := $(OO)/bin/linux/PkgTool.Core
.PHONY: ps4-pkg
ps4-pkg: $(PS4_PKG)
build/ps4/launcher.elf: platform/ps4/app/launcher.c
@mkdir -p $(dir $@)
clang --target=x86_64-pc-freebsd12-elf -fPIC -funwind-tables -O2 -Wall -Wextra -Wno-unused-parameter \
-isysroot $(OO) -isystem $(OO)/include -DAPP_VERSION='"$(or $(VERSION),dev)"' $(if $(NETLOG),-DROMM_NETLOG) -c -o build/ps4/launcher.o $<
ld.lld -m elf_x86_64 -pie --script $(OO)/link.x --eh-frame-hdr -L$(OO)/lib $(PS4_APP_LIBS) $(OO)/lib/crt1.o \
-o $@ build/ps4/launcher.o
# The package tree: the app, its metadata, OpenOrbis's modules and the payload.
$(PS4_PKG): build/ps4/launcher.elf $(PS4_ELF) assets/icon0.png
rm -rf $(PS4_PKG_DIR) && mkdir -p $(PS4_PKG_DIR)/sce_sys/about $(PS4_PKG_DIR)/sce_module
cd $(PS4_PKG_DIR) && $(OO)/bin/linux/create-fself -in=$(CURDIR)/build/ps4/launcher.elf \
-out=launcher.oelf --eboot eboot.bin --paid 0x3800000000000011 && rm launcher.oelf
cp assets/icon0.png $(PS4_PKG_DIR)/sce_sys/icon0.png
cp $(OO)/modules/right.sprx $(PS4_PKG_DIR)/sce_sys/about/
cp $(OO)/modules/libc.prx $(OO)/modules/libSceFios2.prx $(PS4_PKG_DIR)/sce_module/
cp $(PS4_ELF) $(PS4_PKG_DIR)/
cd $(PS4_PKG_DIR) && f=sce_sys/param.sfo && $(PKGTOOL) sfo_new $$f \
&& $(PKGTOOL) sfo_setentry $$f APP_TYPE --type Integer --maxsize 4 --value 1 \
&& $(PKGTOOL) sfo_setentry $$f APP_VER --type Utf8 --maxsize 8 --value '$(PS4_PKG_VER)' \
&& $(PKGTOOL) sfo_setentry $$f ATTRIBUTE --type Integer --maxsize 4 --value 0 \
&& $(PKGTOOL) sfo_setentry $$f CATEGORY --type Utf8 --maxsize 4 --value 'gd' \
&& $(PKGTOOL) sfo_setentry $$f CONTENT_ID --type Utf8 --maxsize 48 --value '$(PS4_CONTENT_ID)' \
&& $(PKGTOOL) sfo_setentry $$f DOWNLOAD_DATA_SIZE --type Integer --maxsize 4 --value 0 \
&& $(PKGTOOL) sfo_setentry $$f SYSTEM_VER --type Integer --maxsize 4 --value 0 \
&& $(PKGTOOL) sfo_setentry $$f TITLE --type Utf8 --maxsize 128 --value 'RommPS' \
&& $(PKGTOOL) sfo_setentry $$f TITLE_ID --type Utf8 --maxsize 12 --value '$(PS4_TITLE_ID)' \
&& $(PKGTOOL) sfo_setentry $$f VERSION --type Utf8 --maxsize 8 --value '$(PS4_PKG_VER)'
cd $(PS4_PKG_DIR) && $(OO)/bin/linux/create-gp4 -out pkg.gp4 --content-id=$(PS4_CONTENT_ID) \
--files "eboot.bin sce_sys/param.sfo sce_sys/icon0.png sce_sys/about/right.sprx sce_module/libc.prx sce_module/libSceFios2.prx romm-sync-ps4.elf" \
&& $(PKGTOOL) pkg_build pkg.gp4 .
mv $(PS4_PKG_DIR)/$(PS4_CONTENT_ID).pkg $@
+16
View File
@@ -0,0 +1,16 @@
# Build image for the native PS5 app (RommPS): the host tools mihawk-99's
# PS5_VulkanTemplate stack expects (ps5/tools/bootstrap.sh --check). Arch,
# like the stack's own build host; amd64 only, emulated on arm64.
FROM --platform=linux/amd64 archlinux:latest
# pacman's download sandbox needs a seccomp call Docker's amd64 emulation lacks.
RUN sed -i 's/^#\?DisableSandbox.*/DisableSandbox/; /^\[options\]/a DisableSandbox' /etc/pacman.conf \
&& pacman -Syu --noconfirm --needed \
base-devel git cmake clang lld llvm python python-numpy python-pillow python-mako \
python-packaging python-yaml python-pyelftools glslang spirv-tools rsync curl zstd ninja meson \
bison flex unzip wget which \
spirv-llvm-translator libclc spirv-headers libdrm \
&& pacman -Scc --noconfirm
# The stack and the app are mounted here (tools/ps5-app-build.sh).
WORKDIR /stack
+9
View File
@@ -0,0 +1,9 @@
build/
dist/
klog/
.deps/
imgui.ini
# Dashboard backgrounds, made by platform/ps5/app/tools/title-art.sh
ps5/sce_sys/pic0.dds
ps5/sce_sys/pic1.dds
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Sascha Willems
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
# RommPS
The native PS5 app for RomM Sync (`PPSA76677`): your RomM library, downloads, save sync status and settings, on the TV with the controller. It talks to the RomM Sync payload on the console (its local API on port 8780), so the payload has to be running; RommPS shows how to load it when it isn't.
It's built on mihawk-99's [PS5 Vulkan Template](https://github.com/mihawk-99/PS5_VulkanTemplate) (at `b577e950`): Sascha Willems' Vulkan example base class with its PS5 hooks, RADV for the GPU, and BlackBearReloaded's UI kit drawn with Vulkan through mihawk-99's PS5_VKHomebrewUI. The app itself is `examples/rommps/rommps.cpp` and the screens in `examples/rommps/kit/`.
## Building and running
It builds against mihawk-99's PS5 stack, checked out in `../ps5-stack` beside this repository (PS5_Vulkan, the payload SDK fork, PS5_Mesa), inside the Docker image from `platform/ps5/app/docker`. From the repository's root:
```bash
tools/ps5-app-build.sh bash -c 'cd /repo/platform/ps5/app/rommps && bash ps5/tools/build.sh rommps' # dist/PPSA76677/
tools/ps5-app-build.sh bash -c 'cd /repo/platform/ps5/app/rommps && bash ps5/tools/run.sh rommps' # deploy and a test run
tools/release-app.sh v1.2.3 # the release zip
```
The menu sounds are in `platform/ps5/app/assets/primary-menu-sounds/`, one WAV per kit cue (`focus_01.wav`, `back_01.wav` and so on).
## Licences
The UI kit is GPL-3.0-or-later, and so is RommPS as distributed: its screens, the UI module (`ps5/ui/`) and the kit's sounds (`ps5/licenses/GPL-3.0.txt`). The kit's fonts are OFL-1.1 and Bitstream Vera. The rest of the template is MIT (`LICENSE.md`: Sascha Willems', and mihawk-99's PS5 layer). The menu sounds are Konami's, used with permission (`ps5/SOUNDS-LICENCE.txt`). Every asset and its licence is listed in `ps5/ASSETS.md`, and the built app carries all of it in `LEGAL.txt` and `licenses/`.
@@ -0,0 +1,34 @@
# Copyright (c) 2016-2026, Sascha Willems
# SPDX-License-Identifier: MIT
file(GLOB BASE_SRC "*.cpp" "*.hpp" "*.h" "../external/imgui/*.cpp")
file(GLOB BASE_HEADERS "*.hpp" "*.h")
set(KTX_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../external/ktx)
set(KTX_SOURCES
${KTX_DIR}/lib/texture.c
${KTX_DIR}/lib/hashlist.c
${KTX_DIR}/lib/checkheader.c
${KTX_DIR}/lib/swap.c
${KTX_DIR}/lib/memstream.c
${KTX_DIR}/lib/filestream.c
${KTX_DIR}/lib/vkloader.c)
# Add shaders
set(SHADER_DIR_GLSL "../shaders/glsl/base")
file(GLOB SHADERS_GLSL "${SHADER_DIR_GLSL}/*.vert" "${SHADER_DIR_GLSL}/*.frag" "${SHADER_DIR_GLSL}/*.comp" "${SHADER_DIR_GLSL}/*.geom" "${SHADER_DIR_GLSL}/*.tesc" "${SHADER_DIR_GLSL}/*.tese" "${SHADER_DIR_GLSL}/*.mesh" "${SHADER_DIR_GLSL}/*.task" "${SHADER_DIR_GLSL}/*.rgen" "${SHADER_DIR_GLSL}/*.rchit" "${SHADER_DIR_GLSL}/*.rmiss" "${SHADER_DIR_GLSL}/*.rcall" "${SHADER_DIR_GLSL}/*.rahit" "${SHADER_DIR_GLSL}/*.rint" "${SHADER_DIR_GLSL}/*.glsl")
set(SHADER_DIR_HLSL "../shaders/hlsl/base")
file(GLOB SHADERS_HLSL "${SHADER_DIR_HLSL}/*.vert" "${SHADER_DIR_HLSL}/*.frag" "${SHADER_DIR_HLSL}/*.comp" "${SHADER_DIR_HLSL}/*.geom" "${SHADER_DIR_HLSL}/*.tesc" "${SHADER_DIR_HLSL}/*.tese" "${SHADER_DIR_HLSL}/*.mesh" "${SHADER_DIR_HLSL}/*.task" "${SHADER_DIR_HLSL}/*.rgen" "${SHADER_DIR_HLSL}/*.rchit" "${SHADER_DIR_HLSL}/*.rmiss" "${SHADER_DIR_HLSL}/*.rcall" "${SHADER_DIR_HLSL}/*.rahit" "${SHADER_DIR_HLSL}/*.rint")
set(SHADER_DIR_SLANG "../shaders/slang/base")
file(GLOB SHADERS_SLANG "${SHADER_DIR_SLANG}/*.slang")
source_group("Shaders\\GLSL" FILES ${SHADERS_GLSL})
source_group("Shaders\\HLSL" FILES ${SHADERS_HLSL})
source_group("Shaders\\SLANG" FILES ${SHADERS_SLANG})
message(STATUS ${SHADERS_GLSL})
add_library(base STATIC ${BASE_SRC} ${KTX_SOURCES} ${SHADERS_GLSL} ${SHADERS_HLSL} ${SHADERS_SLANG})
if(WIN32)
target_link_libraries(base ${Vulkan_LIBRARY} ${WINLIBS})
else(WIN32)
target_link_libraries(base ${Vulkan_LIBRARY} ${XCB_LIBRARIES} ${WAYLAND_CLIENT_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT})
endif(WIN32)
@@ -0,0 +1,117 @@
/*
* Simple command line parse
*
* Copyright (C) 2016-2022 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <vector>
#include <unordered_map>
#include <string>
class CommandLineParser
{
public:
struct CommandLineOption {
std::vector<std::string> commands;
std::string value;
bool hasValue = false;
std::string help;
bool set = false;
};
std::unordered_map<std::string, CommandLineOption> options;
void add(std::string name, std::vector<std::string> commands, bool hasValue, std::string help)
{
options[name].commands = commands;
options[name].help = help;
options[name].set = false;
options[name].hasValue = hasValue;
options[name].value = "";
}
void printHelp()
{
std::cout << "Available command line options:\n";
for (auto option : options) {
std::cout << " ";
for (size_t i = 0; i < option.second.commands.size(); i++) {
std::cout << option.second.commands[i];
if (i < option.second.commands.size() - 1) {
std::cout << ", ";
}
}
std::cout << ": " << option.second.help << "\n";
}
std::cout << "Press any key to close...";
}
void parse(std::vector<const char*> arguments)
{
bool printHelp = false;
// Known arguments
for (auto& option : options) {
for (auto& command : option.second.commands) {
for (size_t i = 0; i < arguments.size(); i++) {
if (strcmp(arguments[i], command.c_str()) == 0) {
option.second.set = true;
// Get value
if (option.second.hasValue) {
if (arguments.size() > i + 1) {
option.second.value = arguments[i + 1];
}
if (option.second.value == "") {
printHelp = true;
break;
}
}
}
}
}
}
// Print help for unknown arguments or missing argument values
if (printHelp) {
options["help"].set = true;
}
}
void parse(int argc, char* argv[])
{
std::vector<const char*> args;
for (int i = 0; i < argc; i++) {
args.push_back(argv[i]);
};
parse(args);
}
bool isSet(std::string name)
{
return ((options.find(name) != options.end()) && options[name].set);
}
std::string getValueAsString(std::string name, std::string defaultValue)
{
assert(options.find(name) != options.end());
std::string value = options[name].value;
return (value != "") ? value : defaultValue;
}
int32_t getValueAsInt(std::string name, int32_t defaultValue)
{
assert(options.find(name) != options.end());
std::string value = options[name].value;
if (value != "") {
char* numConvPtr;
int32_t intVal = (int32_t)strtol(value.c_str(), &numConvPtr, 10);
return (intVal > 0) ? intVal : defaultValue;
}
else {
return defaultValue;
}
return int32_t();
}
};
+289
View File
@@ -0,0 +1,289 @@
/*
* Vulkan entry points
*
* Platform specific macros for the example main entry points
*
* Copyright (C) 2024-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#if defined(VK_EXAMPLE_PS5)
/*
* PlayStation 5 (ps5/README.md)
*
* Every sample is linked into one title, each in a namespace of its own
* (ps5/src/wrap.cpp.in), and the title's launcher creates them by name: a
* sample has no main of its own.
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
VulkanExampleBase *createExample() \
{ \
vulkanExample = new VulkanExample(); \
return vulkanExample; \
}
#elif defined(_WIN32)
/*
* Windows
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) \
{ \
if (vulkanExample != NULL) \
{ \
vulkanExample->handleMessages(hWnd, uMsg, wParam, lParam); \
} \
return (DefWindowProc(hWnd, uMsg, wParam, lParam)); \
} \
int APIENTRY WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR, _In_ int) \
{ \
for (int32_t i = 0; i < __argc; i++) { VulkanExample::args.push_back(__argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(hInstance, WndProc); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
/*
* Android
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
void android_main(android_app* state) \
{ \
androidApp = state; \
vks::android::getDeviceConfig(); \
vulkanExample = new VulkanExample(); \
state->userData = vulkanExample; \
state->onAppCmd = VulkanExample::handleAppCommand; \
state->onInputEvent = VulkanExample::handleAppInput; \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
}
#elif defined(VK_USE_PLATFORM_OHOS)
/**
* OHOS
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample vulkanExample; \
\
void OnSurfaceCreatedCB(OH_NativeXComponent *component, void *window) { \
LOGD("OnSurfaceCreatedCB"); \
uint64_t width, height; \
int32_t ret = OH_NativeXComponent_GetXComponentSize(component, window, &width, &height); \
\
if (!vulkanExample.initVulkan()) { \
LOGE("PluginRender::OnSurfaceCreated vulkanExample initVulkan FALSE"); \
return; \
} \
nativeWindow = (static_cast<OHNativeWindow *>(window)); \
vulkanExample.prepare(); \
uv_async_send(&msgSignal); \
} \
\
void OnSurfaceChangedCB(OH_NativeXComponent *component, void *window) { \
LOGD("OnSurfaceChangedCB"); \
} \
\
void OnSurfaceDestroyedCB(OH_NativeXComponent *component, void *window) { \
LOGD("OnSurfaceDestroyedCB"); \
} \
\
void DispatchTouchEventCB(OH_NativeXComponent *component, void *window) { \
VulkanExample::handleAppInput(component, window, &vulkanExample); \
} \
\
extern "C" napi_value Init(napi_env env, napi_value exports) { \
napi_status status; \
napi_value exportInstance = nullptr; \
OH_NativeXComponent *nativeXComponent = nullptr; \
int32_t ret; \
char idStr[OH_XCOMPONENT_ID_LEN_MAX + 1] = {}; \
uint64_t idSize = OH_XCOMPONENT_ID_LEN_MAX + 1; \
status = napi_get_named_property(env, exports, OH_NATIVE_XCOMPONENT_OBJ, &exportInstance); \
if (status != napi_ok) { \
LOGE("Export: napi_get_named_property fail"); \
} \
status = napi_unwrap(env, exportInstance, reinterpret_cast<void **>(&nativeXComponent)); \
if (status != napi_ok) { \
LOGE("Export: napi_unwrap failed status[%{public}d]", status); \
} \
napi_value global; \
status = napi_get_global(env, &global); \
status = napi_get_named_property(env, global, "globalThis", &global); \
napi_value densityDPI; \
status = napi_get_named_property(env, global, "densityDPI", &densityDPI); \
double density = 0.0f; \
napi_get_value_double(env, densityDPI, &density); \
vks::OHOS::setDeviceConfig(density); \
napi_value context; \
status = napi_get_named_property(env, global, "context", &context); \
napi_value resourceManager = NULL; \
status = napi_get_named_property(env, context, "resourceManager", &resourceManager); \
NativeResourceManager *nativeResourceManager = OH_ResourceManager_InitNativeResourceManager(env, resourceManager); \
ResourceManager::getInstance().initialize(nativeResourceManager); \
status = napi_get_uv_event_loop(env, &loop); \
msgSignal.data = &vulkanExample; \
\
int uv_status = uv_async_init(loop, &msgSignal, VulkanExampleBase::onMessageCallbackStatic); \
static OH_NativeXComponent_Callback callback; \
callback.OnSurfaceCreated = OnSurfaceCreatedCB; \
callback.OnSurfaceChanged = OnSurfaceChangedCB; \
callback.OnSurfaceDestroyed = OnSurfaceDestroyedCB; \
callback.DispatchTouchEvent = DispatchTouchEventCB; \
\
OH_NativeXComponent_RegisterCallback(nativeXComponent, &callback); \
return exports; \
} \
\
static napi_module demoModule = { \
.nm_version = 1, \
.nm_flags = 0, \
.nm_filename = nullptr, \
.nm_register_func = Init, \
.nm_modname = "nativerender_triangle", \
.nm_priv = ((void *)0), \
.reserved = {0}, \
}; \
\
extern "C" __attribute__((constructor)) void RegisterEntryModule(void) { napi_module_register(&demoModule); }
#elif defined(_DIRECT2DISPLAY)
/*
* Direct-to-display
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
static void handleEvent() \
{ \
} \
int main(const int argc, const char *argv[]) \
{ \
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
/*
* Direct FB
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
static void handleEvent(const DFBWindowEvent *event) \
{ \
if (vulkanExample != NULL) \
{ \
vulkanExample->handleEvent(event); \
} \
} \
int main(const int argc, const char *argv[]) \
{ \
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#elif (defined(VK_USE_PLATFORM_WAYLAND_KHR) || defined(VK_USE_PLATFORM_HEADLESS_EXT))
/*
* Wayland / headless
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
int main(const int argc, const char *argv[]) \
{ \
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#elif defined(VK_USE_PLATFORM_XCB_KHR)
/*
* X11 Xcb
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
static void handleEvent(const xcb_generic_event_t *event) \
{ \
if (vulkanExample != NULL) \
{ \
vulkanExample->handleEvent(event); \
} \
} \
int main(const int argc, const char *argv[]) \
{ \
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
/*
* iOS and macOS (using MoltenVK)
*/
#if defined(VK_EXAMPLE_XCODE_GENERATED)
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
int main(const int argc, const char *argv[]) \
{ \
@autoreleasepool \
{ \
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(nullptr); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
} \
return 0; \
}
#else
#define VULKAN_EXAMPLE_MAIN()
#endif
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
/*
* QNX Screen
*/
#define VULKAN_EXAMPLE_MAIN() \
VulkanExample *vulkanExample; \
int main(const int argc, const char *argv[]) \
{ \
for (int i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); }; \
vulkanExample = new VulkanExample(); \
vulkanExample->initVulkan(); \
vulkanExample->setupWindow(); \
vulkanExample->prepare(); \
vulkanExample->renderLoop(); \
delete(vulkanExample); \
return 0; \
}
#endif
@@ -0,0 +1,139 @@
/*
* Vulkan buffer class
*
* Encapsulates a Vulkan buffer
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanBuffer.h"
namespace vks
{
/**
* Map a memory range of this buffer. If successful, mapped points to the specified buffer range.
*
* @param size (Optional) Size of the memory range to map. Pass VK_WHOLE_SIZE to map the complete buffer range.
* @param offset (Optional) Byte offset from beginning
*
* @return VkResult of the buffer mapping call
*/
VkResult Buffer::map(VkDeviceSize size, VkDeviceSize offset)
{
return vkMapMemory(device, memory, offset, size, 0, &mapped);
}
/**
* Unmap a mapped memory range
*
* @note Does not return a result as vkUnmapMemory can't fail
*/
void Buffer::unmap()
{
if (mapped)
{
vkUnmapMemory(device, memory);
mapped = nullptr;
}
}
/**
* Attach the allocated memory block to the buffer
*
* @param offset (Optional) Byte offset (from the beginning) for the memory region to bind
*
* @return VkResult of the bindBufferMemory call
*/
VkResult Buffer::bind(VkDeviceSize offset)
{
return vkBindBufferMemory(device, buffer, memory, offset);
}
/**
* Setup the default descriptor for this buffer
*
* @param size (Optional) Size of the memory range of the descriptor
* @param offset (Optional) Byte offset from beginning
*
*/
void Buffer::setupDescriptor(VkDeviceSize size, VkDeviceSize offset)
{
descriptor.offset = offset;
descriptor.buffer = buffer;
descriptor.range = size;
}
/**
* Copies the specified data to the mapped buffer
*
* @param data Pointer to the data to copy
* @param size Size of the data to copy in machine units
*
*/
void Buffer::copyTo(void* data, VkDeviceSize size)
{
assert(mapped);
memcpy(mapped, data, size);
}
/**
* Flush a memory range of the buffer to make it visible to the device
*
* @note Only required for non-coherent memory
*
* @param size (Optional) Size of the memory range to flush. Pass VK_WHOLE_SIZE to flush the complete buffer range.
* @param offset (Optional) Byte offset from beginning
*
* @return VkResult of the flush call
*/
VkResult Buffer::flush(VkDeviceSize size, VkDeviceSize offset)
{
VkMappedMemoryRange mappedRange{
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = offset,
.size = size
};
return vkFlushMappedMemoryRanges(device, 1, &mappedRange);
}
/**
* Invalidate a memory range of the buffer to make it visible to the host
*
* @note Only required for non-coherent memory
*
* @param size (Optional) Size of the memory range to invalidate. Pass VK_WHOLE_SIZE to invalidate the complete buffer range.
* @param offset (Optional) Byte offset from beginning
*
* @return VkResult of the invalidate call
*/
VkResult Buffer::invalidate(VkDeviceSize size, VkDeviceSize offset)
{
VkMappedMemoryRange mappedRange{
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = offset,
.size = size
};
return vkInvalidateMappedMemoryRanges(device, 1, &mappedRange);
}
/**
* Release all Vulkan resources held by this buffer
*/
void Buffer::destroy()
{
if (buffer)
{
vkDestroyBuffer(device, buffer, nullptr);
buffer = VK_NULL_HANDLE;
}
if (memory)
{
vkFreeMemory(device, memory, nullptr);
memory = VK_NULL_HANDLE;
}
}
};
@@ -0,0 +1,47 @@
/*
* Vulkan buffer class
*
* Encapsulates a Vulkan buffer
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <vector>
#include "vulkan/vulkan.h"
#include "VulkanTools.h"
namespace vks
{
/**
* @brief Encapsulates access to a Vulkan buffer backed up by device memory
* @note To be filled by an external source like the VulkanDevice
*/
struct Buffer
{
VkDevice device;
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
VkDescriptorBufferInfo descriptor;
VkDeviceSize size = 0;
VkDeviceSize alignment = 0;
void* mapped = nullptr;
/** @brief Usage flags to be filled by external source at buffer creation (to query at some later point) */
VkBufferUsageFlags usageFlags;
/** @brief Memory property flags to be filled by external source at buffer creation (to query at some later point) */
VkMemoryPropertyFlags memoryPropertyFlags;
uint64_t deviceAddress;
VkResult map(VkDeviceSize size = VK_WHOLE_SIZE, VkDeviceSize offset = 0);
void unmap();
VkResult bind(VkDeviceSize offset = 0);
void setupDescriptor(VkDeviceSize size = VK_WHOLE_SIZE, VkDeviceSize offset = 0);
void copyTo(void* data, VkDeviceSize size);
VkResult flush(VkDeviceSize size = VK_WHOLE_SIZE, VkDeviceSize offset = 0);
VkResult invalidate(VkDeviceSize size = VK_WHOLE_SIZE, VkDeviceSize offset = 0);
void destroy();
};
}
@@ -0,0 +1,181 @@
/*
* Vulkan examples debug wrapper
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanDebug.h"
#include <iostream>
namespace vks
{
namespace debug
{
bool logToFile{ false };
std::string logFileName{ "validation_output.txt" };
PFN_vkCreateDebugUtilsMessengerEXT vkCreateDebugUtilsMessengerEXT;
PFN_vkDestroyDebugUtilsMessengerEXT vkDestroyDebugUtilsMessengerEXT;
VkDebugUtilsMessengerEXT debugUtilsMessenger;
VKAPI_ATTR VkBool32 VKAPI_CALL debugUtilsMessageCallback(
VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
VkDebugUtilsMessageTypeFlagsEXT messageType,
const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
void* pUserData)
{
// Select prefix depending on flags passed to the callback
std::string prefix;
if (messageSeverity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT) {
prefix = "VERBOSE: ";
#if defined(_WIN32)
if (!logToFile) {
prefix = "\033[32m" + prefix + "\033[0m";
}
#endif
}
else if (messageSeverity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT) {
prefix = "INFO: ";
#if defined(_WIN32)
if (!logToFile) {
prefix = "\033[36m" + prefix + "\033[0m";
}
#endif
}
else if (messageSeverity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) {
prefix = "WARNING: ";
#if defined(_WIN32)
if (!logToFile) {
prefix = "\033[33m" + prefix + "\033[0m";
}
#endif
}
else if (messageSeverity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) {
prefix = "ERROR: ";
#if defined(_WIN32)
if (!logToFile) {
prefix = "\033[31m" + prefix + "\033[0m";
}
#endif
}
// Display message to default output (console/logcat)
std::stringstream debugMessage;
if (pCallbackData->pMessageIdName) {
debugMessage << prefix << "[" << pCallbackData->messageIdNumber << "][" << pCallbackData->pMessageIdName << "] : " << pCallbackData->pMessage;
}
else {
debugMessage << prefix << "[" << pCallbackData->messageIdNumber << "] : " << pCallbackData->pMessage;
}
#if defined(__ANDROID__)
if (messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) {
LOGE("%s", debugMessage.str().c_str());
} else {
LOGD("%s", debugMessage.str().c_str());
}
#elif defined(__OHOS__)
if (messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) {
LOGE("%{public}s", debugMessage.str().c_str());
} else {
LOGD("%{public}s", debugMessage.str().c_str());
}
#else
if (messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) {
std::cerr << debugMessage.str() << "\n\n";
} else {
std::cout << debugMessage.str() << "\n\n";
}
if (logToFile) {
log(debugMessage.str());
}
fflush(stdout);
#endif
// The return value of this callback controls whether the Vulkan call that caused the validation message will be aborted or not
// We return VK_FALSE as we DON'T want Vulkan calls that cause a validation message to abort
// If you instead want to have calls abort, pass in VK_TRUE and the function will return VK_ERROR_VALIDATION_FAILED_EXT
return VK_FALSE;
}
void setupDebugingMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT& debugUtilsMessengerCI)
{
debugUtilsMessengerCI.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
debugUtilsMessengerCI.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
debugUtilsMessengerCI.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT;
debugUtilsMessengerCI.pfnUserCallback = debugUtilsMessageCallback;
}
void log(std::string message)
{
if (logToFile) {
time_t timestamp;
time(&timestamp);
std::ofstream logfile;
logfile.open(logFileName, std::ios_base::app);
logfile << strtok(ctime(&timestamp), "\n") << ": " << message << std::endl;
logfile.close();
}
}
void setupDebugging(VkInstance instance)
{
vkCreateDebugUtilsMessengerEXT = reinterpret_cast<PFN_vkCreateDebugUtilsMessengerEXT>(vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT"));
vkDestroyDebugUtilsMessengerEXT = reinterpret_cast<PFN_vkDestroyDebugUtilsMessengerEXT>(vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT"));
VkDebugUtilsMessengerCreateInfoEXT debugUtilsMessengerCI{};
setupDebugingMessengerCreateInfo(debugUtilsMessengerCI);
VkResult result = vkCreateDebugUtilsMessengerEXT(instance, &debugUtilsMessengerCI, nullptr, &debugUtilsMessenger);
assert(result == VK_SUCCESS);
}
void freeDebugCallback(VkInstance instance)
{
if (debugUtilsMessenger != VK_NULL_HANDLE)
{
vkDestroyDebugUtilsMessengerEXT(instance, debugUtilsMessenger, nullptr);
}
}
}
namespace debugutils
{
PFN_vkCmdBeginDebugUtilsLabelEXT vkCmdBeginDebugUtilsLabelEXT{ nullptr };
PFN_vkCmdEndDebugUtilsLabelEXT vkCmdEndDebugUtilsLabelEXT{ nullptr };
PFN_vkCmdInsertDebugUtilsLabelEXT vkCmdInsertDebugUtilsLabelEXT{ nullptr };
void setup(VkInstance instance)
{
vkCmdBeginDebugUtilsLabelEXT = reinterpret_cast<PFN_vkCmdBeginDebugUtilsLabelEXT>(vkGetInstanceProcAddr(instance, "vkCmdBeginDebugUtilsLabelEXT"));
vkCmdEndDebugUtilsLabelEXT = reinterpret_cast<PFN_vkCmdEndDebugUtilsLabelEXT>(vkGetInstanceProcAddr(instance, "vkCmdEndDebugUtilsLabelEXT"));
vkCmdInsertDebugUtilsLabelEXT = reinterpret_cast<PFN_vkCmdInsertDebugUtilsLabelEXT>(vkGetInstanceProcAddr(instance, "vkCmdInsertDebugUtilsLabelEXT"));
}
void cmdBeginLabel(VkCommandBuffer cmdbuffer, std::string caption, glm::vec4 color)
{
if (!vkCmdBeginDebugUtilsLabelEXT) {
return;
}
VkDebugUtilsLabelEXT labelInfo{};
labelInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
labelInfo.pLabelName = caption.c_str();
memcpy(labelInfo.color, &color[0], sizeof(float) * 4);
vkCmdBeginDebugUtilsLabelEXT(cmdbuffer, &labelInfo);
}
void cmdEndLabel(VkCommandBuffer cmdbuffer)
{
if (!vkCmdEndDebugUtilsLabelEXT) {
return;
}
vkCmdEndDebugUtilsLabelEXT(cmdbuffer);
}
};
}
@@ -0,0 +1,67 @@
/*
* Vulkan examples debug wrapper
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include "vulkan/vulkan.h"
#include <math.h>
#include <stdlib.h>
#include <string>
#include <cstring>
#include <fstream>
#include <assert.h>
#include <stdio.h>
#include <vector>
#include <sstream>
#ifdef _WIN32
#include <windows.h>
#include <fcntl.h>
#include <io.h>
#endif
#ifdef __ANDROID__
#include "VulkanAndroid.h"
#endif
#ifdef __OHOS__
#include "VulkanOHOS.h"
#endif
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
namespace vks
{
namespace debug
{
extern bool logToFile;
extern std::string logFileName;
// Default debug callback
VKAPI_ATTR VkBool32 VKAPI_CALL debugUtilsMessageCallback(
VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
VkDebugUtilsMessageTypeFlagsEXT messageType,
const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
void* pUserData);
// Load debug function pointers and set debug callback
void setupDebugging(VkInstance instance);
// Clear debug callback
void freeDebugCallback(VkInstance instance);
// Used to populate a VkDebugUtilsMessengerCreateInfoEXT with our example messenger function and desired flags
void setupDebugingMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT& debugUtilsMessengerCI);
void log(std::string message);
}
// Wrapper for the VK_EXT_debug_utils extension
// These can be used to name Vulkan objects for debugging tools like RenderDoc
namespace debugutils
{
void setup(VkInstance instance);
void cmdBeginLabel(VkCommandBuffer cmdbuffer, std::string caption, glm::vec4 color);
void cmdEndLabel(VkCommandBuffer cmdbuffer);
}
}
@@ -0,0 +1,597 @@
/*
* Vulkan device class
*
* Encapsulates a physical Vulkan device and its logical representation
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#if (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
// SRS - Enable beta extensions and make VK_KHR_portability_subset visible
#define VK_ENABLE_BETA_EXTENSIONS
#endif
#include <VulkanDevice.h>
#include <unordered_set>
namespace vks
{
/**
* Default constructor
*
* @param physicalDevice Physical device that is to be used
*/
VulkanDevice::VulkanDevice(VkPhysicalDevice physicalDevice)
{
assert(physicalDevice);
this->physicalDevice = physicalDevice;
// Store Properties features, limits and properties of the physical device for later use
// Device properties also contain limits and sparse properties
vkGetPhysicalDeviceProperties(physicalDevice, &properties);
// Features should be checked by the examples before using them
vkGetPhysicalDeviceFeatures(physicalDevice, &features);
// Memory properties are used regularly for creating all kinds of buffers
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memoryProperties);
// Queue family properties, used for setting up requested queues upon device creation
uint32_t queueFamilyCount;
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, nullptr);
assert(queueFamilyCount > 0);
queueFamilyProperties.resize(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, queueFamilyProperties.data());
// Get list of supported extensions
uint32_t extCount = 0;
vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extCount, nullptr);
if (extCount > 0)
{
std::vector<VkExtensionProperties> extensions(extCount);
if (vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extCount, &extensions.front()) == VK_SUCCESS)
{
for (auto& ext : extensions)
{
supportedExtensions.push_back(ext.extensionName);
}
}
}
}
/**
* Default destructor
*
* @note Frees the logical device
*/
VulkanDevice::~VulkanDevice()
{
if (commandPool)
{
vkDestroyCommandPool(logicalDevice, commandPool, nullptr);
}
if (logicalDevice)
{
vkDestroyDevice(logicalDevice, nullptr);
}
}
/**
* Get the index of a memory type that has all the requested property bits set
*
* @param typeBits Bit mask with bits set for each memory type supported by the resource to request for (from VkMemoryRequirements)
* @param properties Bit mask of properties for the memory type to request
* @param (Optional) memTypeFound Pointer to a bool that is set to true if a matching memory type has been found
*
* @return Index of the requested memory type
*
* @throw Throws an exception if memTypeFound is null and no memory type could be found that supports the requested properties
*/
uint32_t VulkanDevice::getMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties, VkBool32 *memTypeFound) const
{
for (uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++)
{
if ((typeBits & 1) == 1)
{
if ((memoryProperties.memoryTypes[i].propertyFlags & properties) == properties)
{
if (memTypeFound)
{
*memTypeFound = true;
}
return i;
}
}
typeBits >>= 1;
}
if (memTypeFound)
{
*memTypeFound = false;
return 0;
}
else
{
throw std::runtime_error("Could not find a matching memory type");
}
}
/**
* Get the index of a queue family that supports the requested queue flags
* SRS - support VkQueueFlags parameter for requesting multiple flags vs. VkQueueFlagBits for a single flag only
*
* @param queueFlags Queue flags to find a queue family index for
*
* @return Index of the queue family index that matches the flags
*
* @throw Throws an exception if no queue family index could be found that supports the requested flags
*/
uint32_t VulkanDevice::getQueueFamilyIndex(VkQueueFlags queueFlags) const
{
// Dedicated queue for compute
// Try to find a queue family index that supports compute but not graphics
if ((queueFlags & VK_QUEUE_COMPUTE_BIT) == queueFlags)
{
for (uint32_t i = 0; i < static_cast<uint32_t>(queueFamilyProperties.size()); i++)
{
if ((queueFamilyProperties[i].queueFlags & VK_QUEUE_COMPUTE_BIT) && ((queueFamilyProperties[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) == 0))
{
return i;
}
}
}
// Dedicated queue for transfer
// Try to find a queue family index that supports transfer but not graphics and compute
if ((queueFlags & VK_QUEUE_TRANSFER_BIT) == queueFlags)
{
for (uint32_t i = 0; i < static_cast<uint32_t>(queueFamilyProperties.size()); i++)
{
if ((queueFamilyProperties[i].queueFlags & VK_QUEUE_TRANSFER_BIT) && ((queueFamilyProperties[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) == 0) && ((queueFamilyProperties[i].queueFlags & VK_QUEUE_COMPUTE_BIT) == 0))
{
return i;
}
}
}
// For other queue types or if no separate compute queue is present, return the first one to support the requested flags
for (uint32_t i = 0; i < static_cast<uint32_t>(queueFamilyProperties.size()); i++)
{
if ((queueFamilyProperties[i].queueFlags & queueFlags) == queueFlags)
{
return i;
}
}
throw std::runtime_error("Could not find a matching queue family index");
}
/**
* Create the logical device based on the assigned physical device, also gets default queue family indices
*
* @param enabledFeatures Can be used to enable certain features upon device creation
* @param pNextChain Optional chain of pointer to extension structures
* @param useSwapChain Set to false for headless rendering to omit the swapchain device extensions
* @param requestedQueueTypes Bit flags specifying the queue types to be requested from the device
*
* @return VkResult of the device creation call
*/
VkResult VulkanDevice::createLogicalDevice(VkPhysicalDeviceFeatures enabledFeatures, std::vector<const char*> enabledExtensions, void* pNextChain, bool useSwapChain, VkQueueFlags requestedQueueTypes)
{
// Desired queues need to be requested upon logical device creation
// Due to differing queue family configurations of Vulkan implementations this can be a bit tricky, especially if the application
// requests different queue types
std::vector<VkDeviceQueueCreateInfo> queueCreateInfos{};
// Get queue family indices for the requested queue family types
// Note that the indices may overlap depending on the implementation
const float defaultQueuePriority(0.0f);
// Graphics queue
if (requestedQueueTypes & VK_QUEUE_GRAPHICS_BIT)
{
queueFamilyIndices.graphics = getQueueFamilyIndex(VK_QUEUE_GRAPHICS_BIT);
VkDeviceQueueCreateInfo queueInfo{
.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
.queueFamilyIndex = queueFamilyIndices.graphics,
.queueCount = 1,
.pQueuePriorities = &defaultQueuePriority
};
queueCreateInfos.push_back(queueInfo);
}
else
{
queueFamilyIndices.graphics = 0;
}
// Dedicated compute queue
if (requestedQueueTypes & VK_QUEUE_COMPUTE_BIT)
{
queueFamilyIndices.compute = getQueueFamilyIndex(VK_QUEUE_COMPUTE_BIT);
if (queueFamilyIndices.compute != queueFamilyIndices.graphics)
{
// If compute family index differs, we need an additional queue create info for the compute queue
VkDeviceQueueCreateInfo queueInfo{
.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
.queueFamilyIndex = queueFamilyIndices.compute,
.queueCount = 1,
.pQueuePriorities = &defaultQueuePriority,
};
queueCreateInfos.push_back(queueInfo);
}
}
else
{
// Else we use the same queue
queueFamilyIndices.compute = queueFamilyIndices.graphics;
}
// Dedicated transfer queue
if (requestedQueueTypes & VK_QUEUE_TRANSFER_BIT)
{
queueFamilyIndices.transfer = getQueueFamilyIndex(VK_QUEUE_TRANSFER_BIT);
if ((queueFamilyIndices.transfer != queueFamilyIndices.graphics) && (queueFamilyIndices.transfer != queueFamilyIndices.compute))
{
// If transfer family index differs, we need an additional queue create info for the transfer queue
VkDeviceQueueCreateInfo queueInfo{
.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
.queueFamilyIndex = queueFamilyIndices.transfer,
.queueCount = 1,
.pQueuePriorities = &defaultQueuePriority
};
queueCreateInfos.push_back(queueInfo);
}
}
else
{
// Else we use the same queue
queueFamilyIndices.transfer = queueFamilyIndices.graphics;
}
// Create the logical device representation
std::vector<const char*> deviceExtensions(enabledExtensions);
if (useSwapChain)
{
// If the device will be used for presenting to a display via a swapchain we need to request the swapchain extension
deviceExtensions.push_back(VK_KHR_SWAPCHAIN_EXTENSION_NAME);
}
VkDeviceCreateInfo deviceCreateInfo{
.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size()),
.pQueueCreateInfos = queueCreateInfos.data(),
.pEnabledFeatures = &enabledFeatures
};
// If a pNext(Chain) has been passed, we need to add it to the device creation info
VkPhysicalDeviceFeatures2 physicalDeviceFeatures2{};
if (pNextChain) {
physicalDeviceFeatures2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
physicalDeviceFeatures2.features = enabledFeatures;
physicalDeviceFeatures2.pNext = pNextChain;
deviceCreateInfo.pEnabledFeatures = nullptr;
deviceCreateInfo.pNext = &physicalDeviceFeatures2;
}
#if (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT)) && defined(VK_KHR_portability_subset)
// SRS - When running on iOS/macOS with MoltenVK and VK_KHR_portability_subset is defined and supported by the device, enable the extension
if (extensionSupported(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME))
{
deviceExtensions.push_back(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME);
}
#endif
if (deviceExtensions.size() > 0)
{
for (const char* enabledExtension : deviceExtensions)
{
if (!extensionSupported(enabledExtension)) {
std::cerr << "Enabled device extension \"" << enabledExtension << "\" is not present at device level\n";
}
}
deviceCreateInfo.enabledExtensionCount = (uint32_t)deviceExtensions.size();
deviceCreateInfo.ppEnabledExtensionNames = deviceExtensions.data();
}
this->enabledFeatures = enabledFeatures;
VkResult result = vkCreateDevice(physicalDevice, &deviceCreateInfo, nullptr, &logicalDevice);
if (result != VK_SUCCESS)
{
return result;
}
// Create a default command pool for graphics command buffers
commandPool = createCommandPool(queueFamilyIndices.graphics);
return result;
}
/**
* Create a buffer on the device
*
* @param usageFlags Usage flag bit mask for the buffer (i.e. index, vertex, uniform buffer)
* @param memoryPropertyFlags Memory properties for this buffer (i.e. device local, host visible, coherent)
* @param size Size of the buffer in byes
* @param buffer Pointer to the buffer handle acquired by the function
* @param memory Pointer to the memory handle acquired by the function
* @param data Pointer to the data that should be copied to the buffer after creation (optional, if not set, no data is copied over)
*
* @return VK_SUCCESS if buffer handle and memory have been created and (optionally passed) data has been copied
*/
VkResult VulkanDevice::createBuffer(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, VkDeviceSize size, VkBuffer *buffer, VkDeviceMemory *memory, void *data)
{
// Create the buffer handle
VkBufferCreateInfo bufferCreateInfo = vks::initializers::bufferCreateInfo(usageFlags, size);
bufferCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK_CHECK_RESULT(vkCreateBuffer(logicalDevice, &bufferCreateInfo, nullptr, buffer));
// Create the memory backing up the buffer handle
VkMemoryRequirements memReqs;
VkMemoryAllocateInfo memAlloc = vks::initializers::memoryAllocateInfo();
vkGetBufferMemoryRequirements(logicalDevice, *buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
// Find a memory type index that fits the properties of the buffer
memAlloc.memoryTypeIndex = getMemoryType(memReqs.memoryTypeBits, memoryPropertyFlags);
// If the buffer has VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT set we also need to enable the appropriate flag during allocation
VkMemoryAllocateFlagsInfoKHR allocFlagsInfo{};
if (usageFlags & VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT) {
allocFlagsInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO_KHR;
allocFlagsInfo.flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR;
memAlloc.pNext = &allocFlagsInfo;
}
VK_CHECK_RESULT(vkAllocateMemory(logicalDevice, &memAlloc, nullptr, memory));
// If a pointer to the buffer data has been passed, map the buffer and copy over the data
if (data != nullptr)
{
void *mapped;
VK_CHECK_RESULT(vkMapMemory(logicalDevice, *memory, 0, size, 0, &mapped));
memcpy(mapped, data, size);
// If host coherency hasn't been requested, do a manual flush to make writes visible
if ((memoryPropertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0)
{
VkMappedMemoryRange mappedRange{
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = *memory,
.size = size
};
vkFlushMappedMemoryRanges(logicalDevice, 1, &mappedRange);
}
vkUnmapMemory(logicalDevice, *memory);
}
// Attach the memory to the buffer object
VK_CHECK_RESULT(vkBindBufferMemory(logicalDevice, *buffer, *memory, 0));
return VK_SUCCESS;
}
/**
* Create a buffer on the device
*
* @param usageFlags Usage flag bit mask for the buffer (i.e. index, vertex, uniform buffer)
* @param memoryPropertyFlags Memory properties for this buffer (i.e. device local, host visible, coherent)
* @param buffer Pointer to a vk::Vulkan buffer object
* @param size Size of the buffer in bytes
* @param data Pointer to the data that should be copied to the buffer after creation (optional, if not set, no data is copied over)
*
* @return VK_SUCCESS if buffer handle and memory have been created and (optionally passed) data has been copied
*/
VkResult VulkanDevice::createBuffer(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, vks::Buffer *buffer, VkDeviceSize size, void *data)
{
buffer->device = logicalDevice;
// Create the buffer handle
VkBufferCreateInfo bufferCreateInfo = vks::initializers::bufferCreateInfo(usageFlags, size);
VK_CHECK_RESULT(vkCreateBuffer(logicalDevice, &bufferCreateInfo, nullptr, &buffer->buffer));
// Create the memory backing up the buffer handle
VkMemoryRequirements memReqs;
VkMemoryAllocateInfo memAlloc = vks::initializers::memoryAllocateInfo();
vkGetBufferMemoryRequirements(logicalDevice, buffer->buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
// Find a memory type index that fits the properties of the buffer
memAlloc.memoryTypeIndex = getMemoryType(memReqs.memoryTypeBits, memoryPropertyFlags);
// If the buffer has VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT set we also need to enable the appropriate flag during allocation
VkMemoryAllocateFlagsInfoKHR allocFlagsInfo{};
if (usageFlags & VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT) {
allocFlagsInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO_KHR;
allocFlagsInfo.flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR;
memAlloc.pNext = &allocFlagsInfo;
}
VK_CHECK_RESULT(vkAllocateMemory(logicalDevice, &memAlloc, nullptr, &buffer->memory));
buffer->alignment = memReqs.alignment;
buffer->size = size;
buffer->usageFlags = usageFlags;
buffer->memoryPropertyFlags = memoryPropertyFlags;
// If a pointer to the buffer data has been passed, map the buffer and copy over the data
if (data != nullptr)
{
VK_CHECK_RESULT(buffer->map());
memcpy(buffer->mapped, data, size);
if ((memoryPropertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0)
buffer->flush();
buffer->unmap();
}
// Initialize a default descriptor that covers the whole buffer size
buffer->setupDescriptor();
// Attach the memory to the buffer object
return buffer->bind();
}
/**
* Copy buffer data from src to dst using VkCmdCopyBuffer
*
* @param src Pointer to the source buffer to copy from
* @param dst Pointer to the destination buffer to copy to
* @param queue Pointer
* @param copyRegion (Optional) Pointer to a copy region, if NULL, the whole buffer is copied
*
* @note Source and destination pointers must have the appropriate transfer usage flags set (TRANSFER_SRC / TRANSFER_DST)
*/
void VulkanDevice::copyBuffer(vks::Buffer *src, vks::Buffer *dst, VkQueue queue, VkBufferCopy *copyRegion)
{
assert(dst->size >= src->size);
assert(src->buffer);
VkCommandBuffer copyCmd = createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
VkBufferCopy bufferCopy{};
if (copyRegion == nullptr)
{
bufferCopy.size = src->size;
}
else
{
bufferCopy = *copyRegion;
}
vkCmdCopyBuffer(copyCmd, src->buffer, dst->buffer, 1, &bufferCopy);
flushCommandBuffer(copyCmd, queue);
}
/**
* Create a command pool for allocation command buffers from
*
* @param queueFamilyIndex Family index of the queue to create the command pool for
* @param createFlags (Optional) Command pool creation flags (Defaults to VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT)
*
* @note Command buffers allocated from the created pool can only be submitted to a queue with the same family index
*
* @return A handle to the created command buffer
*/
VkCommandPool VulkanDevice::createCommandPool(uint32_t queueFamilyIndex, VkCommandPoolCreateFlags createFlags)
{
VkCommandPoolCreateInfo cmdPoolInfo{
.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
.flags = createFlags,
.queueFamilyIndex = queueFamilyIndex
};
VkCommandPool cmdPool;
VK_CHECK_RESULT(vkCreateCommandPool(logicalDevice, &cmdPoolInfo, nullptr, &cmdPool));
return cmdPool;
}
/**
* Allocate a command buffer from the command pool
*
* @param level Level of the new command buffer (primary or secondary)
* @param pool Command pool from which the command buffer will be allocated
* @param (Optional) begin If true, recording on the new command buffer will be started (vkBeginCommandBuffer) (Defaults to false)
*
* @return A handle to the allocated command buffer
*/
VkCommandBuffer VulkanDevice::createCommandBuffer(VkCommandBufferLevel level, VkCommandPool pool, bool begin)
{
VkCommandBufferAllocateInfo cmdBufAllocateInfo = vks::initializers::commandBufferAllocateInfo(pool, level, 1);
VkCommandBuffer cmdBuffer;
VK_CHECK_RESULT(vkAllocateCommandBuffers(logicalDevice, &cmdBufAllocateInfo, &cmdBuffer));
// If requested, also start recording for the new command buffer
if (begin)
{
VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
VK_CHECK_RESULT(vkBeginCommandBuffer(cmdBuffer, &cmdBufInfo));
}
return cmdBuffer;
}
VkCommandBuffer VulkanDevice::createCommandBuffer(VkCommandBufferLevel level, bool begin)
{
return createCommandBuffer(level, commandPool, begin);
}
/**
* Finish command buffer recording and submit it to a queue
*
* @param commandBuffer Command buffer to flush
* @param queue Queue to submit the command buffer to
* @param pool Command pool on which the command buffer has been created
* @param free (Optional) Free the command buffer once it has been submitted (Defaults to true)
*
* @note The queue that the command buffer is submitted to must be from the same family index as the pool it was allocated from
* @note Uses a fence to ensure command buffer has finished executing
*/
void VulkanDevice::flushCommandBuffer(VkCommandBuffer commandBuffer, VkQueue queue, VkCommandPool pool, bool free)
{
if (commandBuffer == VK_NULL_HANDLE)
{
return;
}
VK_CHECK_RESULT(vkEndCommandBuffer(commandBuffer));
VkSubmitInfo submitInfo{
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &commandBuffer
};
// Create fence to ensure that the command buffer has finished executing
VkFenceCreateInfo fenceInfo = vks::initializers::fenceCreateInfo(VK_FLAGS_NONE);
VkFence fence;
VK_CHECK_RESULT(vkCreateFence(logicalDevice, &fenceInfo, nullptr, &fence));
// Submit to the queue
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, fence));
// Wait for the fence to signal that command buffer has finished executing
VK_CHECK_RESULT(vkWaitForFences(logicalDevice, 1, &fence, VK_TRUE, DEFAULT_FENCE_TIMEOUT));
vkDestroyFence(logicalDevice, fence, nullptr);
if (free)
{
vkFreeCommandBuffers(logicalDevice, pool, 1, &commandBuffer);
}
}
void VulkanDevice::flushCommandBuffer(VkCommandBuffer commandBuffer, VkQueue queue, bool free)
{
return flushCommandBuffer(commandBuffer, queue, commandPool, free);
}
/**
* Check if an extension is supported by the (physical device)
*
* @param extension Name of the extension to check
*
* @return True if the extension is supported (present in the list read at device creation time)
*/
bool VulkanDevice::extensionSupported(std::string extension)
{
return (std::find(supportedExtensions.begin(), supportedExtensions.end(), extension) != supportedExtensions.end());
}
/**
* Select the best-fit depth format for this device from a list of possible depth (and stencil) formats
*
* @param checkSamplingSupport Check if the format can be sampled from (e.g. for shader reads)
*
* @return The depth format that best fits for the current device
*
* @throw Throws an exception if no depth format fits the requirements
*/
VkFormat VulkanDevice::getSupportedDepthFormat(bool checkSamplingSupport)
{
// All depth formats may be optional, so we need to find a suitable depth format to use
std::vector<VkFormat> depthFormats = { VK_FORMAT_D32_SFLOAT_S8_UINT, VK_FORMAT_D32_SFLOAT, VK_FORMAT_D24_UNORM_S8_UINT, VK_FORMAT_D16_UNORM_S8_UINT, VK_FORMAT_D16_UNORM };
for (auto& format : depthFormats)
{
VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProperties);
// Format must support depth stencil attachment for optimal tiling
if (formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT)
{
if (checkSamplingSupport) {
if (!(formatProperties.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT)) {
continue;
}
}
return format;
}
}
throw std::runtime_error("Could not find a matching depth format");
}
};
@@ -0,0 +1,69 @@
/*
* Vulkan device class
*
* Encapsulates a physical Vulkan device and its logical representation
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include "VulkanBuffer.h"
#include "VulkanTools.h"
#include "vulkan/vulkan.h"
#include <algorithm>
#include <assert.h>
#include <exception>
namespace vks
{
struct VulkanDevice
{
/** @brief Physical device representation */
VkPhysicalDevice physicalDevice{ VK_NULL_HANDLE };;
/** @brief Logical device representation (application's view of the device) */
VkDevice logicalDevice{ VK_NULL_HANDLE };
/** @brief Properties of the physical device including limits that the application can check against */
VkPhysicalDeviceProperties properties{};
/** @brief Features of the physical device that an application can use to check if a feature is supported */
VkPhysicalDeviceFeatures features{};
/** @brief Features that have been enabled for use on the physical device */
VkPhysicalDeviceFeatures enabledFeatures{};
/** @brief Memory types and heaps of the physical device */
VkPhysicalDeviceMemoryProperties memoryProperties{};
/** @brief Queue family properties of the physical device */
std::vector<VkQueueFamilyProperties> queueFamilyProperties{};
/** @brief List of extensions supported by the device */
std::vector<std::string> supportedExtensions{};
/** @brief Default command pool for the graphics queue family index */
VkCommandPool commandPool{ VK_NULL_HANDLE };;
/** @brief Contains queue family indices */
struct
{
uint32_t graphics;
uint32_t compute;
uint32_t transfer;
} queueFamilyIndices;
operator VkDevice() const
{
return logicalDevice;
};
explicit VulkanDevice(VkPhysicalDevice physicalDevice);
~VulkanDevice();
uint32_t getMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties, VkBool32 *memTypeFound = nullptr) const;
uint32_t getQueueFamilyIndex(VkQueueFlags queueFlags) const;
VkResult createLogicalDevice(VkPhysicalDeviceFeatures enabledFeatures, std::vector<const char *> enabledExtensions, void *pNextChain, bool useSwapChain = true, VkQueueFlags requestedQueueTypes = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_COMPUTE_BIT);
VkResult createBuffer(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, VkDeviceSize size, VkBuffer *buffer, VkDeviceMemory *memory, void *data = nullptr);
VkResult createBuffer(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, vks::Buffer *buffer, VkDeviceSize size, void *data = nullptr);
void copyBuffer(vks::Buffer *src, vks::Buffer *dst, VkQueue queue, VkBufferCopy *copyRegion = nullptr);
VkCommandPool createCommandPool(uint32_t queueFamilyIndex, VkCommandPoolCreateFlags createFlags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
VkCommandBuffer createCommandBuffer(VkCommandBufferLevel level, VkCommandPool pool, bool begin = false);
VkCommandBuffer createCommandBuffer(VkCommandBufferLevel level, bool begin = false);
void flushCommandBuffer(VkCommandBuffer commandBuffer, VkQueue queue, VkCommandPool pool, bool free = true);
void flushCommandBuffer(VkCommandBuffer commandBuffer, VkQueue queue, bool free = true);
bool extensionSupported(std::string extension);
VkFormat getSupportedDepthFormat(bool checkSamplingSupport);
};
} // namespace vks
@@ -0,0 +1,378 @@
/*
* Vulkan framebuffer class
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <algorithm>
#include <iterator>
#include <vector>
#include "vulkan/vulkan.h"
#include "VulkanDevice.h"
#include "VulkanTools.h"
namespace vks
{
/**
* @brief Encapsulates a single frame buffer attachment
*/
struct FramebufferAttachment
{
VkImage image;
VkDeviceMemory memory;
VkImageView view;
VkFormat format;
VkImageSubresourceRange subresourceRange;
VkAttachmentDescription description;
/**
* @brief Returns true if the attachment has a depth component
*/
bool hasDepth()
{
std::vector<VkFormat> formats =
{
VK_FORMAT_D16_UNORM,
VK_FORMAT_X8_D24_UNORM_PACK32,
VK_FORMAT_D32_SFLOAT,
VK_FORMAT_D16_UNORM_S8_UINT,
VK_FORMAT_D24_UNORM_S8_UINT,
VK_FORMAT_D32_SFLOAT_S8_UINT,
};
return std::find(formats.begin(), formats.end(), format) != std::end(formats);
}
/**
* @brief Returns true if the attachment has a stencil component
*/
bool hasStencil()
{
std::vector<VkFormat> formats =
{
VK_FORMAT_S8_UINT,
VK_FORMAT_D16_UNORM_S8_UINT,
VK_FORMAT_D24_UNORM_S8_UINT,
VK_FORMAT_D32_SFLOAT_S8_UINT,
};
return std::find(formats.begin(), formats.end(), format) != std::end(formats);
}
/**
* @brief Returns true if the attachment is a depth and/or stencil attachment
*/
bool isDepthStencil()
{
return(hasDepth() || hasStencil());
}
};
/**
* @brief Describes the attributes of an attachment to be created
*/
struct AttachmentCreateInfo
{
uint32_t width, height;
uint32_t layerCount;
VkFormat format;
VkImageUsageFlags usage;
VkSampleCountFlagBits imageSampleCount = VK_SAMPLE_COUNT_1_BIT;
};
/**
* @brief Encapsulates a complete Vulkan framebuffer with an arbitrary number and combination of attachments
*/
struct Framebuffer
{
private:
vks::VulkanDevice *vulkanDevice;
public:
uint32_t width, height;
VkFramebuffer framebuffer;
VkRenderPass renderPass;
VkSampler sampler;
std::vector<vks::FramebufferAttachment> attachments;
/**
* Default constructor
*
* @param vulkanDevice Pointer to a valid VulkanDevice
*/
Framebuffer(vks::VulkanDevice *vulkanDevice)
{
assert(vulkanDevice);
this->vulkanDevice = vulkanDevice;
}
/**
* Destroy and free Vulkan resources used for the framebuffer and all of its attachments
*/
~Framebuffer()
{
assert(vulkanDevice);
for (auto attachment : attachments)
{
vkDestroyImage(vulkanDevice->logicalDevice, attachment.image, nullptr);
vkDestroyImageView(vulkanDevice->logicalDevice, attachment.view, nullptr);
vkFreeMemory(vulkanDevice->logicalDevice, attachment.memory, nullptr);
}
vkDestroySampler(vulkanDevice->logicalDevice, sampler, nullptr);
vkDestroyRenderPass(vulkanDevice->logicalDevice, renderPass, nullptr);
vkDestroyFramebuffer(vulkanDevice->logicalDevice, framebuffer, nullptr);
}
/**
* Add a new attachment described by createinfo to the framebuffer's attachment list
*
* @param createinfo Structure that specifies the framebuffer to be constructed
*
* @return Index of the new attachment
*/
uint32_t addAttachment(vks::AttachmentCreateInfo createinfo)
{
vks::FramebufferAttachment attachment;
attachment.format = createinfo.format;
VkImageAspectFlags aspectMask = VK_FLAGS_NONE;
// Select aspect mask and layout depending on usage
// Color attachment
if (createinfo.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT)
{
aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
}
// Depth (and/or stencil) attachment
if (createinfo.usage & VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT)
{
if (attachment.hasDepth())
{
aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
}
if (attachment.hasStencil())
{
aspectMask = aspectMask | VK_IMAGE_ASPECT_STENCIL_BIT;
}
}
assert(aspectMask > 0);
VkImageCreateInfo image = vks::initializers::imageCreateInfo();
image.imageType = VK_IMAGE_TYPE_2D;
image.format = createinfo.format;
image.extent.width = createinfo.width;
image.extent.height = createinfo.height;
image.extent.depth = 1;
image.mipLevels = 1;
image.arrayLayers = createinfo.layerCount;
image.samples = createinfo.imageSampleCount;
image.tiling = VK_IMAGE_TILING_OPTIMAL;
image.usage = createinfo.usage;
VkMemoryAllocateInfo memAlloc = vks::initializers::memoryAllocateInfo();
VkMemoryRequirements memReqs;
// Create image for this attachment
VK_CHECK_RESULT(vkCreateImage(vulkanDevice->logicalDevice, &image, nullptr, &attachment.image));
vkGetImageMemoryRequirements(vulkanDevice->logicalDevice, attachment.image, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = vulkanDevice->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(vulkanDevice->logicalDevice, &memAlloc, nullptr, &attachment.memory));
VK_CHECK_RESULT(vkBindImageMemory(vulkanDevice->logicalDevice, attachment.image, attachment.memory, 0));
attachment.subresourceRange = {};
attachment.subresourceRange.aspectMask = aspectMask;
attachment.subresourceRange.levelCount = 1;
attachment.subresourceRange.layerCount = createinfo.layerCount;
VkImageViewCreateInfo imageView = vks::initializers::imageViewCreateInfo();
imageView.viewType = (createinfo.layerCount == 1) ? VK_IMAGE_VIEW_TYPE_2D : VK_IMAGE_VIEW_TYPE_2D_ARRAY;
imageView.format = createinfo.format;
imageView.subresourceRange = attachment.subresourceRange;
imageView.subresourceRange.aspectMask = (attachment.hasDepth()) ? VK_IMAGE_ASPECT_DEPTH_BIT : aspectMask;
imageView.image = attachment.image;
VK_CHECK_RESULT(vkCreateImageView(vulkanDevice->logicalDevice, &imageView, nullptr, &attachment.view));
// Fill attachment description
attachment.description = {};
attachment.description.samples = createinfo.imageSampleCount;
attachment.description.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
attachment.description.storeOp = (createinfo.usage & VK_IMAGE_USAGE_SAMPLED_BIT) ? VK_ATTACHMENT_STORE_OP_STORE : VK_ATTACHMENT_STORE_OP_DONT_CARE;
attachment.description.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
attachment.description.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
attachment.description.format = createinfo.format;
attachment.description.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
// Final layout
// If not, final layout depends on attachment type
if (attachment.hasDepth() || attachment.hasStencil())
{
attachment.description.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL;
}
else
{
attachment.description.finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
}
attachments.push_back(attachment);
return static_cast<uint32_t>(attachments.size() - 1);
}
/**
* Creates a default sampler for sampling from any of the framebuffer attachments
* Applications are free to create their own samplers for different use cases
*
* @param magFilter Magnification filter for lookups
* @param minFilter Minification filter for lookups
* @param adressMode Addressing mode for the U,V and W coordinates
*
* @return VkResult for the sampler creation
*/
VkResult createSampler(VkFilter magFilter, VkFilter minFilter, VkSamplerAddressMode adressMode)
{
VkSamplerCreateInfo samplerInfo = vks::initializers::samplerCreateInfo();
samplerInfo.magFilter = magFilter;
samplerInfo.minFilter = minFilter;
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.addressModeU = adressMode;
samplerInfo.addressModeV = adressMode;
samplerInfo.addressModeW = adressMode;
samplerInfo.mipLodBias = 0.0f;
samplerInfo.maxAnisotropy = 1.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 1.0f;
samplerInfo.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE;
return vkCreateSampler(vulkanDevice->logicalDevice, &samplerInfo, nullptr, &sampler);
}
/**
* Creates a default render pass setup with one sub pass
*
* @return VK_SUCCESS if all resources have been created successfully
*/
VkResult createRenderPass()
{
std::vector<VkAttachmentDescription> attachmentDescriptions;
for (auto& attachment : attachments)
{
attachmentDescriptions.push_back(attachment.description);
};
// Collect attachment references
std::vector<VkAttachmentReference> colorReferences;
VkAttachmentReference depthReference = {};
bool hasDepth = false;
bool hasColor = false;
uint32_t attachmentIndex = 0;
for (auto& attachment : attachments)
{
if (attachment.isDepthStencil())
{
// Only one depth attachment allowed
assert(!hasDepth);
depthReference.attachment = attachmentIndex;
depthReference.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
hasDepth = true;
}
else
{
colorReferences.push_back({ attachmentIndex, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL });
hasColor = true;
}
attachmentIndex++;
};
// Default render pass setup uses only one subpass
VkSubpassDescription subpass = {};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
if (hasColor)
{
subpass.pColorAttachments = colorReferences.data();
subpass.colorAttachmentCount = static_cast<uint32_t>(colorReferences.size());
}
if (hasDepth)
{
subpass.pDepthStencilAttachment = &depthReference;
}
// Use subpass dependencies for attachment layout transitions
std::array<VkSubpassDependency, 4> dependencies{};
dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[0].dstSubpass = 0;
dependencies[0].srcStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].dstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependencies[0].dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT;
dependencies[1].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[1].dstSubpass = 0;
dependencies[1].srcStageMask = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT;
dependencies[1].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[1].srcAccessMask = VK_ACCESS_MEMORY_READ_BIT;
dependencies[1].dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependencies[2].srcSubpass = 0;
dependencies[2].dstSubpass = VK_SUBPASS_EXTERNAL;
dependencies[2].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[2].dstStageMask = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT;
dependencies[2].srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependencies[2].dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
dependencies[3].srcSubpass = 0;
dependencies[3].dstSubpass = VK_SUBPASS_EXTERNAL;
dependencies[3].srcStageMask = VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[3].dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
dependencies[3].srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependencies[3].dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
// Create render pass
VkRenderPassCreateInfo renderPassInfo = {};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.pAttachments = attachmentDescriptions.data();
renderPassInfo.attachmentCount = static_cast<uint32_t>(attachmentDescriptions.size());
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
renderPassInfo.dependencyCount = 4;
renderPassInfo.pDependencies = dependencies.data();
VK_CHECK_RESULT(vkCreateRenderPass(vulkanDevice->logicalDevice, &renderPassInfo, nullptr, &renderPass));
std::vector<VkImageView> attachmentViews;
for (auto& attachment : attachments)
{
attachmentViews.push_back(attachment.view);
}
// Find. max number of layers across attachments
uint32_t maxLayers = 0;
for (auto& attachment : attachments)
{
if (attachment.subresourceRange.layerCount > maxLayers)
{
maxLayers = attachment.subresourceRange.layerCount;
}
}
VkFramebufferCreateInfo framebufferInfo = {};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.pAttachments = attachmentViews.data();
framebufferInfo.attachmentCount = static_cast<uint32_t>(attachmentViews.size());
framebufferInfo.width = width;
framebufferInfo.height = height;
framebufferInfo.layers = maxLayers;
VK_CHECK_RESULT(vkCreateFramebuffer(vulkanDevice->logicalDevice, &framebufferInfo, nullptr, &framebuffer));
return VK_SUCCESS;
}
};
}
@@ -0,0 +1,650 @@
/*
* Initializers for Vulkan structures and objects used by the examples
* Saves lot of VK_STRUCTURE_TYPE assignments
* Some initializers are parameterized for convenience
*
* Copyright (C) 2016 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <vector>
#include "vulkan/vulkan.h"
namespace vks
{
namespace initializers
{
inline VkMemoryAllocateInfo memoryAllocateInfo()
{
VkMemoryAllocateInfo memAllocInfo {};
memAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
return memAllocInfo;
}
inline VkMappedMemoryRange mappedMemoryRange()
{
VkMappedMemoryRange mappedMemoryRange {};
mappedMemoryRange.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE;
return mappedMemoryRange;
}
inline VkCommandBufferAllocateInfo commandBufferAllocateInfo(
VkCommandPool commandPool,
VkCommandBufferLevel level,
uint32_t bufferCount)
{
VkCommandBufferAllocateInfo commandBufferAllocateInfo {};
commandBufferAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
commandBufferAllocateInfo.commandPool = commandPool;
commandBufferAllocateInfo.level = level;
commandBufferAllocateInfo.commandBufferCount = bufferCount;
return commandBufferAllocateInfo;
}
inline VkCommandPoolCreateInfo commandPoolCreateInfo()
{
VkCommandPoolCreateInfo cmdPoolCreateInfo {};
cmdPoolCreateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
return cmdPoolCreateInfo;
}
inline VkCommandBufferBeginInfo commandBufferBeginInfo()
{
VkCommandBufferBeginInfo cmdBufferBeginInfo {};
cmdBufferBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
return cmdBufferBeginInfo;
}
inline VkCommandBufferInheritanceInfo commandBufferInheritanceInfo()
{
VkCommandBufferInheritanceInfo cmdBufferInheritanceInfo {};
cmdBufferInheritanceInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO;
return cmdBufferInheritanceInfo;
}
inline VkRenderPassBeginInfo renderPassBeginInfo()
{
VkRenderPassBeginInfo renderPassBeginInfo {};
renderPassBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
return renderPassBeginInfo;
}
inline VkRenderPassCreateInfo renderPassCreateInfo()
{
VkRenderPassCreateInfo renderPassCreateInfo {};
renderPassCreateInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
return renderPassCreateInfo;
}
/** @brief Initialize an image memory barrier with no image transfer ownership */
inline VkImageMemoryBarrier imageMemoryBarrier()
{
VkImageMemoryBarrier imageMemoryBarrier {};
imageMemoryBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
imageMemoryBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
imageMemoryBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
return imageMemoryBarrier;
}
/** @brief Initialize a buffer memory barrier with no image transfer ownership */
inline VkBufferMemoryBarrier bufferMemoryBarrier()
{
VkBufferMemoryBarrier bufferMemoryBarrier {};
bufferMemoryBarrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
bufferMemoryBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
bufferMemoryBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
return bufferMemoryBarrier;
}
inline VkMemoryBarrier memoryBarrier()
{
VkMemoryBarrier memoryBarrier {};
memoryBarrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
return memoryBarrier;
}
inline VkImageCreateInfo imageCreateInfo()
{
VkImageCreateInfo imageCreateInfo {};
imageCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
return imageCreateInfo;
}
inline VkSamplerCreateInfo samplerCreateInfo()
{
VkSamplerCreateInfo samplerCreateInfo {};
samplerCreateInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerCreateInfo.maxAnisotropy = 1.0f;
return samplerCreateInfo;
}
inline VkImageViewCreateInfo imageViewCreateInfo()
{
VkImageViewCreateInfo imageViewCreateInfo {};
imageViewCreateInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
return imageViewCreateInfo;
}
inline VkFramebufferCreateInfo framebufferCreateInfo()
{
VkFramebufferCreateInfo framebufferCreateInfo {};
framebufferCreateInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
return framebufferCreateInfo;
}
inline VkSemaphoreCreateInfo semaphoreCreateInfo()
{
VkSemaphoreCreateInfo semaphoreCreateInfo {};
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
return semaphoreCreateInfo;
}
inline VkFenceCreateInfo fenceCreateInfo(VkFenceCreateFlags flags = 0)
{
VkFenceCreateInfo fenceCreateInfo {};
fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceCreateInfo.flags = flags;
return fenceCreateInfo;
}
inline VkSubmitInfo submitInfo()
{
return { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO };
}
inline VkViewport viewport(
float width,
float height,
float minDepth,
float maxDepth)
{
VkViewport viewport {};
viewport.width = width;
viewport.height = height;
viewport.minDepth = minDepth;
viewport.maxDepth = maxDepth;
return viewport;
}
inline VkRect2D rect2D(
int32_t width,
int32_t height,
int32_t offsetX,
int32_t offsetY)
{
VkRect2D rect2D {};
rect2D.extent.width = width;
rect2D.extent.height = height;
rect2D.offset.x = offsetX;
rect2D.offset.y = offsetY;
return rect2D;
}
inline VkBufferCreateInfo bufferCreateInfo()
{
VkBufferCreateInfo bufCreateInfo {};
bufCreateInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
return bufCreateInfo;
}
inline VkBufferCreateInfo bufferCreateInfo(
VkBufferUsageFlags usage,
VkDeviceSize size)
{
VkBufferCreateInfo bufCreateInfo {};
bufCreateInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufCreateInfo.usage = usage;
bufCreateInfo.size = size;
return bufCreateInfo;
}
inline VkDescriptorPoolCreateInfo descriptorPoolCreateInfo(
uint32_t poolSizeCount,
VkDescriptorPoolSize* pPoolSizes,
uint32_t maxSets)
{
VkDescriptorPoolCreateInfo descriptorPoolInfo {};
descriptorPoolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptorPoolInfo.poolSizeCount = poolSizeCount;
descriptorPoolInfo.pPoolSizes = pPoolSizes;
descriptorPoolInfo.maxSets = maxSets;
return descriptorPoolInfo;
}
inline VkDescriptorPoolCreateInfo descriptorPoolCreateInfo(
const std::vector<VkDescriptorPoolSize>& poolSizes,
uint32_t maxSets)
{
VkDescriptorPoolCreateInfo descriptorPoolInfo{};
descriptorPoolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptorPoolInfo.poolSizeCount = static_cast<uint32_t>(poolSizes.size());
descriptorPoolInfo.pPoolSizes = poolSizes.data();
descriptorPoolInfo.maxSets = maxSets;
return descriptorPoolInfo;
}
inline VkDescriptorPoolSize descriptorPoolSize(
VkDescriptorType type,
uint32_t descriptorCount)
{
VkDescriptorPoolSize descriptorPoolSize {};
descriptorPoolSize.type = type;
descriptorPoolSize.descriptorCount = descriptorCount;
return descriptorPoolSize;
}
inline VkDescriptorSetLayoutBinding descriptorSetLayoutBinding(
VkDescriptorType type,
VkShaderStageFlags stageFlags,
uint32_t binding,
uint32_t descriptorCount = 1)
{
VkDescriptorSetLayoutBinding setLayoutBinding {};
setLayoutBinding.descriptorType = type;
setLayoutBinding.stageFlags = stageFlags;
setLayoutBinding.binding = binding;
setLayoutBinding.descriptorCount = descriptorCount;
return setLayoutBinding;
}
inline VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo(
const VkDescriptorSetLayoutBinding* pBindings,
uint32_t bindingCount)
{
VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo {};
descriptorSetLayoutCreateInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
descriptorSetLayoutCreateInfo.pBindings = pBindings;
descriptorSetLayoutCreateInfo.bindingCount = bindingCount;
return descriptorSetLayoutCreateInfo;
}
inline VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo(
const std::vector<VkDescriptorSetLayoutBinding>& bindings)
{
VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo{};
descriptorSetLayoutCreateInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
descriptorSetLayoutCreateInfo.pBindings = bindings.data();
descriptorSetLayoutCreateInfo.bindingCount = static_cast<uint32_t>(bindings.size());
return descriptorSetLayoutCreateInfo;
}
inline VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo(
const VkDescriptorSetLayout* pSetLayouts,
uint32_t setLayoutCount = 1)
{
VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo {};
pipelineLayoutCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutCreateInfo.setLayoutCount = setLayoutCount;
pipelineLayoutCreateInfo.pSetLayouts = pSetLayouts;
return pipelineLayoutCreateInfo;
}
inline VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo(
uint32_t setLayoutCount = 1)
{
VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo{};
pipelineLayoutCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutCreateInfo.setLayoutCount = setLayoutCount;
return pipelineLayoutCreateInfo;
}
inline VkDescriptorSetAllocateInfo descriptorSetAllocateInfo(
VkDescriptorPool descriptorPool,
const VkDescriptorSetLayout* pSetLayouts,
uint32_t descriptorSetCount)
{
VkDescriptorSetAllocateInfo descriptorSetAllocateInfo {};
descriptorSetAllocateInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
descriptorSetAllocateInfo.descriptorPool = descriptorPool;
descriptorSetAllocateInfo.pSetLayouts = pSetLayouts;
descriptorSetAllocateInfo.descriptorSetCount = descriptorSetCount;
return descriptorSetAllocateInfo;
}
inline VkDescriptorImageInfo descriptorImageInfo(VkSampler sampler, VkImageView imageView, VkImageLayout imageLayout)
{
VkDescriptorImageInfo descriptorImageInfo {};
descriptorImageInfo.sampler = sampler;
descriptorImageInfo.imageView = imageView;
descriptorImageInfo.imageLayout = imageLayout;
return descriptorImageInfo;
}
inline VkWriteDescriptorSet writeDescriptorSet(
VkDescriptorSet dstSet,
VkDescriptorType type,
uint32_t binding,
VkDescriptorBufferInfo* bufferInfo,
uint32_t descriptorCount = 1)
{
VkWriteDescriptorSet writeDescriptorSet {};
writeDescriptorSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDescriptorSet.dstSet = dstSet;
writeDescriptorSet.descriptorType = type;
writeDescriptorSet.dstBinding = binding;
writeDescriptorSet.pBufferInfo = bufferInfo;
writeDescriptorSet.descriptorCount = descriptorCount;
return writeDescriptorSet;
}
inline VkWriteDescriptorSet writeDescriptorSet(
VkDescriptorSet dstSet,
VkDescriptorType type,
uint32_t binding,
VkDescriptorImageInfo *imageInfo,
uint32_t descriptorCount = 1)
{
VkWriteDescriptorSet writeDescriptorSet {};
writeDescriptorSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDescriptorSet.dstSet = dstSet;
writeDescriptorSet.descriptorType = type;
writeDescriptorSet.dstBinding = binding;
writeDescriptorSet.pImageInfo = imageInfo;
writeDescriptorSet.descriptorCount = descriptorCount;
return writeDescriptorSet;
}
inline VkVertexInputBindingDescription vertexInputBindingDescription(
uint32_t binding,
uint32_t stride,
VkVertexInputRate inputRate)
{
VkVertexInputBindingDescription vInputBindDescription {};
vInputBindDescription.binding = binding;
vInputBindDescription.stride = stride;
vInputBindDescription.inputRate = inputRate;
return vInputBindDescription;
}
inline VkVertexInputAttributeDescription vertexInputAttributeDescription(
uint32_t binding,
uint32_t location,
VkFormat format,
uint32_t offset)
{
VkVertexInputAttributeDescription vInputAttribDescription {};
vInputAttribDescription.location = location;
vInputAttribDescription.binding = binding;
vInputAttribDescription.format = format;
vInputAttribDescription.offset = offset;
return vInputAttribDescription;
}
inline VkPipelineVertexInputStateCreateInfo pipelineVertexInputStateCreateInfo()
{
VkPipelineVertexInputStateCreateInfo pipelineVertexInputStateCreateInfo {};
pipelineVertexInputStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
return pipelineVertexInputStateCreateInfo;
}
inline VkPipelineVertexInputStateCreateInfo pipelineVertexInputStateCreateInfo(
const std::vector<VkVertexInputBindingDescription> &vertexBindingDescriptions,
const std::vector<VkVertexInputAttributeDescription> &vertexAttributeDescriptions
)
{
VkPipelineVertexInputStateCreateInfo pipelineVertexInputStateCreateInfo{};
pipelineVertexInputStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
pipelineVertexInputStateCreateInfo.vertexBindingDescriptionCount = static_cast<uint32_t>(vertexBindingDescriptions.size());
pipelineVertexInputStateCreateInfo.pVertexBindingDescriptions = vertexBindingDescriptions.data();
pipelineVertexInputStateCreateInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(vertexAttributeDescriptions.size());
pipelineVertexInputStateCreateInfo.pVertexAttributeDescriptions = vertexAttributeDescriptions.data();
return pipelineVertexInputStateCreateInfo;
}
inline VkPipelineInputAssemblyStateCreateInfo pipelineInputAssemblyStateCreateInfo(
VkPrimitiveTopology topology,
VkPipelineInputAssemblyStateCreateFlags flags,
VkBool32 primitiveRestartEnable)
{
VkPipelineInputAssemblyStateCreateInfo pipelineInputAssemblyStateCreateInfo {};
pipelineInputAssemblyStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
pipelineInputAssemblyStateCreateInfo.topology = topology;
pipelineInputAssemblyStateCreateInfo.flags = flags;
pipelineInputAssemblyStateCreateInfo.primitiveRestartEnable = primitiveRestartEnable;
return pipelineInputAssemblyStateCreateInfo;
}
inline VkPipelineRasterizationStateCreateInfo pipelineRasterizationStateCreateInfo(
VkPolygonMode polygonMode,
VkCullModeFlags cullMode,
VkFrontFace frontFace,
VkPipelineRasterizationStateCreateFlags flags = 0)
{
VkPipelineRasterizationStateCreateInfo pipelineRasterizationStateCreateInfo {};
pipelineRasterizationStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
pipelineRasterizationStateCreateInfo.polygonMode = polygonMode;
pipelineRasterizationStateCreateInfo.cullMode = cullMode;
pipelineRasterizationStateCreateInfo.frontFace = frontFace;
pipelineRasterizationStateCreateInfo.flags = flags;
pipelineRasterizationStateCreateInfo.depthClampEnable = VK_FALSE;
pipelineRasterizationStateCreateInfo.lineWidth = 1.0f;
return pipelineRasterizationStateCreateInfo;
}
inline VkPipelineColorBlendAttachmentState pipelineColorBlendAttachmentState(
VkColorComponentFlags colorWriteMask,
VkBool32 blendEnable)
{
VkPipelineColorBlendAttachmentState pipelineColorBlendAttachmentState {};
pipelineColorBlendAttachmentState.colorWriteMask = colorWriteMask;
pipelineColorBlendAttachmentState.blendEnable = blendEnable;
return pipelineColorBlendAttachmentState;
}
inline VkPipelineColorBlendStateCreateInfo pipelineColorBlendStateCreateInfo(
uint32_t attachmentCount,
const VkPipelineColorBlendAttachmentState * pAttachments)
{
VkPipelineColorBlendStateCreateInfo pipelineColorBlendStateCreateInfo {};
pipelineColorBlendStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
pipelineColorBlendStateCreateInfo.attachmentCount = attachmentCount;
pipelineColorBlendStateCreateInfo.pAttachments = pAttachments;
return pipelineColorBlendStateCreateInfo;
}
inline VkPipelineDepthStencilStateCreateInfo pipelineDepthStencilStateCreateInfo(
VkBool32 depthTestEnable,
VkBool32 depthWriteEnable,
VkCompareOp depthCompareOp)
{
VkPipelineDepthStencilStateCreateInfo pipelineDepthStencilStateCreateInfo {};
pipelineDepthStencilStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
pipelineDepthStencilStateCreateInfo.depthTestEnable = depthTestEnable;
pipelineDepthStencilStateCreateInfo.depthWriteEnable = depthWriteEnable;
pipelineDepthStencilStateCreateInfo.depthCompareOp = depthCompareOp;
pipelineDepthStencilStateCreateInfo.back.compareOp = VK_COMPARE_OP_ALWAYS;
return pipelineDepthStencilStateCreateInfo;
}
inline VkPipelineViewportStateCreateInfo pipelineViewportStateCreateInfo(
uint32_t viewportCount,
uint32_t scissorCount,
VkPipelineViewportStateCreateFlags flags = 0)
{
VkPipelineViewportStateCreateInfo pipelineViewportStateCreateInfo {};
pipelineViewportStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
pipelineViewportStateCreateInfo.viewportCount = viewportCount;
pipelineViewportStateCreateInfo.scissorCount = scissorCount;
pipelineViewportStateCreateInfo.flags = flags;
return pipelineViewportStateCreateInfo;
}
inline VkPipelineMultisampleStateCreateInfo pipelineMultisampleStateCreateInfo(
VkSampleCountFlagBits rasterizationSamples,
VkPipelineMultisampleStateCreateFlags flags = 0)
{
VkPipelineMultisampleStateCreateInfo pipelineMultisampleStateCreateInfo {};
pipelineMultisampleStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
pipelineMultisampleStateCreateInfo.rasterizationSamples = rasterizationSamples;
pipelineMultisampleStateCreateInfo.flags = flags;
return pipelineMultisampleStateCreateInfo;
}
inline VkPipelineDynamicStateCreateInfo pipelineDynamicStateCreateInfo(
const VkDynamicState * pDynamicStates,
uint32_t dynamicStateCount,
VkPipelineDynamicStateCreateFlags flags = 0)
{
VkPipelineDynamicStateCreateInfo pipelineDynamicStateCreateInfo {};
pipelineDynamicStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
pipelineDynamicStateCreateInfo.pDynamicStates = pDynamicStates;
pipelineDynamicStateCreateInfo.dynamicStateCount = dynamicStateCount;
pipelineDynamicStateCreateInfo.flags = flags;
return pipelineDynamicStateCreateInfo;
}
inline VkPipelineDynamicStateCreateInfo pipelineDynamicStateCreateInfo(
const std::vector<VkDynamicState>& pDynamicStates,
VkPipelineDynamicStateCreateFlags flags = 0)
{
VkPipelineDynamicStateCreateInfo pipelineDynamicStateCreateInfo{};
pipelineDynamicStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
pipelineDynamicStateCreateInfo.pDynamicStates = pDynamicStates.data();
pipelineDynamicStateCreateInfo.dynamicStateCount = static_cast<uint32_t>(pDynamicStates.size());
pipelineDynamicStateCreateInfo.flags = flags;
return pipelineDynamicStateCreateInfo;
}
inline VkPipelineTessellationStateCreateInfo pipelineTessellationStateCreateInfo(uint32_t patchControlPoints)
{
VkPipelineTessellationStateCreateInfo pipelineTessellationStateCreateInfo {};
pipelineTessellationStateCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_TESSELLATION_STATE_CREATE_INFO;
pipelineTessellationStateCreateInfo.patchControlPoints = patchControlPoints;
return pipelineTessellationStateCreateInfo;
}
inline VkGraphicsPipelineCreateInfo pipelineCreateInfo(
VkPipelineLayout layout,
VkRenderPass renderPass,
VkPipelineCreateFlags flags = 0)
{
VkGraphicsPipelineCreateInfo pipelineCreateInfo {};
pipelineCreateInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineCreateInfo.layout = layout;
pipelineCreateInfo.renderPass = renderPass;
pipelineCreateInfo.flags = flags;
pipelineCreateInfo.basePipelineIndex = -1;
pipelineCreateInfo.basePipelineHandle = VK_NULL_HANDLE;
return pipelineCreateInfo;
}
inline VkGraphicsPipelineCreateInfo pipelineCreateInfo()
{
VkGraphicsPipelineCreateInfo pipelineCreateInfo{};
pipelineCreateInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineCreateInfo.basePipelineIndex = -1;
pipelineCreateInfo.basePipelineHandle = VK_NULL_HANDLE;
return pipelineCreateInfo;
}
inline VkComputePipelineCreateInfo computePipelineCreateInfo(
VkPipelineLayout layout,
VkPipelineCreateFlags flags = 0)
{
VkComputePipelineCreateInfo computePipelineCreateInfo {};
computePipelineCreateInfo.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
computePipelineCreateInfo.layout = layout;
computePipelineCreateInfo.flags = flags;
return computePipelineCreateInfo;
}
inline VkPushConstantRange pushConstantRange(
VkShaderStageFlags stageFlags,
uint32_t size,
uint32_t offset)
{
VkPushConstantRange pushConstantRange {};
pushConstantRange.stageFlags = stageFlags;
pushConstantRange.offset = offset;
pushConstantRange.size = size;
return pushConstantRange;
}
inline VkBindSparseInfo bindSparseInfo()
{
VkBindSparseInfo bindSparseInfo{};
bindSparseInfo.sType = VK_STRUCTURE_TYPE_BIND_SPARSE_INFO;
return bindSparseInfo;
}
/** @brief Initialize a map entry for a shader specialization constant */
inline VkSpecializationMapEntry specializationMapEntry(uint32_t constantID, uint32_t offset, size_t size)
{
VkSpecializationMapEntry specializationMapEntry{};
specializationMapEntry.constantID = constantID;
specializationMapEntry.offset = offset;
specializationMapEntry.size = size;
return specializationMapEntry;
}
/** @brief Initialize a specialization constant info structure to pass to a shader stage */
inline VkSpecializationInfo specializationInfo(uint32_t mapEntryCount, const VkSpecializationMapEntry* mapEntries, size_t dataSize, const void* data)
{
VkSpecializationInfo specializationInfo{};
specializationInfo.mapEntryCount = mapEntryCount;
specializationInfo.pMapEntries = mapEntries;
specializationInfo.dataSize = dataSize;
specializationInfo.pData = data;
return specializationInfo;
}
/** @brief Initialize a specialization constant info structure to pass to a shader stage */
inline VkSpecializationInfo specializationInfo(const std::vector<VkSpecializationMapEntry> &mapEntries, size_t dataSize, const void* data)
{
VkSpecializationInfo specializationInfo{};
specializationInfo.mapEntryCount = static_cast<uint32_t>(mapEntries.size());
specializationInfo.pMapEntries = mapEntries.data();
specializationInfo.dataSize = dataSize;
specializationInfo.pData = data;
return specializationInfo;
}
// Ray tracing related
inline VkAccelerationStructureGeometryKHR accelerationStructureGeometryKHR()
{
VkAccelerationStructureGeometryKHR accelerationStructureGeometryKHR{};
accelerationStructureGeometryKHR.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR;
return accelerationStructureGeometryKHR;
}
inline VkAccelerationStructureBuildGeometryInfoKHR accelerationStructureBuildGeometryInfoKHR()
{
VkAccelerationStructureBuildGeometryInfoKHR accelerationStructureBuildGeometryInfoKHR{};
accelerationStructureBuildGeometryInfoKHR.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR;
return accelerationStructureBuildGeometryInfoKHR;
}
inline VkAccelerationStructureBuildSizesInfoKHR accelerationStructureBuildSizesInfoKHR()
{
VkAccelerationStructureBuildSizesInfoKHR accelerationStructureBuildSizesInfoKHR{};
accelerationStructureBuildSizesInfoKHR.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR;
return accelerationStructureBuildSizesInfoKHR;
}
inline VkRayTracingShaderGroupCreateInfoKHR rayTracingShaderGroupCreateInfoKHR()
{
VkRayTracingShaderGroupCreateInfoKHR rayTracingShaderGroupCreateInfoKHR{};
rayTracingShaderGroupCreateInfoKHR.sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR;
return rayTracingShaderGroupCreateInfoKHR;
}
inline VkRayTracingPipelineCreateInfoKHR rayTracingPipelineCreateInfoKHR()
{
VkRayTracingPipelineCreateInfoKHR rayTracingPipelineCreateInfoKHR{};
rayTracingPipelineCreateInfoKHR.sType = VK_STRUCTURE_TYPE_RAY_TRACING_PIPELINE_CREATE_INFO_KHR;
return rayTracingPipelineCreateInfoKHR;
}
inline VkWriteDescriptorSetAccelerationStructureKHR writeDescriptorSetAccelerationStructureKHR()
{
VkWriteDescriptorSetAccelerationStructureKHR writeDescriptorSetAccelerationStructureKHR{};
writeDescriptorSetAccelerationStructureKHR.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET_ACCELERATION_STRUCTURE_KHR;
return writeDescriptorSetAccelerationStructureKHR;
}
}
}
@@ -0,0 +1,367 @@
/*
* Extended sample base class for ray tracing based samples
*
* Copyright (C) 2020-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanRaytracingSample.h"
void VulkanRaytracingSample::setupRenderPass()
{
// Update the default render pass with different color attachment load ops to keep attachment contents
// With this change, we can e.g. draw an UI on top of the ray traced scene
vkDestroyRenderPass(device, renderPass, nullptr);
VkAttachmentLoadOp colorLoadOp{ VK_ATTACHMENT_LOAD_OP_LOAD };
VkImageLayout colorInitialLayout{ VK_IMAGE_LAYOUT_PRESENT_SRC_KHR };
if (rayQueryOnly) {
colorLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorInitialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
}
std::array<VkAttachmentDescription, 2> attachments{
VkAttachmentDescription{
.format = swapChain.colorFormat,
.samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = colorLoadOp,
.storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = colorInitialLayout,
.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
},
VkAttachmentDescription{
.format = depthFormat,
.samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
}
};
VkAttachmentReference colorReference{ .attachment = 0, .layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL };
VkAttachmentReference depthReference{ .attachment = 1,.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL };
VkSubpassDescription subpassDescription{
.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS,
.colorAttachmentCount = 1,
.pColorAttachments = &colorReference,
.pDepthStencilAttachment = &depthReference,
};
std::array<VkSubpassDependency, 2> dependencies{
VkSubpassDependency{
.srcSubpass = VK_SUBPASS_EXTERNAL,
.dstSubpass = 0,
.srcStageMask = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT,
.srcAccessMask = VK_ACCESS_NONE_KHR,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
},
VkSubpassDependency{
.srcSubpass = 0,
.dstSubpass = VK_SUBPASS_EXTERNAL,
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT,
.dstStageMask = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
}
};
VkRenderPassCreateInfo renderPassInfo{
.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO,
.attachmentCount = static_cast<uint32_t>(attachments.size()),
.pAttachments = attachments.data(),
.subpassCount = 1,
.pSubpasses = &subpassDescription,
.dependencyCount = static_cast<uint32_t>(dependencies.size()),
.pDependencies = dependencies.data()
};
VK_CHECK_RESULT(vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass));
}
void VulkanRaytracingSample::setupFrameBuffer()
{
frameBuffers.resize(swapChain.images.size());
for (uint32_t i = 0; i < frameBuffers.size(); i++) {
VkImageView attachments[2] { swapChain.imageViews[i], depthStencil.view };
VkFramebufferCreateInfo frameBufferCreateInfo{
.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO,
.renderPass = renderPass,
.attachmentCount = 2,
.pAttachments = attachments,
.width = width,
.height = height,
.layers = 1
};
VK_CHECK_RESULT(vkCreateFramebuffer(device, &frameBufferCreateInfo, nullptr, &frameBuffers[i]));
}
}
void VulkanRaytracingSample::enableExtensions()
{
// Require Vulkan 1.1
apiVersion = VK_API_VERSION_1_1;
// Ray tracing related extensions required by this sample
enabledDeviceExtensions.push_back(VK_KHR_ACCELERATION_STRUCTURE_EXTENSION_NAME);
if (!rayQueryOnly) {
enabledDeviceExtensions.push_back(VK_KHR_RAY_TRACING_PIPELINE_EXTENSION_NAME);
}
// Required by VK_KHR_acceleration_structure
enabledDeviceExtensions.push_back(VK_KHR_BUFFER_DEVICE_ADDRESS_EXTENSION_NAME);
enabledDeviceExtensions.push_back(VK_KHR_DEFERRED_HOST_OPERATIONS_EXTENSION_NAME);
enabledDeviceExtensions.push_back(VK_EXT_DESCRIPTOR_INDEXING_EXTENSION_NAME);
// Required for VK_KHR_ray_tracing_pipeline
enabledDeviceExtensions.push_back(VK_KHR_SPIRV_1_4_EXTENSION_NAME);
// Required by VK_KHR_spirv_1_4
enabledDeviceExtensions.push_back(VK_KHR_SHADER_FLOAT_CONTROLS_EXTENSION_NAME);
// Format for the storage image is decided at runtime, so we can't explicilily state it in the shader
enabledFeatures.shaderStorageImageWriteWithoutFormat = VK_TRUE;
enabledFeatures.shaderStorageImageReadWithoutFormat = VK_TRUE;
}
VulkanRaytracingSample::ScratchBuffer VulkanRaytracingSample::createScratchBuffer(VkDeviceSize size)
{
ScratchBuffer scratchBuffer{};
VkBufferCreateInfo bufferCreateInfo{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.flags = 0,
.size = size,
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
};
VK_CHECK_RESULT(vkCreateBuffer(vulkanDevice->logicalDevice, &bufferCreateInfo, nullptr, &scratchBuffer.handle));
VkMemoryRequirements memoryRequirements{};
vkGetBufferMemoryRequirements(vulkanDevice->logicalDevice, scratchBuffer.handle, &memoryRequirements);
VkMemoryAllocateFlagsInfo memoryAllocateFlagsInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO,
.flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR
};
VkMemoryAllocateInfo memoryAllocateInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.pNext = &memoryAllocateFlagsInfo,
.allocationSize = memoryRequirements.size,
.memoryTypeIndex = vulkanDevice->getMemoryType(memoryRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(vulkanDevice->logicalDevice, &memoryAllocateInfo, nullptr, &scratchBuffer.memory));
VK_CHECK_RESULT(vkBindBufferMemory(vulkanDevice->logicalDevice, scratchBuffer.handle, scratchBuffer.memory, 0));
VkBufferDeviceAddressInfoKHR bufferDeviceAddresInfo{
.sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO,
.buffer = scratchBuffer.handle
};
scratchBuffer.deviceAddress = vkGetBufferDeviceAddressKHR(vulkanDevice->logicalDevice, &bufferDeviceAddresInfo);
return scratchBuffer;
}
void VulkanRaytracingSample::deleteScratchBuffer(ScratchBuffer& scratchBuffer)
{
if (scratchBuffer.memory != VK_NULL_HANDLE) {
vkFreeMemory(vulkanDevice->logicalDevice, scratchBuffer.memory, nullptr);
}
if (scratchBuffer.handle != VK_NULL_HANDLE) {
vkDestroyBuffer(vulkanDevice->logicalDevice, scratchBuffer.handle, nullptr);
}
}
void VulkanRaytracingSample::createAccelerationStructure(AccelerationStructure& accelerationStructure, VkAccelerationStructureTypeKHR type, VkAccelerationStructureBuildSizesInfoKHR buildSizeInfo)
{
VkBufferCreateInfo bufferCreateInfo{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = buildSizeInfo.accelerationStructureSize,
.usage = VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
};
VK_CHECK_RESULT(vkCreateBuffer(vulkanDevice->logicalDevice, &bufferCreateInfo, nullptr, &accelerationStructure.buffer));
VkMemoryRequirements memoryRequirements{};
vkGetBufferMemoryRequirements(vulkanDevice->logicalDevice, accelerationStructure.buffer, &memoryRequirements);
VkMemoryAllocateFlagsInfo memoryAllocateFlagsInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_FLAGS_INFO,
.flags = VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT_KHR
};
VkMemoryAllocateInfo memoryAllocateInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.pNext = &memoryAllocateFlagsInfo,
.allocationSize = memoryRequirements.size,
.memoryTypeIndex = vulkanDevice->getMemoryType(memoryRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(vulkanDevice->logicalDevice, &memoryAllocateInfo, nullptr, &accelerationStructure.memory));
VK_CHECK_RESULT(vkBindBufferMemory(vulkanDevice->logicalDevice, accelerationStructure.buffer, accelerationStructure.memory, 0));
VkAccelerationStructureCreateInfoKHR accelerationStructureCreate_info{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR,
.buffer = accelerationStructure.buffer,
.size = buildSizeInfo.accelerationStructureSize,
.type = type
};
vkCreateAccelerationStructureKHR(vulkanDevice->logicalDevice, &accelerationStructureCreate_info, nullptr, &accelerationStructure.handle);
VkAccelerationStructureDeviceAddressInfoKHR accelerationDeviceAddressInfo{
.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR,
.accelerationStructure = accelerationStructure.handle
};
accelerationStructure.deviceAddress = vkGetAccelerationStructureDeviceAddressKHR(vulkanDevice->logicalDevice, &accelerationDeviceAddressInfo);
}
void VulkanRaytracingSample::deleteAccelerationStructure(AccelerationStructure& accelerationStructure)
{
vkFreeMemory(device, accelerationStructure.memory, nullptr);
vkDestroyBuffer(device, accelerationStructure.buffer, nullptr);
vkDestroyAccelerationStructureKHR(device, accelerationStructure.handle, nullptr);
}
uint64_t VulkanRaytracingSample::getBufferDeviceAddress(VkBuffer buffer)
{
VkBufferDeviceAddressInfoKHR bufferDeviceAI{
.sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO,
.buffer = buffer
};
return vkGetBufferDeviceAddressKHR(vulkanDevice->logicalDevice, &bufferDeviceAI);
}
void VulkanRaytracingSample::createStorageImage(VkFormat format, VkExtent3D extent)
{
// Release ressources if image is to be recreated
if (storageImage.image != VK_NULL_HANDLE) {
vkDestroyImageView(device, storageImage.view, nullptr);
vkDestroyImage(device, storageImage.image, nullptr);
vkFreeMemory(device, storageImage.memory, nullptr);
storageImage = {};
}
VkImageCreateInfo image{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = format,
.extent = extent,
.mipLevels = 1,
.arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_STORAGE_BIT,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED
};
VK_CHECK_RESULT(vkCreateImage(vulkanDevice->logicalDevice, &image, nullptr, &storageImage.image));
VkMemoryRequirements memReqs;
vkGetImageMemoryRequirements(vulkanDevice->logicalDevice, storageImage.image, &memReqs);
VkMemoryAllocateInfo memoryAllocateInfo = vks::initializers::memoryAllocateInfo();
memoryAllocateInfo.allocationSize = memReqs.size;
memoryAllocateInfo.memoryTypeIndex = vulkanDevice->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(vulkanDevice->logicalDevice, &memoryAllocateInfo, nullptr, &storageImage.memory));
VK_CHECK_RESULT(vkBindImageMemory(vulkanDevice->logicalDevice, storageImage.image, storageImage.memory, 0));
VkImageViewCreateInfo colorImageView{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = storageImage.image,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = format,
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1
},
};
VK_CHECK_RESULT(vkCreateImageView(vulkanDevice->logicalDevice, &colorImageView, nullptr, &storageImage.view));
VkCommandBuffer cmdBuffer = vulkanDevice->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
vks::tools::setImageLayout(cmdBuffer, storageImage.image,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_GENERAL,
{ VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 });
vulkanDevice->flushCommandBuffer(cmdBuffer, queue);
}
void VulkanRaytracingSample::deleteStorageImage()
{
vkDestroyImageView(vulkanDevice->logicalDevice, storageImage.view, nullptr);
vkDestroyImage(vulkanDevice->logicalDevice, storageImage.image, nullptr);
vkFreeMemory(vulkanDevice->logicalDevice, storageImage.memory, nullptr);
}
void VulkanRaytracingSample::prepare()
{
VulkanExampleBase::prepare();
// Get properties and features
rayTracingPipelineProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR;
VkPhysicalDeviceProperties2 deviceProperties2{
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
.pNext = &rayTracingPipelineProperties
};
vkGetPhysicalDeviceProperties2(physicalDevice, &deviceProperties2);
accelerationStructureFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_FEATURES_KHR;
VkPhysicalDeviceFeatures2 deviceFeatures2{
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
.pNext = &accelerationStructureFeatures
};
vkGetPhysicalDeviceFeatures2(physicalDevice, &deviceFeatures2);
// Get the function pointers required for ray tracing
vkGetBufferDeviceAddressKHR = reinterpret_cast<PFN_vkGetBufferDeviceAddressKHR>(vkGetDeviceProcAddr(device, "vkGetBufferDeviceAddressKHR"));
vkCmdBuildAccelerationStructuresKHR = reinterpret_cast<PFN_vkCmdBuildAccelerationStructuresKHR>(vkGetDeviceProcAddr(device, "vkCmdBuildAccelerationStructuresKHR"));
vkBuildAccelerationStructuresKHR = reinterpret_cast<PFN_vkBuildAccelerationStructuresKHR>(vkGetDeviceProcAddr(device, "vkBuildAccelerationStructuresKHR"));
vkCreateAccelerationStructureKHR = reinterpret_cast<PFN_vkCreateAccelerationStructureKHR>(vkGetDeviceProcAddr(device, "vkCreateAccelerationStructureKHR"));
vkDestroyAccelerationStructureKHR = reinterpret_cast<PFN_vkDestroyAccelerationStructureKHR>(vkGetDeviceProcAddr(device, "vkDestroyAccelerationStructureKHR"));
vkGetAccelerationStructureBuildSizesKHR = reinterpret_cast<PFN_vkGetAccelerationStructureBuildSizesKHR>(vkGetDeviceProcAddr(device, "vkGetAccelerationStructureBuildSizesKHR"));
vkGetAccelerationStructureDeviceAddressKHR = reinterpret_cast<PFN_vkGetAccelerationStructureDeviceAddressKHR>(vkGetDeviceProcAddr(device, "vkGetAccelerationStructureDeviceAddressKHR"));
vkCmdTraceRaysKHR = reinterpret_cast<PFN_vkCmdTraceRaysKHR>(vkGetDeviceProcAddr(device, "vkCmdTraceRaysKHR"));
vkGetRayTracingShaderGroupHandlesKHR = reinterpret_cast<PFN_vkGetRayTracingShaderGroupHandlesKHR>(vkGetDeviceProcAddr(device, "vkGetRayTracingShaderGroupHandlesKHR"));
vkCreateRayTracingPipelinesKHR = reinterpret_cast<PFN_vkCreateRayTracingPipelinesKHR>(vkGetDeviceProcAddr(device, "vkCreateRayTracingPipelinesKHR"));
}
VkStridedDeviceAddressRegionKHR VulkanRaytracingSample::getSbtEntryStridedDeviceAddressRegion(VkBuffer buffer, uint32_t handleCount)
{
const uint32_t handleSizeAligned = vks::tools::alignedSize(rayTracingPipelineProperties.shaderGroupHandleSize, rayTracingPipelineProperties.shaderGroupHandleAlignment);
VkStridedDeviceAddressRegionKHR stridedDeviceAddressRegionKHR{
.deviceAddress = getBufferDeviceAddress(buffer),
.stride = handleSizeAligned
};
stridedDeviceAddressRegionKHR.size = handleCount * handleSizeAligned;
return stridedDeviceAddressRegionKHR;
}
void VulkanRaytracingSample::createShaderBindingTable(ShaderBindingTable& shaderBindingTable, uint32_t handleCount)
{
// Create buffer to hold all shader handles for the SBT
VK_CHECK_RESULT(vulkanDevice->createBuffer(
VK_BUFFER_USAGE_SHADER_BINDING_TABLE_BIT_KHR | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
&shaderBindingTable,
rayTracingPipelineProperties.shaderGroupHandleSize * handleCount));
// Get the strided address to be used when dispatching the rays
shaderBindingTable.stridedDeviceAddressRegion = getSbtEntryStridedDeviceAddressRegion(shaderBindingTable.buffer, handleCount);
// Map persistent
shaderBindingTable.map();
}
void VulkanRaytracingSample::drawUI(VkCommandBuffer commandBuffer, VkFramebuffer framebuffer)
{
VkClearValue clearValues[2]{};
clearValues[0].color = defaultClearColor;
clearValues[1].depthStencil = { 1.0f, 0 };
VkRenderPassBeginInfo renderPassBeginInfo{
.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
.renderPass = renderPass,
.framebuffer = framebuffer,
.renderArea = {.offset = {.x = 0, .y = 0, }, .extent = {.width = width, .height = height } },
.clearValueCount = 2,
.pClearValues = clearValues
};
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE);
VulkanExampleBase::drawUI(commandBuffer);
vkCmdEndRenderPass(commandBuffer);
}
@@ -0,0 +1,92 @@
/*
* Extended sample base class for ray tracing based samples
*
* Copyright (C) 2020-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include "vulkan/vulkan.h"
#include "vulkanexamplebase.h"
#include "VulkanTools.h"
#include "VulkanDevice.h"
// Base sample class with added features specific to hardware ray traced samples
class VulkanRaytracingSample : public VulkanExampleBase
{
protected:
// Update the default render pass with different color attachment load ops
virtual void setupRenderPass();
virtual void setupFrameBuffer();
public:
// Function pointers for ray tracing related stuff
PFN_vkGetBufferDeviceAddressKHR vkGetBufferDeviceAddressKHR{ nullptr };
PFN_vkCreateAccelerationStructureKHR vkCreateAccelerationStructureKHR{ nullptr };
PFN_vkDestroyAccelerationStructureKHR vkDestroyAccelerationStructureKHR{ nullptr };
PFN_vkGetAccelerationStructureBuildSizesKHR vkGetAccelerationStructureBuildSizesKHR{ nullptr };
PFN_vkGetAccelerationStructureDeviceAddressKHR vkGetAccelerationStructureDeviceAddressKHR{ nullptr };
PFN_vkBuildAccelerationStructuresKHR vkBuildAccelerationStructuresKHR{ nullptr };
PFN_vkCmdBuildAccelerationStructuresKHR vkCmdBuildAccelerationStructuresKHR{ nullptr };
PFN_vkCmdTraceRaysKHR vkCmdTraceRaysKHR{ nullptr };
PFN_vkGetRayTracingShaderGroupHandlesKHR vkGetRayTracingShaderGroupHandlesKHR{ nullptr };
PFN_vkCreateRayTracingPipelinesKHR vkCreateRayTracingPipelinesKHR{ nullptr };
// Available features and properties
VkPhysicalDeviceRayTracingPipelinePropertiesKHR rayTracingPipelineProperties{};
VkPhysicalDeviceAccelerationStructureFeaturesKHR accelerationStructureFeatures{};
// Enabled features and properties
VkPhysicalDeviceBufferDeviceAddressFeatures enabledBufferDeviceAddresFeatures{};
VkPhysicalDeviceRayTracingPipelineFeaturesKHR enabledRayTracingPipelineFeatures{};
VkPhysicalDeviceAccelerationStructureFeaturesKHR enabledAccelerationStructureFeatures{};
// Holds information for a ray tracing scratch buffer that is used as a temporary storage
struct ScratchBuffer
{
uint64_t deviceAddress{ 0 };
VkBuffer handle{ VK_NULL_HANDLE };
VkDeviceMemory memory{ VK_NULL_HANDLE };
};
// Holds information for a ray tracing acceleration structure
struct AccelerationStructure {
VkAccelerationStructureKHR handle{ VK_NULL_HANDLE };
uint64_t deviceAddress{ 0 };
VkDeviceMemory memory{ VK_NULL_HANDLE };
VkBuffer buffer{ VK_NULL_HANDLE };
};
// Holds information for a storage image that the ray tracing shaders output to
struct StorageImage {
VkDeviceMemory memory{ VK_NULL_HANDLE };
VkImage image{ VK_NULL_HANDLE };
VkImageView view{ VK_NULL_HANDLE };
VkFormat format;
} storageImage;
// Extends the buffer class and holds information for a shader binding table
class ShaderBindingTable : public vks::Buffer {
public:
VkStridedDeviceAddressRegionKHR stridedDeviceAddressRegion{};
};
// Set to true, to denote that the sample only uses ray queries (changes extension and render pass handling)
bool rayQueryOnly = false;
void enableExtensions();
ScratchBuffer createScratchBuffer(VkDeviceSize size);
void deleteScratchBuffer(ScratchBuffer& scratchBuffer);
void createAccelerationStructure(AccelerationStructure& accelerationStructure, VkAccelerationStructureTypeKHR type, VkAccelerationStructureBuildSizesInfoKHR buildSizeInfo);
void deleteAccelerationStructure(AccelerationStructure& accelerationStructure);
uint64_t getBufferDeviceAddress(VkBuffer buffer);
void createStorageImage(VkFormat format, VkExtent3D extent);
void deleteStorageImage();
VkStridedDeviceAddressRegionKHR getSbtEntryStridedDeviceAddressRegion(VkBuffer buffer, uint32_t handleCount);
void createShaderBindingTable(ShaderBindingTable& shaderBindingTable, uint32_t handleCount);
// Draw the ImGUI UI overlay using a render pass
void drawUI(VkCommandBuffer commandBuffer, VkFramebuffer framebuffer);
virtual void prepare();
};
@@ -0,0 +1,515 @@
/*
* Class wrapping access to the swap chain
*
* A swap chain is a collection of framebuffers used for rendering and presentation to the windowing system
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanSwapChain.h"
/** @brief Creates the platform specific surface abstraction of the native platform window used for presentation */
#if defined(VK_USE_PLATFORM_WIN32_KHR)
void VulkanSwapChain::initSurface(void* platformHandle, void* platformWindow)
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
void VulkanSwapChain::initSurface(ANativeWindow* window)
#elif defined(VK_USE_PLATFORM_OHOS)
void VulkanSwapChain::initSurface(NativeWindow* window)
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
void VulkanSwapChain::initSurface(IDirectFB* dfb, IDirectFBSurface* window)
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
void VulkanSwapChain::initSurface(wl_display *display, wl_surface *window)
#elif defined(VK_USE_PLATFORM_XCB_KHR)
void VulkanSwapChain::initSurface(xcb_connection_t* connection, xcb_window_t window)
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK))
void VulkanSwapChain::initSurface(void* view)
#elif defined(VK_USE_PLATFORM_METAL_EXT)
void VulkanSwapChain::initSurface(CAMetalLayer* metalLayer)
#elif (defined(_DIRECT2DISPLAY) || defined(VK_USE_PLATFORM_HEADLESS_EXT))
void VulkanSwapChain::initSurface(uint32_t width, uint32_t height)
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
void VulkanSwapChain::initSurface(screen_context_t screen_context, screen_window_t screen_window)
#endif
{
VkResult err = VK_SUCCESS;
// Create the os-specific surface
#if defined(VK_USE_PLATFORM_WIN32_KHR)
VkWin32SurfaceCreateInfoKHR surfaceCreateInfo{};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;
surfaceCreateInfo.hinstance = (HINSTANCE)platformHandle;
surfaceCreateInfo.hwnd = (HWND)platformWindow;
err = vkCreateWin32SurfaceKHR(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
VkAndroidSurfaceCreateInfoKHR surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR;
surfaceCreateInfo.window = window;
err = vkCreateAndroidSurfaceKHR(instance, &surfaceCreateInfo, NULL, &surface);
#elif defined(VK_USE_PLATFORM_OHOS)
VkSurfaceCreateInfoOHOS surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_SURFACE_CREATE_INFO_OHOS;
surfaceCreateInfo.pNext = NULL;
surfaceCreateInfo.flags = 0;
surfaceCreateInfo.window = window;
err = vkCreateSurfaceOHOS(instance, &surfaceCreateInfo, NULL, &surface);
#elif defined(VK_USE_PLATFORM_IOS_MVK)
VkIOSSurfaceCreateInfoMVK surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_IOS_SURFACE_CREATE_INFO_MVK;
surfaceCreateInfo.pNext = NULL;
surfaceCreateInfo.flags = 0;
surfaceCreateInfo.pView = view;
err = vkCreateIOSSurfaceMVK(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_MACOS_MVK)
VkMacOSSurfaceCreateInfoMVK surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_MACOS_SURFACE_CREATE_INFO_MVK;
surfaceCreateInfo.pNext = NULL;
surfaceCreateInfo.flags = 0;
surfaceCreateInfo.pView = view;
err = vkCreateMacOSSurfaceMVK(instance, &surfaceCreateInfo, NULL, &surface);
#elif defined(VK_USE_PLATFORM_METAL_EXT)
VkMetalSurfaceCreateInfoEXT surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_METAL_SURFACE_CREATE_INFO_EXT;
surfaceCreateInfo.pNext = NULL;
surfaceCreateInfo.flags = 0;
surfaceCreateInfo.pLayer = metalLayer;
err = vkCreateMetalSurfaceEXT(instance, &surfaceCreateInfo, NULL, &surface);
#elif defined(_DIRECT2DISPLAY)
createDirect2DisplaySurface(width, height);
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
VkDirectFBSurfaceCreateInfoEXT surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_DIRECTFB_SURFACE_CREATE_INFO_EXT;
surfaceCreateInfo.dfb = dfb;
surfaceCreateInfo.surface = window;
err = vkCreateDirectFBSurfaceEXT(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
VkWaylandSurfaceCreateInfoKHR surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_WAYLAND_SURFACE_CREATE_INFO_KHR;
surfaceCreateInfo.display = display;
surfaceCreateInfo.surface = window;
err = vkCreateWaylandSurfaceKHR(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_XCB_KHR)
VkXcbSurfaceCreateInfoKHR surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_XCB_SURFACE_CREATE_INFO_KHR;
surfaceCreateInfo.connection = connection;
surfaceCreateInfo.window = window;
err = vkCreateXcbSurfaceKHR(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_HEADLESS_EXT)
VkHeadlessSurfaceCreateInfoEXT surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_HEADLESS_SURFACE_CREATE_INFO_EXT;
PFN_vkCreateHeadlessSurfaceEXT fpCreateHeadlessSurfaceEXT = (PFN_vkCreateHeadlessSurfaceEXT)vkGetInstanceProcAddr(instance, "vkCreateHeadlessSurfaceEXT");
if (!fpCreateHeadlessSurfaceEXT){
vks::tools::exitFatal("Could not fetch function pointer for the headless extension!", -1);
}
err = fpCreateHeadlessSurfaceEXT(instance, &surfaceCreateInfo, nullptr, &surface);
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
VkScreenSurfaceCreateInfoQNX surfaceCreateInfo = {};
surfaceCreateInfo.sType = VK_STRUCTURE_TYPE_SCREEN_SURFACE_CREATE_INFO_QNX;
surfaceCreateInfo.pNext = NULL;
surfaceCreateInfo.flags = 0;
surfaceCreateInfo.context = screen_context;
surfaceCreateInfo.window = screen_window;
err = vkCreateScreenSurfaceQNX(instance, &surfaceCreateInfo, NULL, &surface);
#endif
if (err != VK_SUCCESS) {
vks::tools::exitFatal("Could not create surface!", err);
}
// Get available queue family properties
uint32_t queueCount;
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueCount, nullptr);
assert(queueCount >= 1);
std::vector<VkQueueFamilyProperties> queueProps(queueCount);
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueCount, queueProps.data());
// Iterate over each queue to learn whether it supports presenting:
// Find a queue with present support
// Will be used to present the swap chain images to the windowing system
std::vector<VkBool32> supportsPresent(queueCount);
for (uint32_t i = 0; i < queueCount; i++)
{
vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, i, surface, &supportsPresent[i]);
}
// Search for a graphics and a present queue in the array of queue
// families, try to find one that supports both
uint32_t graphicsQueueNodeIndex = UINT32_MAX;
uint32_t presentQueueNodeIndex = UINT32_MAX;
for (uint32_t i = 0; i < queueCount; i++) {
if ((queueProps[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0) {
if (graphicsQueueNodeIndex == UINT32_MAX) {
graphicsQueueNodeIndex = i;
}
if (supportsPresent[i] == VK_TRUE) {
graphicsQueueNodeIndex = i;
presentQueueNodeIndex = i;
break;
}
}
}
if (presentQueueNodeIndex == UINT32_MAX)
{
// If there's no queue that supports both present and graphics
// try to find a separate present queue
for (uint32_t i = 0; i < queueCount; ++i) {
if (supportsPresent[i] == VK_TRUE) {
presentQueueNodeIndex = i;
break;
}
}
}
// Exit if either a graphics or a presenting queue hasn't been found
if (graphicsQueueNodeIndex == UINT32_MAX || presentQueueNodeIndex == UINT32_MAX) {
vks::tools::exitFatal("Could not find a graphics and/or presenting queue!", -1);
}
if (graphicsQueueNodeIndex != presentQueueNodeIndex) {
vks::tools::exitFatal("Separate graphics and presenting queues are not supported yet!", -1);
}
queueNodeIndex = graphicsQueueNodeIndex;
// Get list of supported surface formats
uint32_t formatCount;
VK_CHECK_RESULT(vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &formatCount, NULL));
assert(formatCount > 0);
std::vector<VkSurfaceFormatKHR> surfaceFormats(formatCount);
VK_CHECK_RESULT(vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice, surface, &formatCount, surfaceFormats.data()));
// We want to get a format that best suits our needs, so we try to get one from a set of preferred formats
// Initialize the format to the first one returned by the implementation in case we can't find one of the preffered formats
VkSurfaceFormatKHR selectedFormat = surfaceFormats[0];
std::vector<VkFormat> preferredImageFormats = {
VK_FORMAT_B8G8R8A8_UNORM,
VK_FORMAT_R8G8B8A8_UNORM,
VK_FORMAT_A8B8G8R8_UNORM_PACK32
};
for (auto& availableFormat : surfaceFormats) {
if (std::find(preferredImageFormats.begin(), preferredImageFormats.end(), availableFormat.format) != preferredImageFormats.end()) {
selectedFormat = availableFormat;
break;
}
}
colorFormat = selectedFormat.format;
colorSpace = selectedFormat.colorSpace;
}
void VulkanSwapChain::setContext(VkInstance instance, VkPhysicalDevice physicalDevice, VkDevice device)
{
this->instance = instance;
this->physicalDevice = physicalDevice;
this->device = device;
}
void VulkanSwapChain::create(uint32_t& width, uint32_t& height, bool vsync, bool fullscreen)
{
assert(physicalDevice);
assert(device);
assert(instance);
// Store the current swap chain handle so we can use it later on to ease up recreation
VkSwapchainKHR oldSwapchain = swapChain;
// Get physical device surface properties and formats
VkSurfaceCapabilitiesKHR surfaceCaps;
VK_CHECK_RESULT(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice, surface, &surfaceCaps));
VkExtent2D swapchainExtent = {};
// If width (and height) equals the special value 0xFFFFFFFF, the size of the surface will be set by the swapchain
if (surfaceCaps.currentExtent.width == (uint32_t)-1) {
// If the surface size is undefined, the size is set to the size of the images requested
swapchainExtent.width = width;
swapchainExtent.height = height;
} else {
// If the surface size is defined, the swap chain size must match
swapchainExtent = surfaceCaps.currentExtent;
width = surfaceCaps.currentExtent.width;
height = surfaceCaps.currentExtent.height;
}
// Select a present mode for the swapchain
uint32_t presentModeCount;
VK_CHECK_RESULT(vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &presentModeCount, nullptr));
assert(presentModeCount > 0);
std::vector<VkPresentModeKHR> presentModes(presentModeCount);
VK_CHECK_RESULT(vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &presentModeCount, presentModes.data()));
// The VK_PRESENT_MODE_FIFO_KHR mode must always be present as per spec
// This mode waits for the vertical blank ("v-sync")
VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
// If v-sync is not requested, try to find a mailbox mode
// It's the lowest latency non-tearing present mode available
if (!vsync)
{
for (size_t i = 0; i < presentModeCount; i++)
{
if (presentModes[i] == VK_PRESENT_MODE_MAILBOX_KHR)
{
swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR;
break;
}
if (presentModes[i] == VK_PRESENT_MODE_IMMEDIATE_KHR)
{
swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
}
}
}
// Determine the number of images
uint32_t desiredNumberOfSwapchainImages = surfaceCaps.minImageCount + 1;
if ((surfaceCaps.maxImageCount > 0) && (desiredNumberOfSwapchainImages > surfaceCaps.maxImageCount)) {
desiredNumberOfSwapchainImages = surfaceCaps.maxImageCount;
}
// Find the transformation of the surface
VkSurfaceTransformFlagsKHR preTransform;
if (surfaceCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR) {
// We prefer a non-rotated transform
preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
} else {
preTransform = surfaceCaps.currentTransform;
}
// Find a supported composite alpha format (not all devices support alpha opaque)
VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
// Simply select the first composite alpha format available
std::vector<VkCompositeAlphaFlagBitsKHR> compositeAlphaFlags = {
VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR,
VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR,
VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR,
};
for (auto& compositeAlphaFlag : compositeAlphaFlags) {
if (surfaceCaps.supportedCompositeAlpha & compositeAlphaFlag) {
compositeAlpha = compositeAlphaFlag;
break;
};
}
VkSwapchainCreateInfoKHR swapchainCI{
.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
.surface = surface,
.minImageCount = desiredNumberOfSwapchainImages,
.imageFormat = colorFormat,
.imageColorSpace = colorSpace,
.imageExtent = { swapchainExtent.width, swapchainExtent.height },
.imageArrayLayers = 1,
.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE,
.queueFamilyIndexCount = 0,
.preTransform = (VkSurfaceTransformFlagBitsKHR)preTransform,
.compositeAlpha = compositeAlpha,
.presentMode = swapchainPresentMode,
// Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area
.clipped = VK_TRUE,
// Setting oldSwapChain to the saved handle of the previous swapchain aids in resource reuse and makes sure that we can still present already acquired images
.oldSwapchain = oldSwapchain,
};
// Enable transfer source on swap chain images if supported
if (surfaceCaps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) {
swapchainCI.imageUsage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
}
// Enable transfer destination on swap chain images if supported
if (surfaceCaps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT) {
swapchainCI.imageUsage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
}
VK_CHECK_RESULT(vkCreateSwapchainKHR(device, &swapchainCI, nullptr, &swapChain));
// If an existing swap chain is re-created, destroy the old swap chain and the ressources owned by the application (image views, images are owned by the swap chain)
if (oldSwapchain != VK_NULL_HANDLE) {
for (auto i = 0; i < images.size(); i++) {
vkDestroyImageView(device, imageViews[i], nullptr);
}
vkDestroySwapchainKHR(device, oldSwapchain, nullptr);
}
// Get the (new) swap chain images
VK_CHECK_RESULT(vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr));
images.resize(imageCount);
VK_CHECK_RESULT(vkGetSwapchainImagesKHR(device, swapChain, &imageCount, images.data()));
// Get the swap chain buffers containing the image and imageview
imageViews.resize(imageCount);
for (auto i = 0; i < images.size(); i++)
{
VkImageViewCreateInfo colorAttachmentView{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = images[i],
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = colorFormat,
.components = { VK_COMPONENT_SWIZZLE_R, VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_A },
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1
},
};
VK_CHECK_RESULT(vkCreateImageView(device, &colorAttachmentView, nullptr, &imageViews[i]));
}
}
VkResult VulkanSwapChain::acquireNextImage(VkSemaphore presentCompleteSemaphore, uint32_t& imageIndex)
{
// By setting timeout to UINT64_MAX we will always wait until the next image has been acquired or an actual error is thrown
// With that we don't have to handle VK_NOT_READY
return vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, presentCompleteSemaphore, (VkFence)nullptr, &imageIndex);
}
void VulkanSwapChain::cleanup()
{
if (swapChain != VK_NULL_HANDLE) {
for (auto i = 0; i < images.size(); i++) {
vkDestroyImageView(device, imageViews[i], nullptr);
}
vkDestroySwapchainKHR(device, swapChain, nullptr);
}
if (surface != VK_NULL_HANDLE) {
vkDestroySurfaceKHR(instance, surface, nullptr);
}
surface = VK_NULL_HANDLE;
swapChain = VK_NULL_HANDLE;
}
#if defined(_DIRECT2DISPLAY)
/**
* Create direct to display surface
*/
void VulkanSwapChain::createDirect2DisplaySurface(uint32_t width, uint32_t height)
{
uint32_t displayPropertyCount;
// Get display property
vkGetPhysicalDeviceDisplayPropertiesKHR(physicalDevice, &displayPropertyCount, NULL);
VkDisplayPropertiesKHR* pDisplayProperties = new VkDisplayPropertiesKHR[displayPropertyCount];
vkGetPhysicalDeviceDisplayPropertiesKHR(physicalDevice, &displayPropertyCount, pDisplayProperties);
// Get plane property
uint32_t planePropertyCount;
vkGetPhysicalDeviceDisplayPlanePropertiesKHR(physicalDevice, &planePropertyCount, NULL);
VkDisplayPlanePropertiesKHR* pPlaneProperties = new VkDisplayPlanePropertiesKHR[planePropertyCount];
vkGetPhysicalDeviceDisplayPlanePropertiesKHR(physicalDevice, &planePropertyCount, pPlaneProperties);
VkDisplayKHR display = VK_NULL_HANDLE;
VkDisplayModeKHR displayMode;
VkDisplayModePropertiesKHR* pModeProperties;
bool foundMode = false;
for(uint32_t i = 0; i < displayPropertyCount;++i)
{
display = pDisplayProperties[i].display;
uint32_t modeCount;
vkGetDisplayModePropertiesKHR(physicalDevice, display, &modeCount, NULL);
pModeProperties = new VkDisplayModePropertiesKHR[modeCount];
vkGetDisplayModePropertiesKHR(physicalDevice, display, &modeCount, pModeProperties);
for (uint32_t j = 0; j < modeCount; ++j)
{
const VkDisplayModePropertiesKHR* mode = &pModeProperties[j];
if (mode->parameters.visibleRegion.width == width && mode->parameters.visibleRegion.height == height)
{
displayMode = mode->displayMode;
foundMode = true;
break;
}
}
if (foundMode)
{
break;
}
delete [] pModeProperties;
}
if(!foundMode)
{
vks::tools::exitFatal("Can't find a display and a display mode!", -1);
return;
}
// Search for a best plane we can use
uint32_t bestPlaneIndex = UINT32_MAX;
VkDisplayKHR* pDisplays = NULL;
for(uint32_t i = 0; i < planePropertyCount; i++)
{
uint32_t planeIndex=i;
uint32_t displayCount;
vkGetDisplayPlaneSupportedDisplaysKHR(physicalDevice, planeIndex, &displayCount, NULL);
if (pDisplays)
{
delete [] pDisplays;
}
pDisplays = new VkDisplayKHR[displayCount];
vkGetDisplayPlaneSupportedDisplaysKHR(physicalDevice, planeIndex, &displayCount, pDisplays);
// Find a display that matches the current plane
bestPlaneIndex = UINT32_MAX;
for(uint32_t j = 0; j < displayCount; j++)
{
if(display == pDisplays[j])
{
bestPlaneIndex = i;
break;
}
}
if(bestPlaneIndex != UINT32_MAX)
{
break;
}
}
if(bestPlaneIndex == UINT32_MAX)
{
vks::tools::exitFatal("Can't find a plane for displaying!", -1);
return;
}
VkDisplayPlaneCapabilitiesKHR planeCap;
vkGetDisplayPlaneCapabilitiesKHR(physicalDevice, displayMode, bestPlaneIndex, &planeCap);
VkDisplayPlaneAlphaFlagBitsKHR alphaMode = (VkDisplayPlaneAlphaFlagBitsKHR)0;
if (planeCap.supportedAlpha & VK_DISPLAY_PLANE_ALPHA_PER_PIXEL_PREMULTIPLIED_BIT_KHR)
{
alphaMode = VK_DISPLAY_PLANE_ALPHA_PER_PIXEL_PREMULTIPLIED_BIT_KHR;
}
else if (planeCap.supportedAlpha & VK_DISPLAY_PLANE_ALPHA_PER_PIXEL_BIT_KHR)
{
alphaMode = VK_DISPLAY_PLANE_ALPHA_PER_PIXEL_BIT_KHR;
}
else if (planeCap.supportedAlpha & VK_DISPLAY_PLANE_ALPHA_GLOBAL_BIT_KHR)
{
alphaMode = VK_DISPLAY_PLANE_ALPHA_GLOBAL_BIT_KHR;
}
else if (planeCap.supportedAlpha & VK_DISPLAY_PLANE_ALPHA_OPAQUE_BIT_KHR)
{
alphaMode = VK_DISPLAY_PLANE_ALPHA_OPAQUE_BIT_KHR;
}
VkDisplaySurfaceCreateInfoKHR surfaceInfo{};
surfaceInfo.sType = VK_STRUCTURE_TYPE_DISPLAY_SURFACE_CREATE_INFO_KHR;
surfaceInfo.pNext = NULL;
surfaceInfo.flags = 0;
surfaceInfo.displayMode = displayMode;
surfaceInfo.planeIndex = bestPlaneIndex;
surfaceInfo.planeStackIndex = pPlaneProperties[bestPlaneIndex].currentStackIndex;
surfaceInfo.transform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
surfaceInfo.globalAlpha = 1.0;
surfaceInfo.alphaMode = alphaMode;
surfaceInfo.imageExtent.width = width;
surfaceInfo.imageExtent.height = height;
VkResult result = vkCreateDisplayPlaneSurfaceKHR(instance, &surfaceInfo, NULL, &surface);
if (result !=VK_SUCCESS) {
vks::tools::exitFatal("Failed to create surface!", result);
}
delete[] pDisplays;
delete[] pModeProperties;
delete[] pDisplayProperties;
delete[] pPlaneProperties;
}
#endif
@@ -0,0 +1,93 @@
/*
* Class wrapping access to the swap chain
*
* A swap chain is a collection of framebuffers used for rendering and presentation to the windowing system
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <stdlib.h>
#include <string>
#include <assert.h>
#include <stdio.h>
#include <vector>
#include <vulkan/vulkan.h>
#include "VulkanTools.h"
#ifdef __ANDROID__
#include "VulkanAndroid.h"
#endif
#ifdef __APPLE__
#include <sys/utsname.h>
#endif
class VulkanSwapChain
{
private:
VkInstance instance{ VK_NULL_HANDLE };
VkDevice device{ VK_NULL_HANDLE };
VkPhysicalDevice physicalDevice{ VK_NULL_HANDLE };
VkSurfaceKHR surface{ VK_NULL_HANDLE };
public:
VkFormat colorFormat{};
VkColorSpaceKHR colorSpace{};
VkSwapchainKHR swapChain{ VK_NULL_HANDLE };
std::vector<VkImage> images{};
std::vector<VkImageView> imageViews{};
uint32_t queueNodeIndex{ UINT32_MAX };
uint32_t imageCount{ 0 };
#if defined(VK_USE_PLATFORM_WIN32_KHR)
void initSurface(void* platformHandle, void* platformWindow);
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
void initSurface(ANativeWindow* window);
#elif defined (VK_USE_PLATFORM_OHOS)
void initSurface(NativeWindow* window);
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
void initSurface(IDirectFB* dfb, IDirectFBSurface* window);
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
void initSurface(wl_display* display, wl_surface* window);
#elif defined(VK_USE_PLATFORM_XCB_KHR)
void initSurface(xcb_connection_t* connection, xcb_window_t window);
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK))
void initSurface(void* view);
#elif defined(VK_USE_PLATFORM_METAL_EXT)
void initSurface(CAMetalLayer* metalLayer);
#elif (defined(_DIRECT2DISPLAY) || defined(VK_USE_PLATFORM_HEADLESS_EXT))
void initSurface(uint32_t width, uint32_t height);
#if defined(_DIRECT2DISPLAY)
void createDirect2DisplaySurface(uint32_t width, uint32_t height);
#endif
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
void initSurface(screen_context_t screen_context, screen_window_t screen_window);
#endif
/* Set the Vulkan objects required for swapchain creation and management, must be called before swapchain creation */
void setContext(VkInstance instance, VkPhysicalDevice physicalDevice, VkDevice device);
/**
* Create the swapchain and get its images with given width and height
*
* @param width Pointer to the width of the swapchain (may be adjusted to fit the requirements of the swapchain)
* @param height Pointer to the height of the swapchain (may be adjusted to fit the requirements of the swapchain)
* @param vsync (Optional, default = false) Can be used to force vsync-ed rendering (by using VK_PRESENT_MODE_FIFO_KHR as presentation mode)
*/
void create(uint32_t& width, uint32_t& height, bool vsync = false, bool fullscreen = false);
/**
* Acquires the next image in the swap chain
*
* @param presentCompleteSemaphore (Optional) Semaphore that is signaled when the image is ready for use
* @param imageIndex Pointer to the image index that will be increased if the next image could be acquired
*
* @note The function will always wait until the next image has been acquired by setting timeout to UINT64_MAX
*
* @return VkResult of the image acquisition
*/
VkResult acquireNextImage(VkSemaphore presentCompleteSemaphore, uint32_t& imageIndex);
/* Free all Vulkan resources acquired by the swapchain */
void cleanup();
};
@@ -0,0 +1,723 @@
/*
* Vulkan texture loader for KTX files
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license(MIT) (http://opensource.org/licenses/MIT)
*/
#include <VulkanTexture.h>
namespace vks
{
void Texture::updateDescriptor()
{
descriptor.sampler = sampler;
descriptor.imageView = view;
descriptor.imageLayout = imageLayout;
}
void Texture::destroy()
{
vkDestroyImageView(device->logicalDevice, view, nullptr);
vkDestroyImage(device->logicalDevice, image, nullptr);
if (sampler)
{
vkDestroySampler(device->logicalDevice, sampler, nullptr);
}
vkFreeMemory(device->logicalDevice, deviceMemory, nullptr);
}
ktxResult Texture::loadKTXFile(std::string filename, ktxTexture **target)
{
ktxResult result = KTX_SUCCESS;
#if defined(__ANDROID__)
AAsset* asset = AAssetManager_open(androidApp->activity->assetManager, filename.c_str(), AASSET_MODE_STREAMING);
if (!asset) {
vks::tools::exitFatal("Could not load texture from " + filename + "\n\nMake sure the assets submodule has been checked out and is up-to-date.", -1);
}
size_t size = AAsset_getLength(asset);
assert(size > 0);
ktx_uint8_t *textureData = new ktx_uint8_t[size];
AAsset_read(asset, textureData, size);
AAsset_close(asset);
result = ktxTexture_CreateFromMemory(textureData, size, KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, target);
delete[] textureData;
#elif defined (__OHOS__)
// Load texture from HarmonyOS raw resources using ResourceManager singleton
NativeResourceManager* resourceMgr = ResourceManager::getInstance().getNativeResourceManager();
if (!resourceMgr) {
vks::tools::exitFatal("ResourceManager not initialized. Call ResourceManager::getInstance().initialize() first.", -1);
}
RawFile* rawFile = OH_ResourceManager_OpenRawFile(resourceMgr, filename.c_str());
if (!rawFile) {
vks::tools::exitFatal("Could not load texture from " + filename + "\n\nMake sure the assets submodule has been checked out and is up-to-date.", -1);
}
size_t size = OH_ResourceManager_GetRawFileSize(rawFile);
assert(size > 0);
ktx_uint8_t *textureData = new ktx_uint8_t[size];
OH_ResourceManager_ReadRawFile(rawFile, textureData, size);
OH_ResourceManager_CloseRawFile(rawFile);
result = ktxTexture_CreateFromMemory(textureData, size, KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, target);
delete[] textureData;
#else
if (!vks::tools::fileExists(filename)) {
vks::tools::exitFatal("Could not load texture from " + filename + "\n\nMake sure the assets submodule has been checked out and is up-to-date.", -1);
}
result = ktxTexture_CreateFromNamedFile(filename.c_str(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, target);
#endif
return result;
}
/**
* Load a 2D texture including all mip levels
*
* @param filename File to load (supports .ktx)
* @param format Vulkan format of the image data stored in the file
* @param device Vulkan device to create the texture on
* @param copyQueue Queue used for the texture staging copy commands (must support transfer)
* @param (Optional) imageUsageFlags Usage flags for the texture's image (defaults to VK_IMAGE_USAGE_SAMPLED_BIT)
* @param (Optional) imageLayout Usage layout for the texture (defaults VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
*
*/
void Texture2D::loadFromFile(std::string filename, VkFormat format, vks::VulkanDevice *device, VkQueue copyQueue, VkImageUsageFlags imageUsageFlags, VkImageLayout imageLayout)
{
ktxTexture* ktxTexture;
ktxResult result = loadKTXFile(filename, &ktxTexture);
assert(result == KTX_SUCCESS);
this->device = device;
width = ktxTexture->baseWidth;
height = ktxTexture->baseHeight;
mipLevels = ktxTexture->numLevels;
this->format = format;
ktx_uint8_t *ktxTextureData = ktxTexture_GetData(ktxTexture);
ktx_size_t ktxTextureSize = ktxTexture_GetSize(ktxTexture);
// Get device properties for the requested texture format
VkFormatProperties formatProperties;
vkGetPhysicalDeviceFormatProperties(device->physicalDevice, format, &formatProperties);
// Use a separate command buffer for texture loading
VkCommandBuffer copyCmd = device->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Create a host-visible staging buffer that contains the raw image data
VkBuffer stagingBuffer;
VkDeviceMemory stagingMemory;
VkBufferCreateInfo bufferCreateInfo{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = ktxTextureSize,
.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
};
VK_CHECK_RESULT(vkCreateBuffer(device->logicalDevice, &bufferCreateInfo, nullptr, &stagingBuffer));
// Get memory requirements for the staging buffer (alignment, memory type bits)
VkMemoryRequirements memReqs;
vkGetBufferMemoryRequirements(device->logicalDevice, stagingBuffer, &memReqs);
VkMemoryAllocateInfo memAllocInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
// Get memory type index for a host visible buffer
.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &stagingMemory));
VK_CHECK_RESULT(vkBindBufferMemory(device->logicalDevice, stagingBuffer, stagingMemory, 0));
// Copy texture data into staging buffer
uint8_t* data{ nullptr };
VK_CHECK_RESULT(vkMapMemory(device->logicalDevice, stagingMemory, 0, memReqs.size, 0, (void **)&data));
memcpy(data, ktxTextureData, ktxTextureSize);
vkUnmapMemory(device->logicalDevice, stagingMemory);
// Setup buffer copy regions for each mip level
std::vector<VkBufferImageCopy> bufferCopyRegions;
for (uint32_t i = 0; i < mipLevels; i++) {
ktx_size_t offset;
KTX_error_code result = ktxTexture_GetImageOffset(ktxTexture, i, 0, 0, &offset);
assert(result == KTX_SUCCESS);
VkBufferImageCopy bufferCopyRegion{
.bufferOffset = offset,
.imageSubresource = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = i,
.baseArrayLayer = 0,
.layerCount = 1,
},
.imageExtent = {
.width = std::max(1u, ktxTexture->baseWidth >> i),
.height = std::max(1u, ktxTexture->baseHeight >> i),
.depth = 1
}
};
bufferCopyRegions.push_back(bufferCopyRegion);
}
// Create optimal tiled target image
VkImageCreateInfo imageCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = format,
.extent = {.width = width, .height = height, .depth = 1 },
.mipLevels = mipLevels,
.arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = imageUsageFlags,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
};
// Ensure that the TRANSFER_DST bit is set for staging
if (!(imageCreateInfo.usage & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) {
imageCreateInfo.usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
}
VK_CHECK_RESULT(vkCreateImage(device->logicalDevice, &imageCreateInfo, nullptr, &image));
vkGetImageMemoryRequirements(device->logicalDevice, image, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &deviceMemory));
VK_CHECK_RESULT(vkBindImageMemory(device->logicalDevice, image, deviceMemory, 0));
VkImageSubresourceRange subresourceRange{ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .layerCount = 1, };
// Image barrier for optimal image (target)
// Optimal image will be used as destination for the copy
vks::tools::setImageLayout(
copyCmd,
image,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
subresourceRange);
// Copy mip levels from staging buffer
vkCmdCopyBufferToImage(
copyCmd,
stagingBuffer,
image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
static_cast<uint32_t>(bufferCopyRegions.size()),
bufferCopyRegions.data()
);
// Change texture image layout to shader read after all mip levels have been copied
this->imageLayout = imageLayout;
vks::tools::setImageLayout(
copyCmd,
image,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
imageLayout,
subresourceRange);
device->flushCommandBuffer(copyCmd, copyQueue);
// Clean up staging resources
vkDestroyBuffer(device->logicalDevice, stagingBuffer, nullptr);
vkFreeMemory(device->logicalDevice, stagingMemory, nullptr);
ktxTexture_Destroy(ktxTexture);
// Create a default sampler
VkSamplerCreateInfo samplerCreateInfo{
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.mipLodBias = 0.0f,
.anisotropyEnable = device->enabledFeatures.samplerAnisotropy,
.maxAnisotropy = device->enabledFeatures.samplerAnisotropy ? device->properties.limits.maxSamplerAnisotropy : 1.0f,
.compareOp = VK_COMPARE_OP_NEVER,
.minLod = 0.0f,
.maxLod = (float)mipLevels,
.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE
};
VK_CHECK_RESULT(vkCreateSampler(device->logicalDevice, &samplerCreateInfo, nullptr, &sampler));
// Create image view
// Textures are not directly accessed by the shaders and
// are abstracted by image views containing additional
// information and sub resource ranges
VkImageViewCreateInfo viewCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = format,
.subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .baseArrayLayer = 0, .layerCount = 1 },
};
VK_CHECK_RESULT(vkCreateImageView(device->logicalDevice, &viewCreateInfo, nullptr, &view));
// Update descriptor image info member that can be used for setting up descriptor sets
updateDescriptor();
}
/**
* Creates a 2D texture from a buffer
*
* @param buffer Buffer containing texture data to upload
* @param bufferSize Size of the buffer in machine units
* @param width Width of the texture to create
* @param height Height of the texture to create
* @param format Vulkan format of the image data stored in the file
* @param device Vulkan device to create the texture on
* @param copyQueue Queue used for the texture staging copy commands (must support transfer)
* @param (Optional) filter Texture filtering for the sampler (defaults to VK_FILTER_LINEAR)
* @param (Optional) imageUsageFlags Usage flags for the texture's image (defaults to VK_IMAGE_USAGE_SAMPLED_BIT)
* @param (Optional) imageLayout Usage layout for the texture (defaults VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
*/
void Texture2D::fromBuffer(void* buffer, VkDeviceSize bufferSize, VkFormat format, uint32_t texWidth, uint32_t texHeight, vks::VulkanDevice *device, VkQueue copyQueue, VkFilter filter, VkImageUsageFlags imageUsageFlags, VkImageLayout imageLayout)
{
assert(buffer);
this->device = device;
width = texWidth;
height = texHeight;
mipLevels = 1;
VkMemoryAllocateInfo memAllocInfo = vks::initializers::memoryAllocateInfo();
VkMemoryRequirements memReqs;
// Create a host-visible staging buffer that contains the raw image data
VkBuffer stagingBuffer;
VkDeviceMemory stagingMemory;
VkBufferCreateInfo bufferCreateInfo = vks::initializers::bufferCreateInfo();
bufferCreateInfo.size = bufferSize;
// This buffer is used as a transfer source for the buffer copy
bufferCreateInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
bufferCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK_CHECK_RESULT(vkCreateBuffer(device->logicalDevice, &bufferCreateInfo, nullptr, &stagingBuffer));
// Get memory requirements for the staging buffer (alignment, memory type bits)
vkGetBufferMemoryRequirements(device->logicalDevice, stagingBuffer, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
// Get memory type index for a host visible buffer
memAllocInfo.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &stagingMemory));
VK_CHECK_RESULT(vkBindBufferMemory(device->logicalDevice, stagingBuffer, stagingMemory, 0));
// Copy texture data into staging buffer
uint8_t *data{ nullptr };
VK_CHECK_RESULT(vkMapMemory(device->logicalDevice, stagingMemory, 0, memReqs.size, 0, (void **)&data));
memcpy(data, buffer, bufferSize);
vkUnmapMemory(device->logicalDevice, stagingMemory);
VkBufferImageCopy bufferCopyRegion{
.bufferOffset = 0,
.imageSubresource = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = 0,
.baseArrayLayer = 0,
.layerCount = 1
},
.imageExtent = {
.width = width,
.height = height,
.depth = 1,
}
};
// Create optimal tiled target image
VkImageCreateInfo imageCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = format,
.extent = {.width = width, .height = height, .depth = 1 },
.mipLevels = mipLevels,
.arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = imageUsageFlags,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED
};
// Ensure that the TRANSFER_DST bit is set for staging
if (!(imageCreateInfo.usage & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) {
imageCreateInfo.usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
}
VK_CHECK_RESULT(vkCreateImage(device->logicalDevice, &imageCreateInfo, nullptr, &image));
vkGetImageMemoryRequirements(device->logicalDevice, image, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &deviceMemory));
VK_CHECK_RESULT(vkBindImageMemory(device->logicalDevice, image, deviceMemory, 0));
VkImageSubresourceRange subresourceRange{ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .layerCount = 1 };
// Use a separate command buffer for texture loading
VkCommandBuffer copyCmd = device->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Image barrier for optimal image (target)
// Optimal image will be used as destination for the copy
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, subresourceRange);
// Copy mip levels from staging buffer
vkCmdCopyBufferToImage(copyCmd, stagingBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bufferCopyRegion);
// Change texture image layout to shader read after all mip levels have been copied
this->imageLayout = imageLayout;
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, imageLayout, subresourceRange);
device->flushCommandBuffer(copyCmd, copyQueue);
// Clean up staging resources
vkDestroyBuffer(device->logicalDevice, stagingBuffer, nullptr);
vkFreeMemory(device->logicalDevice, stagingMemory, nullptr);
// Create sampler
VkSamplerCreateInfo samplerCreateInfo{
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = filter,
.minFilter = filter,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.mipLodBias = 0.0f,
.maxAnisotropy = 1.0f,
.compareOp = VK_COMPARE_OP_NEVER,
.minLod = 0.0f,
.maxLod = 0.0f,
};
VK_CHECK_RESULT(vkCreateSampler(device->logicalDevice, &samplerCreateInfo, nullptr, &sampler));
// Create image view
VkImageViewCreateInfo viewCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = format,
.subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = 1, .baseArrayLayer = 0, .layerCount = 1 },
};
VK_CHECK_RESULT(vkCreateImageView(device->logicalDevice, &viewCreateInfo, nullptr, &view));
// Update descriptor image info member that can be used for setting up descriptor sets
updateDescriptor();
}
/**
* Load a 2D texture array including all mip levels
*
* @param filename File to load (supports .ktx)
* @param format Vulkan format of the image data stored in the file
* @param device Vulkan device to create the texture on
* @param copyQueue Queue used for the texture staging copy commands (must support transfer)
* @param (Optional) imageUsageFlags Usage flags for the texture's image (defaults to VK_IMAGE_USAGE_SAMPLED_BIT)
* @param (Optional) imageLayout Usage layout for the texture (defaults VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
*
*/
void Texture2DArray::loadFromFile(std::string filename, VkFormat format, vks::VulkanDevice *device, VkQueue copyQueue, VkImageUsageFlags imageUsageFlags, VkImageLayout imageLayout)
{
ktxTexture* ktxTexture;
ktxResult result = loadKTXFile(filename, &ktxTexture);
assert(result == KTX_SUCCESS);
this->device = device;
width = ktxTexture->baseWidth;
height = ktxTexture->baseHeight;
layerCount = ktxTexture->numLayers;
mipLevels = ktxTexture->numLevels;
ktx_uint8_t *ktxTextureData = ktxTexture_GetData(ktxTexture);
ktx_size_t ktxTextureSize = ktxTexture_GetSize(ktxTexture);
// Create a host-visible staging buffer that contains the raw image data
VkBuffer stagingBuffer;
VkDeviceMemory stagingMemory;
VkBufferCreateInfo bufferCreateInfo = vks::initializers::bufferCreateInfo();
bufferCreateInfo.size = ktxTextureSize;
// This buffer is used as a transfer source for the buffer copy
bufferCreateInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
bufferCreateInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK_CHECK_RESULT(vkCreateBuffer(device->logicalDevice, &bufferCreateInfo, nullptr, &stagingBuffer));
// Get memory requirements for the staging buffer (alignment, memory type bits)
VkMemoryRequirements memReqs;
vkGetBufferMemoryRequirements(device->logicalDevice, stagingBuffer, &memReqs);
VkMemoryAllocateInfo memAllocInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &stagingMemory));
VK_CHECK_RESULT(vkBindBufferMemory(device->logicalDevice, stagingBuffer, stagingMemory, 0));
// Copy texture data into staging buffer
uint8_t *data{ nullptr };
VK_CHECK_RESULT(vkMapMemory(device->logicalDevice, stagingMemory, 0, memReqs.size, 0, (void **)&data));
memcpy(data, ktxTextureData, ktxTextureSize);
vkUnmapMemory(device->logicalDevice, stagingMemory);
// Setup buffer copy regions for each layer including all of its miplevels
std::vector<VkBufferImageCopy> bufferCopyRegions;
for (uint32_t layer = 0; layer < layerCount; layer++) {
for (uint32_t level = 0; level < mipLevels; level++) {
ktx_size_t offset;
KTX_error_code result = ktxTexture_GetImageOffset(ktxTexture, level, layer, 0, &offset);
assert(result == KTX_SUCCESS);
VkBufferImageCopy bufferCopyRegion{
.bufferOffset = offset,
.imageSubresource {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = level,
.baseArrayLayer = layer,
.layerCount = 1,
},
.imageExtent {
.width = ktxTexture->baseWidth >> level,
.height = ktxTexture->baseHeight >> level,
.depth = 1,
}
};
bufferCopyRegions.push_back(bufferCopyRegion);
}
}
// Create optimal tiled target image
VkImageCreateInfo imageCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = format,
.extent = {.width = width, .height = height, .depth = 1 },
.mipLevels = mipLevels,
.arrayLayers = layerCount,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = imageUsageFlags,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
};
// Ensure that the TRANSFER_DST bit is set for staging
if (!(imageCreateInfo.usage & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) {
imageCreateInfo.usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
}
VK_CHECK_RESULT(vkCreateImage(device->logicalDevice, &imageCreateInfo, nullptr, &image));
vkGetImageMemoryRequirements(device->logicalDevice, image, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &deviceMemory));
VK_CHECK_RESULT(vkBindImageMemory(device->logicalDevice, image, deviceMemory, 0));
// Use a separate command buffer for texture loading
VkCommandBuffer copyCmd = device->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Image barrier for optimal image (target)
// Set initial layout for all array layers (faces) of the optimal (target) tiled texture
VkImageSubresourceRange subresourceRange{ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .layerCount = layerCount };
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, subresourceRange);
// Copy the layers and mip levels from the staging buffer to the optimal tiled image
vkCmdCopyBufferToImage(copyCmd, stagingBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, static_cast<uint32_t>(bufferCopyRegions.size()), bufferCopyRegions.data());
// Change texture image layout to shader read after all faces have been copied
this->imageLayout = imageLayout;
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, imageLayout, subresourceRange);
device->flushCommandBuffer(copyCmd, copyQueue);
// Create sampler
VkSamplerCreateInfo samplerCreateInfo{
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = samplerCreateInfo.addressModeU,
.addressModeW = samplerCreateInfo.addressModeU,
.mipLodBias = 0.0f,
.anisotropyEnable = device->enabledFeatures.samplerAnisotropy,
.maxAnisotropy = device->enabledFeatures.samplerAnisotropy ? device->properties.limits.maxSamplerAnisotropy : 1.0f,
.compareOp = VK_COMPARE_OP_NEVER,
.minLod = 0.0f,
.maxLod = (float)mipLevels,
.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE,
};
VK_CHECK_RESULT(vkCreateSampler(device->logicalDevice, &samplerCreateInfo, nullptr, &sampler));
// Create image view
VkImageViewCreateInfo viewCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image,
.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY,
.format = format,
.subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .baseArrayLayer = 0, .layerCount = layerCount },
};
VK_CHECK_RESULT(vkCreateImageView(device->logicalDevice, &viewCreateInfo, nullptr, &view));
// Clean up staging resources
ktxTexture_Destroy(ktxTexture);
vkDestroyBuffer(device->logicalDevice, stagingBuffer, nullptr);
vkFreeMemory(device->logicalDevice, stagingMemory, nullptr);
// Update descriptor image info member that can be used for setting up descriptor sets
updateDescriptor();
}
/**
* Load a cubemap texture including all mip levels from a single file
*
* @param filename File to load (supports .ktx)
* @param format Vulkan format of the image data stored in the file
* @param device Vulkan device to create the texture on
* @param copyQueue Queue used for the texture staging copy commands (must support transfer)
* @param (Optional) imageUsageFlags Usage flags for the texture's image (defaults to VK_IMAGE_USAGE_SAMPLED_BIT)
* @param (Optional) imageLayout Usage layout for the texture (defaults VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
*
*/
void TextureCubeMap::loadFromFile(std::string filename, VkFormat format, vks::VulkanDevice *device, VkQueue copyQueue, VkImageUsageFlags imageUsageFlags, VkImageLayout imageLayout)
{
ktxTexture* ktxTexture;
ktxResult result = loadKTXFile(filename, &ktxTexture);
assert(result == KTX_SUCCESS);
this->device = device;
width = ktxTexture->baseWidth;
height = ktxTexture->baseHeight;
mipLevels = ktxTexture->numLevels;
ktx_uint8_t *ktxTextureData = ktxTexture_GetData(ktxTexture);
ktx_size_t ktxTextureSize = ktxTexture_GetSize(ktxTexture);
// Create a host-visible staging buffer that contains the raw image data
VkBuffer stagingBuffer;
VkDeviceMemory stagingMemory;
VkBufferCreateInfo bufferCreateInfo{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = ktxTextureSize,
.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
};
VK_CHECK_RESULT(vkCreateBuffer(device->logicalDevice, &bufferCreateInfo, nullptr, &stagingBuffer));
// Get memory requirements for the staging buffer (alignment, memory type bits)
VkMemoryRequirements memReqs;
vkGetBufferMemoryRequirements(device->logicalDevice, stagingBuffer, &memReqs);
VkMemoryAllocateInfo memAllocInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &stagingMemory));
VK_CHECK_RESULT(vkBindBufferMemory(device->logicalDevice, stagingBuffer, stagingMemory, 0));
// Copy texture data into staging buffer
uint8_t *data{ nullptr };
VK_CHECK_RESULT(vkMapMemory(device->logicalDevice, stagingMemory, 0, memReqs.size, 0, (void **)&data));
memcpy(data, ktxTextureData, ktxTextureSize);
vkUnmapMemory(device->logicalDevice, stagingMemory);
// Setup buffer copy regions for each face including all of its mip levels
std::vector<VkBufferImageCopy> bufferCopyRegions;
for (uint32_t face = 0; face < 6; face++) {
for (uint32_t level = 0; level < mipLevels; level++) {
ktx_size_t offset;
KTX_error_code result = ktxTexture_GetImageOffset(ktxTexture, level, 0, face, &offset);
assert(result == KTX_SUCCESS);
VkBufferImageCopy bufferCopyRegion{
.bufferOffset = offset,
.imageSubresource = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = level,
.baseArrayLayer = face,
.layerCount = 1
},
.imageExtent = {
.width = ktxTexture->baseWidth >> level,
.height = ktxTexture->baseHeight >> level,
.depth = 1
},
};
bufferCopyRegions.push_back(bufferCopyRegion);
}
}
// Create optimal tiled target image
VkImageCreateInfo imageCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
// This flag is required for cube map images
.flags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT,
.imageType = VK_IMAGE_TYPE_2D,
.format = format,
.extent = { .width = width, .height = height, .depth = 1 },
.mipLevels = mipLevels,
// Cube faces count as array layers in Vulkan
.arrayLayers = 6,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = imageUsageFlags,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
};
// Ensure that the TRANSFER_DST bit is set for staging
if (!(imageCreateInfo.usage & VK_IMAGE_USAGE_TRANSFER_DST_BIT)){
imageCreateInfo.usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
}
VK_CHECK_RESULT(vkCreateImage(device->logicalDevice, &imageCreateInfo, nullptr, &image));
vkGetImageMemoryRequirements(device->logicalDevice, image, &memReqs);
memAllocInfo.allocationSize = memReqs.size;
memAllocInfo.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &deviceMemory));
VK_CHECK_RESULT(vkBindImageMemory(device->logicalDevice, image, deviceMemory, 0));
// Use a separate command buffer for texture loading
VkCommandBuffer copyCmd = device->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Image barrier for optimal image (target)
// Set initial layout for all array layers (faces) of the optimal (target) tiled texture
VkImageSubresourceRange subresourceRange{ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .layerCount = 6 };
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, subresourceRange);
// Copy the cube map faces from the staging buffer to the optimal tiled image
vkCmdCopyBufferToImage(copyCmd, stagingBuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, static_cast<uint32_t>(bufferCopyRegions.size()), bufferCopyRegions.data());
// Change texture image layout to shader read after all faces have been copied
this->imageLayout = imageLayout;
vks::tools::setImageLayout(copyCmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, imageLayout, subresourceRange);
device->flushCommandBuffer(copyCmd, copyQueue);
// Create sampler
VkSamplerCreateInfo samplerCreateInfo{
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = samplerCreateInfo.addressModeU,
.addressModeW = samplerCreateInfo.addressModeU,
.mipLodBias = 0.0f,
.anisotropyEnable = device->enabledFeatures.samplerAnisotropy,
.maxAnisotropy = device->enabledFeatures.samplerAnisotropy ? device->properties.limits.maxSamplerAnisotropy : 1.0f,
.compareOp = VK_COMPARE_OP_NEVER,
.minLod = 0.0f,
.maxLod = (float)mipLevels,
.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE
};
VK_CHECK_RESULT(vkCreateSampler(device->logicalDevice, &samplerCreateInfo, nullptr, &sampler));
// Create image view
VkImageViewCreateInfo viewCreateInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image,
.viewType = VK_IMAGE_VIEW_TYPE_CUBE,
.format = format,
.subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = mipLevels, .baseArrayLayer = 0, .layerCount = 6 },
};
VK_CHECK_RESULT(vkCreateImageView(device->logicalDevice, &viewCreateInfo, nullptr, &view));
// Clean up staging resources
ktxTexture_Destroy(ktxTexture);
vkDestroyBuffer(device->logicalDevice, stagingBuffer, nullptr);
vkFreeMemory(device->logicalDevice, stagingMemory, nullptr);
// Update descriptor image info member that can be used for setting up descriptor sets
updateDescriptor();
}
}
@@ -0,0 +1,97 @@
/*
* Vulkan texture loader for KTX files
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license(MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <fstream>
#include <stdlib.h>
#include <string>
#include <vector>
#include "vulkan/vulkan.h"
#include <ktx.h>
#include <ktxvulkan.h>
#include "VulkanBuffer.h"
#include "VulkanDevice.h"
#include "VulkanTools.h"
#if defined(__ANDROID__)
# include <android/asset_manager.h>
#endif
namespace vks
{
class Texture
{
public:
vks::VulkanDevice * device;
VkImage image;
VkImageLayout imageLayout;
VkDeviceMemory deviceMemory;
VkImageView view;
uint32_t width, height;
uint32_t mipLevels;
uint32_t layerCount;
VkDescriptorImageInfo descriptor;
VkSampler sampler;
VkFormat format;
void updateDescriptor();
void destroy();
ktxResult loadKTXFile(std::string filename, ktxTexture **target);
};
class Texture2D : public Texture
{
public:
void loadFromFile(
std::string filename,
VkFormat format,
vks::VulkanDevice *device,
VkQueue copyQueue,
VkImageUsageFlags imageUsageFlags = VK_IMAGE_USAGE_SAMPLED_BIT,
VkImageLayout imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
void fromBuffer(
void * buffer,
VkDeviceSize bufferSize,
VkFormat format,
uint32_t texWidth,
uint32_t texHeight,
vks::VulkanDevice *device,
VkQueue copyQueue,
VkFilter filter = VK_FILTER_LINEAR,
VkImageUsageFlags imageUsageFlags = VK_IMAGE_USAGE_SAMPLED_BIT,
VkImageLayout imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
};
class Texture2DArray : public Texture
{
public:
void loadFromFile(
std::string filename,
VkFormat format,
vks::VulkanDevice *device,
VkQueue copyQueue,
VkImageUsageFlags imageUsageFlags = VK_IMAGE_USAGE_SAMPLED_BIT,
VkImageLayout imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
};
class TextureCubeMap : public Texture
{
public:
void loadFromFile(
std::string filename,
VkFormat format,
vks::VulkanDevice *device,
VkQueue copyQueue,
VkImageUsageFlags imageUsageFlags = VK_IMAGE_USAGE_SAMPLED_BIT,
VkImageLayout imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
};
} // namespace vks
@@ -0,0 +1,502 @@
/*
* Assorted commonly used Vulkan helper functions
*
* Copyright (C) 2016-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanTools.h"
#if !(defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
// iOS & macOS: getAssetPath() and getShaderBasePath() implemented externally for access to Obj-C++ path utilities
const std::string getAssetPath()
{
if (vks::tools::resourcePath != "") {
return vks::tools::resourcePath + "/assets/";
}
#if defined(VK_USE_PLATFORM_ANDROID_KHR) || defined (VK_USE_PLATFORM_OHOS)
return "";
#elif defined(VK_EXAMPLE_ASSETS_DIR)
return VK_EXAMPLE_ASSETS_DIR;
#else
return "./../assets/";
#endif
}
const std::string getShaderBasePath()
{
if (vks::tools::resourcePath != "") {
return vks::tools::resourcePath + "/shaders/";
}
#if defined(VK_USE_PLATFORM_ANDROID_KHR) || defined (VK_USE_PLATFORM_OHOS)
return "shaders/";
#elif defined(VK_EXAMPLE_SHADERS_DIR)
return VK_EXAMPLE_SHADERS_DIR;
#else
return "./../shaders/";
#endif
}
#endif
namespace vks
{
namespace tools
{
bool errorModeSilent = false;
std::string resourcePath = "";
std::string errorString(VkResult errorCode)
{
switch (errorCode)
{
#define STR(r) case VK_ ##r: return #r
STR(NOT_READY);
STR(TIMEOUT);
STR(EVENT_SET);
STR(EVENT_RESET);
STR(INCOMPLETE);
STR(ERROR_OUT_OF_HOST_MEMORY);
STR(ERROR_OUT_OF_DEVICE_MEMORY);
STR(ERROR_INITIALIZATION_FAILED);
STR(ERROR_DEVICE_LOST);
STR(ERROR_MEMORY_MAP_FAILED);
STR(ERROR_LAYER_NOT_PRESENT);
STR(ERROR_EXTENSION_NOT_PRESENT);
STR(ERROR_FEATURE_NOT_PRESENT);
STR(ERROR_INCOMPATIBLE_DRIVER);
STR(ERROR_TOO_MANY_OBJECTS);
STR(ERROR_FORMAT_NOT_SUPPORTED);
STR(ERROR_SURFACE_LOST_KHR);
STR(ERROR_NATIVE_WINDOW_IN_USE_KHR);
STR(SUBOPTIMAL_KHR);
STR(ERROR_OUT_OF_DATE_KHR);
STR(ERROR_INCOMPATIBLE_DISPLAY_KHR);
STR(ERROR_VALIDATION_FAILED_EXT);
STR(ERROR_INVALID_SHADER_NV);
STR(ERROR_INCOMPATIBLE_SHADER_BINARY_EXT);
#undef STR
default:
return "UNKNOWN_ERROR";
}
}
std::string physicalDeviceTypeString(VkPhysicalDeviceType type)
{
switch (type)
{
#define STR(r) case VK_PHYSICAL_DEVICE_TYPE_ ##r: return #r
STR(OTHER);
STR(INTEGRATED_GPU);
STR(DISCRETE_GPU);
STR(VIRTUAL_GPU);
STR(CPU);
#undef STR
default: return "UNKNOWN_DEVICE_TYPE";
}
}
VkBool32 getSupportedDepthFormat(VkPhysicalDevice physicalDevice, VkFormat *depthFormat)
{
// Since all depth formats may be optional, we need to find a suitable depth format to use
// Start with the highest precision packed format
std::vector<VkFormat> formatList = {
VK_FORMAT_D32_SFLOAT_S8_UINT,
VK_FORMAT_D32_SFLOAT,
VK_FORMAT_D24_UNORM_S8_UINT,
VK_FORMAT_D16_UNORM_S8_UINT,
VK_FORMAT_D16_UNORM
};
for (auto& format : formatList)
{
VkFormatProperties formatProps;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProps);
if (formatProps.optimalTilingFeatures & VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT)
{
*depthFormat = format;
return true;
}
}
return false;
}
VkBool32 getSupportedDepthStencilFormat(VkPhysicalDevice physicalDevice, VkFormat* depthStencilFormat)
{
std::vector<VkFormat> formatList = {
VK_FORMAT_D32_SFLOAT_S8_UINT,
VK_FORMAT_D24_UNORM_S8_UINT,
VK_FORMAT_D16_UNORM_S8_UINT,
};
for (auto& format : formatList)
{
VkFormatProperties formatProps;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProps);
if (formatProps.optimalTilingFeatures & VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT)
{
*depthStencilFormat = format;
return true;
}
}
return false;
}
VkBool32 formatHasStencil(VkFormat format)
{
std::vector<VkFormat> stencilFormats = {
VK_FORMAT_S8_UINT,
VK_FORMAT_D16_UNORM_S8_UINT,
VK_FORMAT_D24_UNORM_S8_UINT,
VK_FORMAT_D32_SFLOAT_S8_UINT,
};
return std::find(stencilFormats.begin(), stencilFormats.end(), format) != std::end(stencilFormats);
}
// Returns if a given format support LINEAR filtering
VkBool32 formatIsFilterable(VkPhysicalDevice physicalDevice, VkFormat format, VkImageTiling tiling)
{
VkFormatProperties formatProps;
vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &formatProps);
if (tiling == VK_IMAGE_TILING_OPTIMAL)
return formatProps.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
if (tiling == VK_IMAGE_TILING_LINEAR)
return formatProps.linearTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
return false;
}
// Create an image memory barrier for changing the layout of
// an image and put it into an active command buffer
// See chapter 11.4 "Image Layout" for details
void setImageLayout(
VkCommandBuffer cmdbuffer,
VkImage image,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkImageSubresourceRange subresourceRange,
VkPipelineStageFlags srcStageMask,
VkPipelineStageFlags dstStageMask)
{
// Create an image barrier object
VkImageMemoryBarrier imageMemoryBarrier = vks::initializers::imageMemoryBarrier();
imageMemoryBarrier.oldLayout = oldImageLayout;
imageMemoryBarrier.newLayout = newImageLayout;
imageMemoryBarrier.image = image;
imageMemoryBarrier.subresourceRange = subresourceRange;
// Source layouts (old)
// Source access mask controls actions that have to be finished on the old layout
// before it will be transitioned to the new layout
switch (oldImageLayout)
{
case VK_IMAGE_LAYOUT_UNDEFINED:
// Image layout is undefined (or does not matter)
// Only valid as initial layout
// No flags required, listed only for completeness
imageMemoryBarrier.srcAccessMask = 0;
break;
case VK_IMAGE_LAYOUT_PREINITIALIZED:
// Image is preinitialized
// Only valid as initial layout for linear images, preserves memory contents
// Make sure host writes have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
// Image is a color attachment
// Make sure any writes to the color buffer have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL:
// Image is a depth/stencil attachment
// Make sure any writes to the depth/stencil buffer have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL:
// Image is a transfer source
// Make sure any reads from the image have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
break;
case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:
// Image is a transfer destination
// Make sure any writes to the image have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:
// Image is read by a shader
// Make sure any shader reads from the image have been finished
imageMemoryBarrier.srcAccessMask = VK_ACCESS_SHADER_READ_BIT;
break;
default:
// Other source layouts aren't handled (yet)
break;
}
// Target layouts (new)
// Destination access mask controls the dependency for the new image layout
switch (newImageLayout)
{
case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:
// Image will be used as a transfer destination
// Make sure any writes to the image have been finished
imageMemoryBarrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL:
// Image will be used as a transfer source
// Make sure any reads from the image have been finished
imageMemoryBarrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
break;
case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:
// Image will be used as a color attachment
// Make sure any writes to the color buffer have been finished
imageMemoryBarrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL:
// Image layout will be used as a depth/stencil attachment
// Make sure any writes to depth/stencil buffer have been finished
imageMemoryBarrier.dstAccessMask = imageMemoryBarrier.dstAccessMask | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
break;
case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:
// Image will be read in a shader (sampler, input attachment)
// Make sure any writes to the image have been finished
if (imageMemoryBarrier.srcAccessMask == 0)
{
imageMemoryBarrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
}
imageMemoryBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
break;
default:
// Other source layouts aren't handled (yet)
break;
}
// Put barrier inside setup command buffer
vkCmdPipelineBarrier(
cmdbuffer,
srcStageMask,
dstStageMask,
0,
0, nullptr,
0, nullptr,
1, &imageMemoryBarrier);
}
// Fixed sub resource on first mip level and layer
void setImageLayout(
VkCommandBuffer cmdbuffer,
VkImage image,
VkImageAspectFlags aspectMask,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkPipelineStageFlags srcStageMask,
VkPipelineStageFlags dstStageMask)
{
VkImageSubresourceRange subresourceRange = {};
subresourceRange.aspectMask = aspectMask;
subresourceRange.baseMipLevel = 0;
subresourceRange.levelCount = 1;
subresourceRange.layerCount = 1;
setImageLayout(cmdbuffer, image, oldImageLayout, newImageLayout, subresourceRange, srcStageMask, dstStageMask);
}
void insertImageMemoryBarrier(
VkCommandBuffer cmdbuffer,
VkImage image,
VkAccessFlags srcAccessMask,
VkAccessFlags dstAccessMask,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkPipelineStageFlags srcStageMask,
VkPipelineStageFlags dstStageMask,
VkImageSubresourceRange subresourceRange)
{
VkImageMemoryBarrier imageMemoryBarrier = vks::initializers::imageMemoryBarrier();
imageMemoryBarrier.srcAccessMask = srcAccessMask;
imageMemoryBarrier.dstAccessMask = dstAccessMask;
imageMemoryBarrier.oldLayout = oldImageLayout;
imageMemoryBarrier.newLayout = newImageLayout;
imageMemoryBarrier.image = image;
imageMemoryBarrier.subresourceRange = subresourceRange;
vkCmdPipelineBarrier(
cmdbuffer,
srcStageMask,
dstStageMask,
0,
0, nullptr,
0, nullptr,
1, &imageMemoryBarrier);
}
void exitFatal(const std::string& message, int32_t exitCode)
{
#if defined(_WIN32)
if (!errorModeSilent) {
MessageBox(NULL, message.c_str(), NULL, MB_OK | MB_ICONERROR);
}
#elif defined(__ANDROID__)
LOGE("Fatal error: %s", message.c_str());
vks::android::showAlert(message.c_str());
#elif defined (__OHOS__)
LOGE("Fatal error: %{public}s", message.c_str());
#endif
std::cerr << message << "\n";
#if defined(VK_EXAMPLE_PS5)
// A title never calls exit (the console reports it as a crash): the launcher catches this, ends the sample and reports it
throw std::runtime_error(message);
#elif !defined(__ANDROID__) && !defined(__OHOS__)
exit(exitCode);
#endif
}
void exitFatal(const std::string& message, VkResult resultCode)
{
exitFatal(message, (int32_t)resultCode);
}
#if defined(__ANDROID__)
// Android shaders are stored as assets in the apk
// So they need to be loaded via the asset manager
VkShaderModule loadShader(AAssetManager* assetManager, const char *fileName, VkDevice device)
{
// Load shader from compressed asset
AAsset* asset = AAssetManager_open(assetManager, fileName, AASSET_MODE_STREAMING);
assert(asset);
size_t size = AAsset_getLength(asset);
assert(size > 0);
char *shaderCode = new char[size];
AAsset_read(asset, shaderCode, size);
AAsset_close(asset);
VkShaderModule shaderModule;
VkShaderModuleCreateInfo moduleCreateInfo{};
moduleCreateInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleCreateInfo.pNext = NULL;
moduleCreateInfo.codeSize = size;
moduleCreateInfo.pCode = (uint32_t*)shaderCode;
moduleCreateInfo.flags = 0;
VK_CHECK_RESULT(vkCreateShaderModule(device, &moduleCreateInfo, NULL, &shaderModule));
delete[] shaderCode;
return shaderModule;
}
#elif defined (__OHOS__)
/**
* Load shader from HarmonyOS raw resources using ResourceManager singleton
* This is the preferred method - no need to pass NativeResourceManager explicitly
*/
VkShaderModule loadShader(const char *fileName, VkDevice device) {
NativeResourceManager* resourceMgr = ResourceManager::getInstance().getNativeResourceManager();
if (!resourceMgr) {
std::string msg = "ResourceManager not initialized. Call ResourceManager::getInstance().initialize() first.";
// vks::tools::exitFatal(msg, -1);
}
RawFile *rawfile = OH_ResourceManager_OpenRawFile(resourceMgr, fileName);
if (!rawfile) {
std::string msg = "Could not load shader from " + std::string(fileName);
vks::tools::exitFatal(msg, -1);
}
size_t size = OH_ResourceManager_GetRawFileSize(rawfile);
assert(size > 0);
char *shaderCode = new char[size];
OH_ResourceManager_ReadRawFile(rawfile, shaderCode, size);
OH_ResourceManager_CloseRawFile(rawfile);
VkShaderModule shaderModule;
VkShaderModuleCreateInfo moduleCreateInfo;
moduleCreateInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleCreateInfo.pNext = NULL;
moduleCreateInfo.codeSize = size;
moduleCreateInfo.pCode = (uint32_t *)shaderCode;
moduleCreateInfo.flags = 0;
VK_CHECK_RESULT(vkCreateShaderModule(device, &moduleCreateInfo, NULL, &shaderModule));
delete[] shaderCode;
return shaderModule;
}
#else
VkShaderModule loadShader(const char *fileName, VkDevice device)
{
std::ifstream is(fileName, std::ios::binary | std::ios::in | std::ios::ate);
if (is.is_open())
{
size_t size = is.tellg();
is.seekg(0, std::ios::beg);
char* shaderCode = new char[size];
is.read(shaderCode, size);
is.close();
assert(size > 0);
VkShaderModule shaderModule;
VkShaderModuleCreateInfo moduleCreateInfo{};
moduleCreateInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleCreateInfo.codeSize = size;
moduleCreateInfo.pCode = (uint32_t*)shaderCode;
VK_CHECK_RESULT(vkCreateShaderModule(device, &moduleCreateInfo, NULL, &shaderModule));
delete[] shaderCode;
return shaderModule;
}
else
{
std::cerr << "Error: Could not open shader file \"" << fileName << "\"" << "\n";
#if defined(VK_EXAMPLE_PS5)
// A null module would reach the driver: end the sample instead
exitFatal(std::string("Could not open shader file ") + fileName, -1);
#endif
return VK_NULL_HANDLE;
}
}
#endif
bool fileExists(const std::string &filename)
{
std::ifstream f(filename.c_str());
return !f.fail();
}
uint32_t alignedSize(uint32_t value, uint32_t alignment)
{
return (value + alignment - 1) & ~(alignment - 1);
}
size_t alignedSize(size_t value, size_t alignment)
{
return (value + alignment - 1) & ~(alignment - 1);
}
VkDeviceSize alignedVkSize(VkDeviceSize value, VkDeviceSize alignment)
{
return (value + alignment - 1) & ~(alignment - 1);
}
}
}
+158
View File
@@ -0,0 +1,158 @@
/*
* Assorted Vulkan helper functions
*
* Copyright (C) 2016-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include "vulkan/vulkan.h"
#include "VulkanInitializers.hpp"
#include <math.h>
#include <stdlib.h>
#include <string>
#include <cstring>
#include <fstream>
#include <format>
#include <assert.h>
#include <stdio.h>
#include <vector>
#include <iostream>
#include <stdexcept>
#include <fstream>
#include <algorithm>
#if defined(_WIN32)
#include <windows.h>
#include <fcntl.h>
#include <io.h>
#elif defined(__ANDROID__)
#include "VulkanAndroid.h"
#include <android/asset_manager.h>
#elif defined(__OHOS__)
#include "VulkanOHOS.h"
#endif
// Custom define for better code readability
#define VK_FLAGS_NONE 0
// Default fence timeout in nanoseconds
#define DEFAULT_FENCE_TIMEOUT 100000000000
// Macro to check and display Vulkan return results
#if defined(__ANDROID__)
#define VK_CHECK_RESULT(f) \
{ \
VkResult res = (f); \
if (res != VK_SUCCESS) \
{ \
std::string message = std::format("Fatal : VkResult is {} in {} at line {}", vks::tools::errorString(res), __FILE__, __LINE__); \
LOGE("%s", message.c_str()); \
vks::tools::exitFatal(message, -1); \
} \
}
#elif defined (__OHOS__)
#define VK_CHECK_RESULT(f) \
{ \
VkResult res = (f); \
if (res != VK_SUCCESS) \
{ \
LOGE("Fatal : VkResult is \" %s \" in %s at line %d", \
vks::tools::errorString(res).c_str(), __FILE__, __LINE__); \
assert(res == VK_SUCCESS); \
} \
}
#else
#define VK_CHECK_RESULT(f) \
{ \
VkResult res = (f); \
if (res != VK_SUCCESS) \
{ \
std::string message = std::format("Fatal : VkResult is {} in {} at line {}", vks::tools::errorString(res), __FILE__, __LINE__); \
vks::tools::exitFatal(message, -1); \
} \
}
#endif
const std::string getAssetPath();
const std::string getShaderBasePath();
namespace vks
{
namespace tools
{
/** @brief Setting this path chnanges the place where the samples looks for assets and shaders */
extern std::string resourcePath;
/** @brief Disable message boxes on fatal errors */
extern bool errorModeSilent;
/** @brief Returns an error code as a string */
std::string errorString(VkResult errorCode);
/** @brief Returns the device type as a string */
std::string physicalDeviceTypeString(VkPhysicalDeviceType type);
// Selected a suitable supported depth format starting with 32 bit down to 16 bit
// Returns false if none of the depth formats in the list is supported by the device
VkBool32 getSupportedDepthFormat(VkPhysicalDevice physicalDevice, VkFormat *depthFormat);
// Same as getSupportedDepthFormat but will only select formats that also have stencil
VkBool32 getSupportedDepthStencilFormat(VkPhysicalDevice physicalDevice, VkFormat* depthStencilFormat);
// Returns true a given format support LINEAR filtering
VkBool32 formatIsFilterable(VkPhysicalDevice physicalDevice, VkFormat format, VkImageTiling tiling);
// Returns true if a given format has a stencil part
VkBool32 formatHasStencil(VkFormat format);
// Put an image memory barrier for setting an image layout on the sub resource into the given command buffer
void setImageLayout(
VkCommandBuffer cmdbuffer,
VkImage image,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkImageSubresourceRange subresourceRange,
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT);
// Uses a fixed sub resource layout with first mip level and layer
void setImageLayout(
VkCommandBuffer cmdbuffer,
VkImage image,
VkImageAspectFlags aspectMask,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkPipelineStageFlags srcStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VkPipelineStageFlags dstStageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT);
/** @brief Insert an image memory barrier into the command buffer */
void insertImageMemoryBarrier(
VkCommandBuffer cmdbuffer,
VkImage image,
VkAccessFlags srcAccessMask,
VkAccessFlags dstAccessMask,
VkImageLayout oldImageLayout,
VkImageLayout newImageLayout,
VkPipelineStageFlags srcStageMask,
VkPipelineStageFlags dstStageMask,
VkImageSubresourceRange subresourceRange);
// Display error message and exit on fatal error
void exitFatal(const std::string& message, int32_t exitCode);
void exitFatal(const std::string& message, VkResult resultCode);
// Load a SPIR-V shader (binary)
#if defined(__ANDROID__)
VkShaderModule loadShader(AAssetManager* assetManager, const char *fileName, VkDevice device);
#elif defined (__OHOS__)
VkShaderModule loadShader(const char *fileName, VkDevice device);
#else
VkShaderModule loadShader(const char *fileName, VkDevice device);
#endif
/** @brief Checks if a file exists */
bool fileExists(const std::string &filename);
uint32_t alignedSize(uint32_t value, uint32_t alignment);
VkDeviceSize alignedVkSize(VkDeviceSize value, VkDeviceSize alignment);
}
}
@@ -0,0 +1,514 @@
/*
* UI overlay class using ImGui
*
* Copyright (C) 2017-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include "VulkanUIOverlay.h"
namespace vks
{
UIOverlay::UIOverlay()
{
#if defined(__ANDROID__)
if (vks::android::screenDensity >= ACONFIGURATION_DENSITY_XXHIGH) {
scale = 3.5f;
}
else if (vks::android::screenDensity >= ACONFIGURATION_DENSITY_XHIGH) {
scale = 2.5f;
}
else if (vks::android::screenDensity >= ACONFIGURATION_DENSITY_HIGH) {
scale = 2.0f;
};
#endif
#if defined (__OHOS__)
if (vks::OHOS::screenDensity >= 480) {
scale = 3.5f;
} else if (vks::OHOS::screenDensity >= 320) {
scale = 2.5f;
} else if (vks::OHOS::screenDensity >= 240) {
scale = 2.0f;
}
#endif
// Init ImGui
ImGui::CreateContext();
// Color scheme
ImGuiStyle& style = ImGui::GetStyle();
style.Colors[ImGuiCol_TitleBg] = ImVec4(1.0f, 0.0f, 0.0f, 1.0f);
style.Colors[ImGuiCol_TitleBgActive] = ImVec4(1.0f, 0.0f, 0.0f, 1.0f);
style.Colors[ImGuiCol_TitleBgCollapsed] = ImVec4(1.0f, 0.0f, 0.0f, 0.1f);
style.Colors[ImGuiCol_MenuBarBg] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_Header] = ImVec4(0.8f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_HeaderActive] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_HeaderHovered] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_FrameBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.8f);
style.Colors[ImGuiCol_CheckMark] = ImVec4(1.0f, 0.0f, 0.0f, 0.8f);
style.Colors[ImGuiCol_SliderGrab] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_SliderGrabActive] = ImVec4(1.0f, 0.0f, 0.0f, 0.8f);
style.Colors[ImGuiCol_FrameBgHovered] = ImVec4(1.0f, 1.0f, 1.0f, 0.1f);
style.Colors[ImGuiCol_FrameBgActive] = ImVec4(1.0f, 1.0f, 1.0f, 0.2f);
style.Colors[ImGuiCol_Button] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
style.Colors[ImGuiCol_ButtonHovered] = ImVec4(1.0f, 0.0f, 0.0f, 0.6f);
style.Colors[ImGuiCol_ButtonActive] = ImVec4(1.0f, 0.0f, 0.0f, 0.8f);
#if defined(VK_EXAMPLE_PS5)
// PS5: in the title's theme, when it has the UI module
ps5StyleOverlay(style);
#endif
// Dimensions
ImGuiIO& io = ImGui::GetIO();
io.FontGlobalScale = scale;
}
UIOverlay::~UIOverlay() {
if (ImGui::GetCurrentContext()) {
ImGui::DestroyContext();
}
}
/** Prepare all vulkan resources required to render the UI overlay */
void UIOverlay::prepare()
{
assert(maxConcurrentFrames > 0);
ImGuiIO& io = ImGui::GetIO();
// Create font texture
unsigned char* fontData;
int texWidth, texHeight;
#if defined(__ANDROID__)
float scale = (float)vks::android::screenDensity / (float)ACONFIGURATION_DENSITY_MEDIUM;
AAsset* asset = AAssetManager_open(androidApp->activity->assetManager, "Roboto-Medium.ttf", AASSET_MODE_STREAMING);
if (asset) {
size_t size = AAsset_getLength(asset);
assert(size > 0);
char *fontAsset = new char[size];
AAsset_read(asset, fontAsset, size);
AAsset_close(asset);
io.Fonts->AddFontFromMemoryTTF(fontAsset, size, 14.0f * scale);
delete[] fontAsset;
}
#elif defined (__OHOS__)
float scale = (float)vks::OHOS::screenDensity / 160.0f;
NativeResourceManager* resourceMgr = ResourceManager::getInstance().getNativeResourceManager();
if (!resourceMgr) {
std::string msg = "ResourceManager not initialized. Call ResourceManager::getInstance().initialize() first.";
// vks::tools::exitFatal(msg, -1);
}
RawFile *rawfile = OH_ResourceManager_OpenRawFile(resourceMgr, "Roboto-Medium.ttf");
if (rawfile) {
size_t size = OH_ResourceManager_GetRawFileSize(rawfile);
assert(size > 0);
char *fontAsset = new char[size];
OH_ResourceManager_ReadRawFile(rawfile, fontAsset, size);
OH_ResourceManager_CloseRawFile(rawfile);
io.Fonts->AddFontFromMemoryTTF(fontAsset, size, 14.0f * scale);
delete[] fontAsset;
}
#else
std::string filename = getAssetPath() + "Roboto-Medium.ttf";
float fontSize = 16.0f;
#if defined(VK_EXAMPLE_PS5)
// PS5: the theme's typeface, when it has one
ps5OverlayFont(filename, fontSize);
#endif
io.Fonts->AddFontFromFileTTF(filename.c_str(), fontSize * scale);
#endif
io.Fonts->GetTexDataAsRGBA32(&fontData, &texWidth, &texHeight);
VkDeviceSize uploadSize = texWidth*texHeight * 4 * sizeof(char);
// Set ImGui style scale factor to handle retina and other HiDPI displays (same as font scaling above)
ImGuiStyle& style = ImGui::GetStyle();
style.ScaleAllSizes(scale);
// Create target image for copy
VkImageCreateInfo imageInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = VK_FORMAT_R8G8B8A8_UNORM,
.extent = {.width = (uint32_t)texWidth, .height = (uint32_t)texHeight, .depth = 1 },
.mipLevels = 1,
.arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED
};
VK_CHECK_RESULT(vkCreateImage(device->logicalDevice, &imageInfo, nullptr, &fontImage));
VkMemoryRequirements memReqs;
vkGetImageMemoryRequirements(device->logicalDevice, fontImage, &memReqs);
VkMemoryAllocateInfo memAllocInfo{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
.memoryTypeIndex = device->getMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
};
VK_CHECK_RESULT(vkAllocateMemory(device->logicalDevice, &memAllocInfo, nullptr, &fontMemory));
VK_CHECK_RESULT(vkBindImageMemory(device->logicalDevice, fontImage, fontMemory, 0));
// Image view
VkImageViewCreateInfo viewInfo{
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = fontImage,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = VK_FORMAT_R8G8B8A8_UNORM,
.subresourceRange = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .levelCount = 1, .layerCount = 1 }
};
VK_CHECK_RESULT(vkCreateImageView(device->logicalDevice, &viewInfo, nullptr, &fontView));
// Staging buffers for font data upload
vks::Buffer stagingBuffer;
VK_CHECK_RESULT(device->createBuffer(VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, &stagingBuffer, uploadSize));
stagingBuffer.map();
memcpy(stagingBuffer.mapped, fontData, uploadSize);
// Copy buffer data to font image
VkCommandBuffer copyCmd = device->createCommandBuffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
// Prepare for transfer
vks::tools::setImageLayout(
copyCmd,
fontImage,
VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_UNDEFINED,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_PIPELINE_STAGE_HOST_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT);
// Copy
VkBufferImageCopy bufferCopyRegion{
.imageSubresource = {.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .layerCount = 1 },
.imageExtent = {.width = (uint32_t)texWidth, .height = (uint32_t)texHeight, .depth = 1 }
};
vkCmdCopyBufferToImage(copyCmd, stagingBuffer.buffer, fontImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bufferCopyRegion);
// Prepare for shader read
vks::tools::setImageLayout(
copyCmd,
fontImage,
VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
device->flushCommandBuffer(copyCmd, queue, true);
stagingBuffer.destroy();
// Font texture Sampler
VkSamplerCreateInfo samplerInfo{
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.maxAnisotropy = 1.0f,
.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE,
};
VK_CHECK_RESULT(vkCreateSampler(device->logicalDevice, &samplerInfo, nullptr, &sampler));
// Descriptor pool
VkDescriptorPoolSize poolSize{ .type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1 };
VkDescriptorPoolCreateInfo descriptorPoolInfo{ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, .maxSets = 2, .poolSizeCount = 1, .pPoolSizes = &poolSize };
VK_CHECK_RESULT(vkCreateDescriptorPool(device->logicalDevice, &descriptorPoolInfo, nullptr, &descriptorPool));
// Descriptor set layout
VkDescriptorSetLayoutBinding setLayoutBinding = vks::initializers::descriptorSetLayoutBinding(VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT, 0);
VkDescriptorSetLayoutCreateInfo descriptorLayout{ .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, .bindingCount = 1, .pBindings = &setLayoutBinding };
VK_CHECK_RESULT(vkCreateDescriptorSetLayout(device->logicalDevice, &descriptorLayout, nullptr, &descriptorSetLayout));
// Descriptor set
VkDescriptorSetAllocateInfo allocInfo = vks::initializers::descriptorSetAllocateInfo(descriptorPool, &descriptorSetLayout, 1);
VK_CHECK_RESULT(vkAllocateDescriptorSets(device->logicalDevice, &allocInfo, &descriptorSet));
VkDescriptorImageInfo fontDescriptor{ .sampler = sampler, .imageView = fontView, .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
VkWriteDescriptorSet writeDescriptorSets = vks::initializers::writeDescriptorSet(descriptorSet, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 0, &fontDescriptor);
vkUpdateDescriptorSets(device->logicalDevice, 1, &writeDescriptorSets, 0, nullptr);
// Buffers per max. frames-in-flight
buffers.resize(maxConcurrentFrames);
// Pipeline layout
// Push constants for UI rendering parameters
VkPushConstantRange pushConstantRange{ .stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .size = sizeof(PushConstBlock) };
VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo{
.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
.setLayoutCount = 1,
.pSetLayouts = &descriptorSetLayout,
.pushConstantRangeCount = 1,
.pPushConstantRanges = &pushConstantRange
};
VK_CHECK_RESULT(vkCreatePipelineLayout(device->logicalDevice, &pipelineLayoutCreateInfo, nullptr, &pipelineLayout));
// Setup graphics pipeline for UI rendering
VkPipelineInputAssemblyStateCreateInfo inputAssemblyState = vks::initializers::pipelineInputAssemblyStateCreateInfo(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, 0, VK_FALSE);
VkPipelineRasterizationStateCreateInfo rasterizationState = vks::initializers::pipelineRasterizationStateCreateInfo(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE, VK_FRONT_FACE_COUNTER_CLOCKWISE);
// Enable blending
VkPipelineColorBlendAttachmentState blendAttachmentState{
.blendEnable = VK_TRUE,
.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA,
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
.colorBlendOp = VK_BLEND_OP_ADD,
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO,
.alphaBlendOp = VK_BLEND_OP_ADD,
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT
};
// Vertex bindings an attributes based on ImGui vertex definition
std::vector<VkVertexInputBindingDescription> vertexInputBindings = {
{ .binding = 0, .stride = sizeof(ImDrawVert), .inputRate = VK_VERTEX_INPUT_RATE_VERTEX }
};
std::vector<VkVertexInputAttributeDescription> vertexInputAttributes = {
{ .location = 0, .binding = 0, .format = VK_FORMAT_R32G32_SFLOAT, .offset = offsetof(ImDrawVert, pos) },
{ .location = 1, .binding = 0, .format = VK_FORMAT_R32G32_SFLOAT, .offset = offsetof(ImDrawVert, uv) },
{ .location = 2, .binding = 0, .format = VK_FORMAT_R8G8B8A8_UNORM, .offset = offsetof(ImDrawVert, col) },
};
VkPipelineVertexInputStateCreateInfo vertexInputState = vks::initializers::pipelineVertexInputStateCreateInfo();
vertexInputState.vertexBindingDescriptionCount = static_cast<uint32_t>(vertexInputBindings.size());
vertexInputState.pVertexBindingDescriptions = vertexInputBindings.data();
vertexInputState.vertexAttributeDescriptionCount = static_cast<uint32_t>(vertexInputAttributes.size());
vertexInputState.pVertexAttributeDescriptions = vertexInputAttributes.data();
VkPipelineColorBlendStateCreateInfo colorBlendState = vks::initializers::pipelineColorBlendStateCreateInfo(1, &blendAttachmentState);
VkPipelineDepthStencilStateCreateInfo depthStencilState = vks::initializers::pipelineDepthStencilStateCreateInfo(VK_FALSE, VK_FALSE, VK_COMPARE_OP_ALWAYS);
VkPipelineViewportStateCreateInfo viewportState = vks::initializers::pipelineViewportStateCreateInfo(1, 1, 0);
VkPipelineMultisampleStateCreateInfo multisampleState = vks::initializers::pipelineMultisampleStateCreateInfo(rasterizationSamples);
std::vector<VkDynamicState> dynamicStateEnables = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR };
VkPipelineDynamicStateCreateInfo dynamicState = vks::initializers::pipelineDynamicStateCreateInfo(dynamicStateEnables);
VkGraphicsPipelineCreateInfo pipelineCreateInfo{
.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
.stageCount = static_cast<uint32_t>(shaders.size()),
.pStages = shaders.data(),
.pVertexInputState = &vertexInputState,
.pInputAssemblyState = &inputAssemblyState,
.pViewportState = &viewportState,
.pRasterizationState = &rasterizationState,
.pMultisampleState = &multisampleState,
.pDepthStencilState = &depthStencilState,
.pColorBlendState = &colorBlendState,
.pDynamicState = &dynamicState,
.layout = pipelineLayout,
.renderPass = renderPass,
.subpass = subpass,
};
#if defined(VK_KHR_dynamic_rendering)
// If we are using dynamic rendering (renderPass is null), we must define color, depth and stencil attachments at pipeline create time
VkPipelineRenderingCreateInfo pipelineRenderingCreateInfo{};
if (renderPass == VK_NULL_HANDLE) {
pipelineRenderingCreateInfo = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO,
.colorAttachmentCount = 1,
.pColorAttachmentFormats = &colorFormat,
.depthAttachmentFormat = depthFormat,
.stencilAttachmentFormat = depthFormat
};
pipelineCreateInfo.pNext = &pipelineRenderingCreateInfo;
}
#endif
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device->logicalDevice, VK_NULL_HANDLE, 1, &pipelineCreateInfo, nullptr, &pipeline));
}
/** Update vertex and index buffer containing the imGui elements when required */
void UIOverlay::update(uint32_t currentBuffer)
{
ImDrawData* imDrawData = ImGui::GetDrawData();
if (!imDrawData) {
return;
}
// Note: Alignment is done inside buffer creation
VkDeviceSize vertexBufferSize = imDrawData->TotalVtxCount * sizeof(ImDrawVert);
VkDeviceSize indexBufferSize = imDrawData->TotalIdxCount * sizeof(ImDrawIdx);
// Update buffers only if vertex or index count has been changed compared to current buffer size
if ((vertexBufferSize == 0) || (indexBufferSize == 0)) {
return;
}
// Create buffers with multiple of a chunk size to minimize the need to recreate them
const VkDeviceSize chunkSize = 16384;
vertexBufferSize = ((vertexBufferSize + chunkSize - 1) / chunkSize) * chunkSize;
indexBufferSize = ((indexBufferSize + chunkSize - 1) / chunkSize) * chunkSize;
// Recreate vertex buffer only if necessary
if ((buffers[currentBuffer].vertexBuffer.buffer == VK_NULL_HANDLE) || (buffers[currentBuffer].vertexBuffer.size < vertexBufferSize)) {
buffers[currentBuffer].vertexBuffer.unmap();
buffers[currentBuffer].vertexBuffer.destroy();
VK_CHECK_RESULT(device->createBuffer(VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, &buffers[currentBuffer].vertexBuffer, vertexBufferSize));
buffers[currentBuffer].vertexCount = imDrawData->TotalVtxCount;
buffers[currentBuffer].vertexBuffer.map();
}
// Recreate index buffer only if necessary
if ((buffers[currentBuffer].indexBuffer.buffer == VK_NULL_HANDLE) || (buffers[currentBuffer].indexBuffer.size < indexBufferSize)) {
buffers[currentBuffer].indexBuffer.unmap();
buffers[currentBuffer].indexBuffer.destroy();
VK_CHECK_RESULT(device->createBuffer(VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, &buffers[currentBuffer].indexBuffer, indexBufferSize));
buffers[currentBuffer].indexCount = imDrawData->TotalIdxCount;
buffers[currentBuffer].indexBuffer.map();
}
// Upload data
ImDrawVert* vtxDst = (ImDrawVert*)buffers[currentBuffer].vertexBuffer.mapped;
ImDrawIdx* idxDst = (ImDrawIdx*)buffers[currentBuffer].indexBuffer.mapped;
for (int n = 0; n < imDrawData->CmdListsCount; n++) {
const ImDrawList* cmd_list = imDrawData->CmdLists[n];
memcpy(vtxDst, cmd_list->VtxBuffer.Data, cmd_list->VtxBuffer.Size * sizeof(ImDrawVert));
memcpy(idxDst, cmd_list->IdxBuffer.Data, cmd_list->IdxBuffer.Size * sizeof(ImDrawIdx));
vtxDst += cmd_list->VtxBuffer.Size;
idxDst += cmd_list->IdxBuffer.Size;
}
// Flush to make writes visible to GPU
buffers[currentBuffer].vertexBuffer.flush();
buffers[currentBuffer].indexBuffer.flush();
}
void UIOverlay::draw(const VkCommandBuffer commandBuffer, uint32_t currentBuffer)
{
ImDrawData* imDrawData = ImGui::GetDrawData();
int32_t vertexOffset = 0;
int32_t indexOffset = 0;
if ((!imDrawData) || (imDrawData->CmdListsCount == 0)) {
return;
}
if (buffers[currentBuffer].vertexBuffer.buffer == VK_NULL_HANDLE || buffers[currentBuffer].indexBuffer.buffer == VK_NULL_HANDLE) {
return;
}
ImGuiIO& io = ImGui::GetIO();
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSet, 0, NULL);
pushConstBlock.scale = glm::vec2(2.0f / io.DisplaySize.x, 2.0f / io.DisplaySize.y);
pushConstBlock.translate = glm::vec2(-1.0f);
vkCmdPushConstants(commandBuffer, pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(PushConstBlock), &pushConstBlock);
assert(buffers[currentBuffer].vertexBuffer.buffer != VK_NULL_HANDLE && buffers[currentBuffer].indexBuffer.buffer != VK_NULL_HANDLE);
VkDeviceSize offsets[1] = { 0 };
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &buffers[currentBuffer].vertexBuffer.buffer, offsets);
vkCmdBindIndexBuffer(commandBuffer, buffers[currentBuffer].indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT16);
for (int32_t i = 0; i < imDrawData->CmdListsCount; i++) {
const ImDrawList* cmd_list = imDrawData->CmdLists[i];
for (int32_t j = 0; j < cmd_list->CmdBuffer.Size; j++) {
const ImDrawCmd* pcmd = &cmd_list->CmdBuffer[j];
VkRect2D scissorRect{
.offset = {.x = std::max((int32_t)(pcmd->ClipRect.x), 0), .y = std::max((int32_t)(pcmd->ClipRect.y), 0) },
.extent = {.width = (uint32_t)(pcmd->ClipRect.z - pcmd->ClipRect.x), .height = (uint32_t)(pcmd->ClipRect.w - pcmd->ClipRect.y) }
};
vkCmdSetScissor(commandBuffer, 0, 1, &scissorRect);
vkCmdDrawIndexed(commandBuffer, pcmd->ElemCount, 1, indexOffset, vertexOffset, 0);
indexOffset += pcmd->ElemCount;
}
#if (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT)) && TARGET_OS_SIMULATOR
// Apple Device Simulator does not support vkCmdDrawIndexed() with vertexOffset > 0, so rebind vertex buffer instead
offsets[0] += cmd_list->VtxBuffer.Size * sizeof(ImDrawVert);
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &buffers[currentBuffer].vertexBuffer.buffer, offsets);
#else
vertexOffset += cmd_list->VtxBuffer.Size;
#endif
}
}
void UIOverlay::resize(uint32_t width, uint32_t height)
{
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2((float)(width), (float)(height));
}
void UIOverlay::freeResources()
{
for (auto& buffer : buffers) {
buffer.vertexBuffer.destroy();
buffer.indexBuffer.destroy();
}
vkDestroyImageView(device->logicalDevice, fontView, nullptr);
vkDestroyImage(device->logicalDevice, fontImage, nullptr);
vkFreeMemory(device->logicalDevice, fontMemory, nullptr);
vkDestroySampler(device->logicalDevice, sampler, nullptr);
vkDestroyDescriptorSetLayout(device->logicalDevice, descriptorSetLayout, nullptr);
vkDestroyDescriptorPool(device->logicalDevice, descriptorPool, nullptr);
vkDestroyPipelineLayout(device->logicalDevice, pipelineLayout, nullptr);
vkDestroyPipeline(device->logicalDevice, pipeline, nullptr);
}
bool UIOverlay::header(const char *caption)
{
return ImGui::CollapsingHeader(caption, ImGuiTreeNodeFlags_DefaultOpen);
}
bool UIOverlay::checkBox(const char *caption, bool *value)
{
return ImGui::Checkbox(caption, value);
}
bool UIOverlay::checkBox(const char *caption, int32_t *value)
{
bool val = (*value == 1);
bool res = ImGui::Checkbox(caption, &val);
*value = val;
return res;
}
bool UIOverlay::radioButton(const char* caption, bool value)
{
return ImGui::RadioButton(caption, value);
}
bool UIOverlay::inputFloat(const char *caption, float *value, float step, uint32_t precision)
{
return ImGui::InputFloat(caption, value, step, step * 10.0f, precision);
}
bool UIOverlay::sliderFloat(const char* caption, float* value, float min, float max)
{
return ImGui::SliderFloat(caption, value, min, max);
}
bool UIOverlay::sliderInt(const char* caption, int32_t* value, int32_t min, int32_t max)
{
return ImGui::SliderInt(caption, value, min, max);
}
bool UIOverlay::comboBox(const char *caption, int32_t *itemindex, std::vector<std::string> items)
{
if (items.empty()) {
return false;
}
std::vector<const char*> charitems;
charitems.reserve(items.size());
for (size_t i = 0; i < items.size(); i++) {
charitems.push_back(items[i].c_str());
}
uint32_t itemCount = static_cast<uint32_t>(charitems.size());
return ImGui::Combo(caption, itemindex, &charitems[0], itemCount, itemCount);
}
bool UIOverlay::button(const char *caption)
{
return ImGui::Button(caption);
}
bool UIOverlay::colorPicker(const char* caption, float* color) {
return ImGui::ColorEdit4(caption, color, ImGuiColorEditFlags_NoInputs);
}
void UIOverlay::text(const char *formatstr, ...)
{
va_list args;
va_start(args, formatstr);
ImGui::TextV(formatstr, args);
va_end(args);
}
}
@@ -0,0 +1,112 @@
/*
* UI overlay class using ImGui
*
* Copyright (C) 2017-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <vector>
#include <sstream>
#include <iomanip>
#include <vulkan/vulkan.h>
#include "VulkanTools.h"
#include "VulkanDebug.h"
#include "VulkanBuffer.h"
#include "VulkanDevice.h"
#include "../external/imgui/imgui.h"
#if defined(VK_EXAMPLE_PS5)
#include <string>
// PS5: the title's theme for the overlay. The UI module (ps5/ui/overlay_theme.cpp)
// defines both; without it ps5/src/example_ps5.cpp's weak ones keep upstream's look.
void ps5StyleOverlay(ImGuiStyle &style);
bool ps5OverlayFont(std::string &path, float &size);
#endif
#if defined(__ANDROID__)
#include "VulkanAndroid.h"
#endif
#if defined(__APPLE__)
#include <TargetConditionals.h>
#endif
namespace vks
{
class UIOverlay
{
public:
vks::VulkanDevice* device{ nullptr };
VkQueue queue{ VK_NULL_HANDLE };
VkSampleCountFlagBits rasterizationSamples{ VK_SAMPLE_COUNT_1_BIT };
uint32_t subpass{ 0 };
struct Buffers {
vks::Buffer vertexBuffer;
vks::Buffer indexBuffer;
int32_t vertexCount{ 0 };
int32_t indexCount{ 0 };
};
std::vector<Buffers> buffers;
uint32_t maxConcurrentFrames{ 0 };
uint32_t currentBuffer{ 0 };
std::vector<VkPipelineShaderStageCreateInfo> shaders;
VkDescriptorPool descriptorPool{ VK_NULL_HANDLE };
VkDescriptorSetLayout descriptorSetLayout{ VK_NULL_HANDLE };
VkDescriptorSet descriptorSet{ VK_NULL_HANDLE };
VkPipelineLayout pipelineLayout{ VK_NULL_HANDLE };
VkPipeline pipeline{ VK_NULL_HANDLE };
VkDeviceMemory fontMemory{ VK_NULL_HANDLE };
VkImage fontImage{ VK_NULL_HANDLE };
VkImageView fontView{ VK_NULL_HANDLE };
VkSampler sampler{ VK_NULL_HANDLE };
struct PushConstBlock {
glm::vec2 scale;
glm::vec2 translate;
} pushConstBlock;
bool visible{ true };
float scale{ 1.0f };
VkRenderPass renderPass{ VK_NULL_HANDLE };
VkFormat colorFormat{ VK_FORMAT_UNDEFINED };
VkFormat depthFormat{ VK_FORMAT_UNDEFINED };
UIOverlay();
~UIOverlay();
void prepare();
void update(uint32_t currentBuffer);
void draw(const VkCommandBuffer commandBuffer, uint32_t currentBuffer);
void resize(uint32_t width, uint32_t height);
void freeResources();
bool header(const char* caption);
bool checkBox(const char* caption, bool* value);
bool checkBox(const char* caption, int32_t* value);
bool radioButton(const char* caption, bool value);
bool inputFloat(const char* caption, float* value, float step, uint32_t precision);
bool sliderFloat(const char* caption, float* value, float min, float max);
bool sliderInt(const char* caption, int32_t* value, int32_t min, int32_t max);
bool comboBox(const char* caption, int32_t* itemindex, std::vector<std::string> items);
bool button(const char* caption);
bool colorPicker(const char* caption, float* color);
void text(const char* formatstr, ...);
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,321 @@
/*
* Vulkan glTF model and texture loading class based on tinyglTF (https://github.com/syoyo/tinygltf)
*
* Copyright (C) 2018-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
/*
* Note that this isn't a complete glTF loader and not all features of the glTF 2.0 spec are supported
* For details on how glTF 2.0 works, see the official spec at https://github.com/KhronosGroup/glTF/tree/master/specification/2.0
*
* If you are looking for a complete glTF implementation, check out https://github.com/SaschaWillems/Vulkan-glTF-PBR/
*/
#pragma once
#include <stdlib.h>
#include <string>
#include <fstream>
#include <vector>
#include "vulkan/vulkan.h"
#include "VulkanDevice.h"
#include <ktx.h>
#include <ktxvulkan.h>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#define TINYGLTF_NO_STB_IMAGE_WRITE
#ifdef VK_USE_PLATFORM_ANDROID_KHR
#define TINYGLTF_ANDROID_LOAD_FROM_ASSETS
#endif
#include "tiny_gltf.h"
#ifdef __OHOS__
#include <rawfile/raw_file_manager.h>
#endif
#if defined(__ANDROID__)
#include <android/asset_manager.h>
#endif
namespace vkglTF
{
enum DescriptorBindingFlags {
ImageBaseColor = 0x00000001,
ImageNormalMap = 0x00000002
};
extern VkDescriptorSetLayout descriptorSetLayoutImage;
extern VkDescriptorSetLayout descriptorSetLayoutUbo;
extern VkMemoryPropertyFlags memoryPropertyFlags;
extern uint32_t descriptorBindingFlags;
struct Node;
/*
glTF texture loading class
*/
struct Texture {
vks::VulkanDevice* device = nullptr;
VkImage image;
VkImageLayout imageLayout;
VkDeviceMemory deviceMemory;
VkImageView view;
uint32_t width, height;
uint32_t mipLevels;
uint32_t layerCount;
VkDescriptorImageInfo descriptor;
VkSampler sampler;
uint32_t index;
void updateDescriptor();
void destroy();
void fromglTfImage(tinygltf::Image& gltfimage, std::string path, vks::VulkanDevice* device, VkQueue copyQueue);
};
/*
glTF material class
*/
struct Material {
vks::VulkanDevice* device = nullptr;
enum AlphaMode { ALPHAMODE_OPAQUE, ALPHAMODE_MASK, ALPHAMODE_BLEND };
AlphaMode alphaMode = ALPHAMODE_OPAQUE;
float alphaCutoff = 1.0f;
float metallicFactor = 1.0f;
float roughnessFactor = 1.0f;
glm::vec4 baseColorFactor = glm::vec4(1.0f);
vkglTF::Texture* baseColorTexture = nullptr;
vkglTF::Texture* metallicRoughnessTexture = nullptr;
vkglTF::Texture* normalTexture = nullptr;
vkglTF::Texture* occlusionTexture = nullptr;
vkglTF::Texture* emissiveTexture = nullptr;
vkglTF::Texture* specularGlossinessTexture;
vkglTF::Texture* diffuseTexture;
VkDescriptorSet descriptorSet = VK_NULL_HANDLE;
Material(vks::VulkanDevice* device) : device(device) {};
void createDescriptorSet(VkDescriptorPool descriptorPool, VkDescriptorSetLayout descriptorSetLayout, uint32_t descriptorBindingFlags);
};
/*
glTF primitive
*/
struct Primitive {
uint32_t firstIndex;
uint32_t indexCount;
uint32_t firstVertex;
uint32_t vertexCount;
Material& material;
struct Dimensions {
glm::vec3 min = glm::vec3(FLT_MAX);
glm::vec3 max = glm::vec3(-FLT_MAX);
glm::vec3 size;
glm::vec3 center;
float radius;
} dimensions;
void setDimensions(glm::vec3 min, glm::vec3 max);
Primitive(uint32_t firstIndex, uint32_t indexCount, Material& material) : firstIndex(firstIndex), indexCount(indexCount), material(material) {};
};
/*
glTF mesh
*/
struct Mesh {
vks::VulkanDevice* device;
std::vector<Primitive*> primitives;
std::string name;
struct UniformBuffer {
VkBuffer buffer;
VkDeviceMemory memory;
VkDescriptorBufferInfo descriptor;
VkDescriptorSet descriptorSet = VK_NULL_HANDLE;
void* mapped;
} uniformBuffer;
struct UniformBlock {
glm::mat4 matrix;
glm::mat4 jointMatrix[64]{};
float jointcount{ 0 };
} uniformBlock;
Mesh(vks::VulkanDevice* device, glm::mat4 matrix);
~Mesh();
};
/*
glTF skin
*/
struct Skin {
std::string name;
Node* skeletonRoot = nullptr;
std::vector<glm::mat4> inverseBindMatrices;
std::vector<Node*> joints;
};
/*
glTF node
*/
struct Node {
Node* parent;
uint32_t index;
std::vector<Node*> children;
glm::mat4 matrix;
std::string name;
Mesh* mesh;
Skin* skin;
int32_t skinIndex = -1;
glm::vec3 translation{};
glm::vec3 scale{ 1.0f };
glm::quat rotation{};
glm::mat4 localMatrix();
glm::mat4 getMatrix();
void update();
~Node();
};
/*
glTF animation channel
*/
struct AnimationChannel {
enum PathType { TRANSLATION, ROTATION, SCALE };
PathType path;
Node* node;
uint32_t samplerIndex;
};
/*
glTF animation sampler
*/
struct AnimationSampler {
enum InterpolationType { LINEAR, STEP, CUBICSPLINE };
InterpolationType interpolation;
std::vector<float> inputs;
std::vector<glm::vec4> outputsVec4;
};
/*
glTF animation
*/
struct Animation {
std::string name;
std::vector<AnimationSampler> samplers;
std::vector<AnimationChannel> channels;
float start = std::numeric_limits<float>::max();
float end = std::numeric_limits<float>::min();
};
/*
glTF default vertex layout with easy Vulkan mapping functions
*/
enum class VertexComponent { Position, Normal, UV, Color, Tangent, Joint0, Weight0 };
struct Vertex {
glm::vec3 pos;
glm::vec3 normal;
glm::vec2 uv;
glm::vec4 color;
glm::vec4 joint0;
glm::vec4 weight0;
glm::vec4 tangent;
static VkVertexInputBindingDescription vertexInputBindingDescription;
static std::vector<VkVertexInputAttributeDescription> vertexInputAttributeDescriptions;
static VkPipelineVertexInputStateCreateInfo pipelineVertexInputStateCreateInfo;
static VkVertexInputBindingDescription inputBindingDescription(uint32_t binding);
static VkVertexInputAttributeDescription inputAttributeDescription(uint32_t binding, uint32_t location, VertexComponent component);
static std::vector<VkVertexInputAttributeDescription> inputAttributeDescriptions(uint32_t binding, const std::vector<VertexComponent> components);
/** @brief Returns the default pipeline vertex input state create info structure for the requested vertex components */
static VkPipelineVertexInputStateCreateInfo* getPipelineVertexInputState(const std::vector<VertexComponent> components);
};
enum FileLoadingFlags {
None = 0x00000000,
PreTransformVertices = 0x00000001,
PreMultiplyVertexColors = 0x00000002,
FlipY = 0x00000004,
DontLoadImages = 0x00000008,
FlipUV = 0x00000010
};
enum RenderFlags {
BindImages = 0x00000001,
RenderOpaqueNodes = 0x00000002,
RenderAlphaMaskedNodes = 0x00000004,
RenderAlphaBlendedNodes = 0x00000008
};
/*
glTF model loading and rendering class
*/
class Model {
private:
vkglTF::Texture* getTexture(uint32_t index);
vkglTF::Texture emptyTexture;
void createEmptyTexture(VkQueue transferQueue);
public:
vks::VulkanDevice* device;
VkDescriptorPool descriptorPool;
struct Vertices {
int count;
VkBuffer buffer;
VkDeviceMemory memory;
} vertices;
struct Indices {
int count;
VkBuffer buffer;
VkDeviceMemory memory;
} indices;
std::vector<Node*> nodes;
std::vector<Node*> linearNodes;
std::vector<Skin*> skins;
std::vector<Texture> textures;
std::vector<Material> materials;
std::vector<Animation> animations;
struct Dimensions {
glm::vec3 min = glm::vec3(FLT_MAX);
glm::vec3 max = glm::vec3(-FLT_MAX);
glm::vec3 size;
glm::vec3 center;
float radius;
} dimensions;
bool metallicRoughnessWorkflow = true;
bool buffersBound = false;
std::string path;
Model() {};
~Model();
void loadNode(vkglTF::Node* parent, const tinygltf::Node& node, uint32_t nodeIndex, const tinygltf::Model& model, std::vector<uint32_t>& indexBuffer, std::vector<Vertex>& vertexBuffer, float globalscale);
void loadSkins(tinygltf::Model& gltfModel);
void loadImages(tinygltf::Model& gltfModel, vks::VulkanDevice* device, VkQueue transferQueue);
void loadMaterials(tinygltf::Model& gltfModel);
void loadAnimations(tinygltf::Model& gltfModel);
void loadFromFile(std::string filename, vks::VulkanDevice* device, VkQueue transferQueue, uint32_t fileLoadingFlags = vkglTF::FileLoadingFlags::None, float scale = 1.0f);
void bindBuffers(VkCommandBuffer commandBuffer);
void drawNode(Node* node, VkCommandBuffer commandBuffer, uint32_t renderFlags = 0, VkPipelineLayout pipelineLayout = VK_NULL_HANDLE, uint32_t bindImageSet = 1);
void draw(VkCommandBuffer commandBuffer, uint32_t renderFlags = 0, VkPipelineLayout pipelineLayout = VK_NULL_HANDLE, uint32_t bindImageSet = 1);
void getNodeDimensions(Node* node, glm::vec3& min, glm::vec3& max);
void getSceneDimensions();
void updateAnimation(uint32_t index, float time);
Node* findNode(Node* parent, uint32_t index);
Node* nodeFromIndex(uint32_t index);
Node* nodeFromName(const std::string name);
void prepareNodeDescriptor(vkglTF::Node* node, VkDescriptorSetLayout descriptorSetLayout);
};
}
+108
View File
@@ -0,0 +1,108 @@
/*
* Benchmark class - Can be used for automated benchmarks
*
* Copyright (C) 2016-2025 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include <vector>
#include <string>
#include <algorithm>
#include <limits>
#include <functional>
#include <chrono>
#include <iomanip>
namespace vks
{
class Benchmark {
private:
FILE* stream{ nullptr };
VkPhysicalDeviceProperties deviceProps{};
public:
bool active = false;
bool outputFrameTimes = false;
int outputFrames = -1; // -1 means no frames limit
uint32_t warmup = 1; // Default to 1 sec of warm-up
uint32_t duration = 10;
std::vector<double> frameTimes;
std::string filename = "";
double runtime = 0.0;
uint32_t frameCount = 0;
void run(std::function<void()> renderFunc, VkPhysicalDeviceProperties deviceProps) {
active = true;
this->deviceProps = deviceProps;
#if defined(_WIN32)
bool consoleAttached = AttachConsole(ATTACH_PARENT_PROCESS);
if (!consoleAttached) {
consoleAttached = AttachConsole(GetCurrentProcessId());
}
if (consoleAttached) {
freopen_s(&stream, "CONOUT$", "w+", stdout);
freopen_s(&stream, "CONOUT$", "w+", stderr);
}
#endif
// Warm up phase to get more stable frame rates
{
double tMeasured = 0.0;
while (tMeasured < (warmup * 1000)) {
auto tStart = std::chrono::high_resolution_clock::now();
renderFunc();
auto tDiff = std::chrono::duration<double, std::milli>(std::chrono::high_resolution_clock::now() - tStart).count();
tMeasured += tDiff;
};
}
// Benchmark phase
{
while (runtime < (duration * 1000.0)) {
auto tStart = std::chrono::high_resolution_clock::now();
renderFunc();
auto tDiff = std::chrono::duration<double, std::milli>(std::chrono::high_resolution_clock::now() - tStart).count();
runtime += tDiff;
frameTimes.push_back(tDiff);
frameCount++;
if (outputFrames != -1 && outputFrames == frameCount) break;
};
std::cout << std::fixed << std::setprecision(3);
std::cout << "Benchmark finished\n";
std::cout << "device : " << deviceProps.deviceName << " (driver version: " << deviceProps.driverVersion << ")" << "\n";
std::cout << "runtime: " << (runtime / 1000.0) << "\n";
std::cout << "frames : " << frameCount << "\n";
std::cout << "fps : " << frameCount / (runtime / 1000.0) << "\n";
}
}
void saveResults() {
std::ofstream result(filename, std::ios::out);
if (result.is_open()) {
result << std::fixed << std::setprecision(4);
result << "device,driverversion,duration (ms),frames,fps" << "\n";
result << deviceProps.deviceName << "," << deviceProps.driverVersion << "," << runtime << "," << frameCount << "," << frameCount / (runtime / 1000.0) << "\n";
if (outputFrameTimes) {
result << "\n" << "frame,ms" << "\n";
for (size_t i = 0; i < frameTimes.size(); i++) {
result << i << "," << frameTimes[i] << "\n";
}
double tMin = *std::min_element(frameTimes.begin(), frameTimes.end());
double tMax = *std::max_element(frameTimes.begin(), frameTimes.end());
double tAvg = std::accumulate(frameTimes.begin(), frameTimes.end(), 0.0) / (double)frameTimes.size();
std::cout << "best : " << (1000.0 / tMin) << " fps (" << tMin << " ms)" << "\n";
std::cout << "worst : " << (1000.0 / tMax) << " fps (" << tMax << " ms)" << "\n";
std::cout << "avg : " << (1000.0 / tAvg) << " fps (" << tAvg << " ms)" << "\n";
std::cout << "\n";
}
result.flush();
#if defined(_WIN32)
FreeConsole();
#endif
}
}
};
}
+252
View File
@@ -0,0 +1,252 @@
/*
* Basic camera class providing a look-at and first-person camera
*
* Copyright (C) 2016-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
class Camera
{
private:
float fov{ 60.0f };
float znear{ 0.1f }, zfar{ 1024.0f };
void updateViewMatrix()
{
glm::mat4 currentMatrix = matrices.view;
glm::mat4 rotM = glm::mat4(1.0f);
glm::mat4 transM;
rotM = glm::rotate(rotM, glm::radians(rotation.x * (flipY ? -1.0f : 1.0f)), glm::vec3(1.0f, 0.0f, 0.0f));
rotM = glm::rotate(rotM, glm::radians(rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));
rotM = glm::rotate(rotM, glm::radians(rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));
glm::vec3 translation = position;
if (flipY) {
translation.y *= -1.0f;
}
transM = glm::translate(glm::mat4(1.0f), translation);
if (type == CameraType::firstperson)
{
matrices.view = rotM * transM;
}
else
{
matrices.view = transM * rotM;
}
viewPos = glm::vec4(position, 0.0f) * glm::vec4(-1.0f, 1.0f, -1.0f, 1.0f);
if (matrices.view != currentMatrix) {
updated = true;
}
};
public:
enum CameraType { lookat, firstperson };
CameraType type = CameraType::lookat;
glm::vec3 rotation = glm::vec3();
glm::vec3 position = glm::vec3();
glm::vec4 viewPos = glm::vec4();
float rotationSpeed = 1.0f;
float movementSpeed = 1.0f;
bool updated = true;
bool flipY = false;
struct
{
glm::mat4 perspective;
glm::mat4 view;
} matrices;
struct
{
bool left = false;
bool right = false;
bool up = false;
bool down = false;
} keys;
bool moving() const
{
return keys.left || keys.right || keys.up || keys.down;
}
float getNearClip() const {
return znear;
}
float getFarClip() const {
return zfar;
}
void setPerspective(float fov, float aspect, float znear, float zfar)
{
glm::mat4 currentMatrix = matrices.perspective;
this->fov = fov;
this->znear = znear;
this->zfar = zfar;
matrices.perspective = glm::perspective(glm::radians(fov), aspect, znear, zfar);
if (flipY) {
matrices.perspective[1][1] *= -1.0f;
}
if (matrices.view != currentMatrix) {
updated = true;
}
};
void updateAspectRatio(float aspect)
{
glm::mat4 currentMatrix = matrices.perspective;
matrices.perspective = glm::perspective(glm::radians(fov), aspect, znear, zfar);
if (flipY) {
matrices.perspective[1][1] *= -1.0f;
}
if (matrices.view != currentMatrix) {
updated = true;
}
}
void setPosition(glm::vec3 position)
{
this->position = position;
updateViewMatrix();
}
void setRotation(glm::vec3 rotation)
{
this->rotation = rotation;
updateViewMatrix();
}
void rotate(glm::vec3 delta)
{
this->rotation += delta;
updateViewMatrix();
}
void setTranslation(glm::vec3 translation)
{
this->position = translation;
updateViewMatrix();
};
void translate(glm::vec3 delta)
{
this->position += delta;
updateViewMatrix();
}
void setRotationSpeed(float rotationSpeed)
{
this->rotationSpeed = rotationSpeed;
}
void setMovementSpeed(float movementSpeed)
{
this->movementSpeed = movementSpeed;
}
void update(float deltaTime)
{
updated = false;
if (type == CameraType::firstperson)
{
if (moving())
{
glm::vec3 camFront;
camFront.x = -cos(glm::radians(rotation.x)) * sin(glm::radians(rotation.y));
camFront.y = sin(glm::radians(rotation.x));
camFront.z = cos(glm::radians(rotation.x)) * cos(glm::radians(rotation.y));
camFront = glm::normalize(camFront);
float moveSpeed = deltaTime * movementSpeed;
if (keys.up)
position += camFront * moveSpeed;
if (keys.down)
position -= camFront * moveSpeed;
if (keys.left)
position -= glm::normalize(glm::cross(camFront, glm::vec3(0.0f, 1.0f, 0.0f))) * moveSpeed;
if (keys.right)
position += glm::normalize(glm::cross(camFront, glm::vec3(0.0f, 1.0f, 0.0f))) * moveSpeed;
}
}
updateViewMatrix();
};
// Update camera passing separate axis data (gamepad)
// Returns true if view or position has been changed
bool updatePad(glm::vec2 axisLeft, glm::vec2 axisRight, float deltaTime)
{
bool retVal = false;
if (type == CameraType::firstperson)
{
// Use the common console thumbstick layout
// Left = view, right = move
const float deadZone = 0.0015f;
const float range = 1.0f - deadZone;
glm::vec3 camFront;
camFront.x = -cos(glm::radians(rotation.x)) * sin(glm::radians(rotation.y));
camFront.y = sin(glm::radians(rotation.x));
camFront.z = cos(glm::radians(rotation.x)) * cos(glm::radians(rotation.y));
camFront = glm::normalize(camFront);
float moveSpeed = deltaTime * movementSpeed * 2.0f;
float rotSpeed = deltaTime * rotationSpeed * 50.0f;
// Move
if (fabsf(axisLeft.y) > deadZone)
{
float pos = (fabsf(axisLeft.y) - deadZone) / range;
position -= camFront * pos * ((axisLeft.y < 0.0f) ? -1.0f : 1.0f) * moveSpeed;
retVal = true;
}
if (fabsf(axisLeft.x) > deadZone)
{
float pos = (fabsf(axisLeft.x) - deadZone) / range;
position += glm::normalize(glm::cross(camFront, glm::vec3(0.0f, 1.0f, 0.0f))) * pos * ((axisLeft.x < 0.0f) ? -1.0f : 1.0f) * moveSpeed;
retVal = true;
}
// Rotate
if (fabsf(axisRight.x) > deadZone)
{
float pos = (fabsf(axisRight.x) - deadZone) / range;
rotation.y += pos * ((axisRight.x < 0.0f) ? -1.0f : 1.0f) * rotSpeed;
retVal = true;
}
if (fabsf(axisRight.y) > deadZone)
{
float pos = (fabsf(axisRight.y) - deadZone) / range;
rotation.x -= pos * ((axisRight.y < 0.0f) ? -1.0f : 1.0f) * rotSpeed;
retVal = true;
}
}
else
{
// todo: move code from example base class for look-at
}
if (retVal)
{
updateViewMatrix();
}
return retVal;
}
};
+72
View File
@@ -0,0 +1,72 @@
/*
* View frustum culling class
*
* Copyright (C) 2016 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include <array>
#include <math.h>
#include <glm/glm.hpp>
namespace vks
{
class Frustum
{
public:
enum side { LEFT = 0, RIGHT = 1, TOP = 2, BOTTOM = 3, BACK = 4, FRONT = 5 };
std::array<glm::vec4, 6> planes;
void update(glm::mat4 matrix)
{
planes[LEFT].x = matrix[0].w + matrix[0].x;
planes[LEFT].y = matrix[1].w + matrix[1].x;
planes[LEFT].z = matrix[2].w + matrix[2].x;
planes[LEFT].w = matrix[3].w + matrix[3].x;
planes[RIGHT].x = matrix[0].w - matrix[0].x;
planes[RIGHT].y = matrix[1].w - matrix[1].x;
planes[RIGHT].z = matrix[2].w - matrix[2].x;
planes[RIGHT].w = matrix[3].w - matrix[3].x;
planes[TOP].x = matrix[0].w - matrix[0].y;
planes[TOP].y = matrix[1].w - matrix[1].y;
planes[TOP].z = matrix[2].w - matrix[2].y;
planes[TOP].w = matrix[3].w - matrix[3].y;
planes[BOTTOM].x = matrix[0].w + matrix[0].y;
planes[BOTTOM].y = matrix[1].w + matrix[1].y;
planes[BOTTOM].z = matrix[2].w + matrix[2].y;
planes[BOTTOM].w = matrix[3].w + matrix[3].y;
planes[BACK].x = matrix[0].w + matrix[0].z;
planes[BACK].y = matrix[1].w + matrix[1].z;
planes[BACK].z = matrix[2].w + matrix[2].z;
planes[BACK].w = matrix[3].w + matrix[3].z;
planes[FRONT].x = matrix[0].w - matrix[0].z;
planes[FRONT].y = matrix[1].w - matrix[1].z;
planes[FRONT].z = matrix[2].w - matrix[2].z;
planes[FRONT].w = matrix[3].w - matrix[3].z;
for (auto i = 0; i < planes.size(); i++)
{
float length = sqrtf(planes[i].x * planes[i].x + planes[i].y * planes[i].y + planes[i].z * planes[i].z);
planes[i] /= length;
}
}
bool checkSphere(glm::vec3 pos, float radius)
{
for (auto i = 0; i < planes.size(); i++)
{
if ((planes[i].x * pos.x) + (planes[i].y * pos.y) + (planes[i].z * pos.z) + planes[i].w <= -radius)
{
return false;
}
}
return true;
}
};
}
+184
View File
@@ -0,0 +1,184 @@
/*
* Key codes for multiple platforms
*
* Copyright (C) 2016 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#if defined(_WIN32)
#define KEY_ESCAPE VK_ESCAPE
#define KEY_F1 VK_F1
#define KEY_F2 VK_F2
#define KEY_F3 VK_F3
#define KEY_F4 VK_F4
#define KEY_F5 VK_F5
#define KEY_W 0x57
#define KEY_A 0x41
#define KEY_S 0x53
#define KEY_D 0x44
#define KEY_P 0x50
#define KEY_SPACE 0x20
#define KEY_KPADD 0x6B
#define KEY_KPSUB 0x6D
#define KEY_B 0x42
#define KEY_F 0x46
#define KEY_L 0x4C
#define KEY_N 0x4E
#define KEY_O 0x4F
#define KEY_T 0x54
#elif defined(VK_USE_PLATFORM_ANDROID_KHR) || defined(VK_USE_PLATFORM_OHOS)
#define GAMEPAD_BUTTON_A 0x1000
#define GAMEPAD_BUTTON_B 0x1001
#define GAMEPAD_BUTTON_X 0x1002
#define GAMEPAD_BUTTON_Y 0x1003
#define GAMEPAD_BUTTON_L1 0x1004
#define GAMEPAD_BUTTON_R1 0x1005
#define GAMEPAD_BUTTON_START 0x1006
#define TOUCH_DOUBLE_TAP 0x1100
// for textoverlay example
#define KEY_SPACE 0x3E // AKEYCODE_SPACE
#define KEY_KPADD 0x9D // AKEYCODE_NUMPAD_ADD
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
#if !defined(VK_EXAMPLE_XCODE_GENERATED)
// For iOS and macOS pre-configured Xcode example project: Use character keycodes
// - Use numeric keys as optional alternative to function keys
#define KEY_DELETE 0x7F
#define KEY_ESCAPE 0x1B
#define KEY_F1 0xF704 // NSF1FunctionKey
#define KEY_F2 0xF705 // NSF2FunctionKey
#define KEY_F3 0xF706 // NSF3FunctionKey
#define KEY_F4 0xF707 // NSF4FunctionKey
#define KEY_1 '1'
#define KEY_2 '2'
#define KEY_3 '3'
#define KEY_4 '4'
#define KEY_W 'w'
#define KEY_A 'a'
#define KEY_S 's'
#define KEY_D 'd'
#define KEY_P 'p'
#define KEY_SPACE ' '
#define KEY_KPADD '+'
#define KEY_KPSUB '-'
#define KEY_B 'b'
#define KEY_F 'f'
#define KEY_L 'l'
#define KEY_N 'n'
#define KEY_O 'o'
#define KEY_Q 'q'
#define KEY_T 't'
#define KEY_Z 'z'
#else // defined(VK_EXAMPLE_XCODE_GENERATED)
// For cross-platform cmake-generated Xcode project: Use ANSI keyboard keycodes
// - Use numeric keys as optional alternative to function keys
// - Use main keyboard plus/minus instead of keypad plus/minus
#include <Carbon/Carbon.h>
#define KEY_DELETE kVK_Delete
#define KEY_ESCAPE kVK_Escape
#define KEY_F1 kVK_F1
#define KEY_F2 kVK_F2
#define KEY_F3 kVK_F3
#define KEY_F4 kVK_F4
#define KEY_1 kVK_ANSI_1
#define KEY_2 kVK_ANSI_2
#define KEY_3 kVK_ANSI_3
#define KEY_4 kVK_ANSI_4
#define KEY_W kVK_ANSI_W
#define KEY_A kVK_ANSI_A
#define KEY_S kVK_ANSI_S
#define KEY_D kVK_ANSI_D
#define KEY_P kVK_ANSI_P
#define KEY_SPACE kVK_Space
#define KEY_KPADD kVK_ANSI_Equal
#define KEY_KPSUB kVK_ANSI_Minus
#define KEY_B kVK_ANSI_B
#define KEY_F kVK_ANSI_F
#define KEY_L kVK_ANSI_L
#define KEY_N kVK_ANSI_N
#define KEY_O kVK_ANSI_O
#define KEY_Q kVK_ANSI_Q
#define KEY_T kVK_ANSI_T
#define KEY_Z kVK_ANSI_Z
#endif
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
#define KEY_ESCAPE DIKS_ESCAPE
#define KEY_F1 DIKS_F1
#define KEY_F2 DIKS_F2
#define KEY_F3 DIKS_F3
#define KEY_F4 DIKS_F4
#define KEY_W DIKS_SMALL_W
#define KEY_A DIKS_SMALL_A
#define KEY_S DIKS_SMALL_S
#define KEY_D DIKS_SMALL_D
#define KEY_P DIKS_SMALL_P
#define KEY_SPACE DIKS_SPACE
#define KEY_KPADD DIKS_PLUS_SIGN
#define KEY_KPSUB DIKS_MINUS_SIGN
#define KEY_B DIKS_SMALL_B
#define KEY_F DIKS_SMALL_F
#define KEY_L DIKS_SMALL_L
#define KEY_N DIKS_SMALL_N
#define KEY_O DIKS_SMALL_O
#define KEY_T DIKS_SMALL_T
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
#include <linux/input.h>
// todo: hack for bloom example
#define KEY_ESCAPE KEY_ESC
#define KEY_KPADD KEY_KPPLUS
#define KEY_KPSUB KEY_KPMINUS
#elif defined(__linux__) || defined(__FreeBSD__)
#define KEY_ESCAPE 0x9
#define KEY_F1 0x43
#define KEY_F2 0x44
#define KEY_F3 0x45
#define KEY_F4 0x46
#define KEY_W 0x19
#define KEY_A 0x26
#define KEY_S 0x27
#define KEY_D 0x28
#define KEY_P 0x21
#define KEY_SPACE 0x41
#define KEY_KPADD 0x56
#define KEY_KPSUB 0x52
#define KEY_B 0x38
#define KEY_F 0x29
#define KEY_L 0x2E
#define KEY_N 0x39
#define KEY_O 0x20
#define KEY_T 0x1C
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
#include <sys/keycodes.h>
#define KEY_ESCAPE KEYCODE_ESCAPE
#define KEY_F1 KEYCODE_F1
#define KEY_F2 KEYCODE_F2
#define KEY_F3 KEYCODE_F3
#define KEY_F4 KEYCODE_F4
#define KEY_W KEYCODE_W
#define KEY_A KEYCODE_A
#define KEY_S KEYCODE_S
#define KEY_D KEYCODE_D
#define KEY_P KEYCODE_P
#define KEY_SPACE KEYCODE_SPACE
#define KEY_KPADD KEYCODE_KP_PLUS
#define KEY_KPSUB KEYCODE_KP_MINUS
#define KEY_B KEYCODE_B
#define KEY_F KEYCODE_F
#define KEY_L KEYCODE_L
#define KEY_N KEYCODE_N
#define KEY_O KEYCODE_O
#define KEY_T KEYCODE_T
#endif
+121
View File
@@ -0,0 +1,121 @@
/*
* Basic C++11 based thread pool with per-thread job queues
*
* Copyright (C) 2016 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
// make_unique is not available in C++11
// Taken from Herb Sutter's blog (https://herbsutter.com/gotw/_102/)
template<typename T, typename ...Args>
std::unique_ptr<T> make_unique(Args&& ...args)
{
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
namespace vks
{
class Thread
{
private:
bool destroying = false;
std::thread worker;
std::queue<std::function<void()>> jobQueue;
std::mutex queueMutex;
std::condition_variable condition;
// Loop through all remaining jobs
void queueLoop()
{
while (true)
{
std::function<void()> job;
{
std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [this] { return !jobQueue.empty() || destroying; });
if (destroying)
{
break;
}
job = jobQueue.front();
}
job();
{
std::lock_guard<std::mutex> lock(queueMutex);
jobQueue.pop();
condition.notify_one();
}
}
}
public:
Thread()
{
worker = std::thread(&Thread::queueLoop, this);
}
~Thread()
{
if (worker.joinable())
{
wait();
queueMutex.lock();
destroying = true;
condition.notify_one();
queueMutex.unlock();
worker.join();
}
}
// Add a new job to the thread's queue
void addJob(std::function<void()> function)
{
std::lock_guard<std::mutex> lock(queueMutex);
jobQueue.push(std::move(function));
condition.notify_one();
}
// Wait until all work items have been finished
void wait()
{
std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [this]() { return jobQueue.empty(); });
}
};
class ThreadPool
{
public:
std::vector<std::unique_ptr<Thread>> threads;
// Sets the number of threads to be allocated in this pool
void setThreadCount(uint32_t count)
{
threads.clear();
for (uint32_t i = 0; i < count; i++)
{
threads.push_back(make_unique<Thread>());
}
}
// Wait until all threads have finished their work items
void wait()
{
for (auto &thread : threads)
{
thread->wait();
}
}
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,458 @@
/*
* Vulkan Example base class
*
* Copyright (C) 2016-2026 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/
#pragma once
#ifdef _WIN32
#pragma comment(linker, "/subsystem:windows")
#include <windows.h>
#include <fcntl.h>
#include <io.h>
#include <ShellScalingAPI.h>
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
#include <android/native_activity.h>
#include <android/asset_manager.h>
#include <android_native_app_glue.h>
#include <sys/system_properties.h>
#include "VulkanAndroid.h"
#elif defined(VK_USE_PLATFORM_OHOS)
#include <ace/xcomponent/native_interface_xcomponent.h>
#include <rawfile/raw_file_manager.h>
#include "uv.h"
#include "VulkanOHOS.h"
extern NativeWindow* nativeWindow;
extern uv_loop_t* loop;
extern uv_async_t msgSignal;
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
#include <directfb.h>
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
#include <wayland-client.h>
#include "xdg-shell-client-protocol.h"
#elif defined(_DIRECT2DISPLAY)
//
#elif defined(VK_USE_PLATFORM_XCB_KHR)
#include <xcb/xcb.h>
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
#include <TargetConditionals.h>
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <vector>
#include <array>
#include <unordered_map>
#include <numeric>
#include <ctime>
#include <iostream>
#include <chrono>
#include <random>
#include <algorithm>
#include <sys/stat.h>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/matrix_inverse.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <string>
#include "vulkan/vulkan.h"
#include "CommandLineParser.hpp"
#include "keycodes.hpp"
#include "VulkanTools.h"
#include "VulkanDebug.h"
#include "VulkanUIOverlay.h"
#include "VulkanSwapChain.h"
#include "VulkanBuffer.h"
#include "VulkanDevice.h"
#include "VulkanTexture.h"
#include "VulkanInitializers.hpp"
#include "camera.hpp"
#include "benchmark.hpp"
constexpr uint32_t maxConcurrentFrames{ 2 };
class VulkanExampleBase
{
private:
std::string getWindowTitle() const;
uint32_t destWidth{};
uint32_t destHeight{};
bool resizing = false;
void handleMouseMove(int32_t x, int32_t y);
void nextFrame();
void updateOverlay();
void createPipelineCache();
void createCommandPool();
void createSynchronizationPrimitives();
void createSurface();
void createSwapChain();
void createCommandBuffers();
void destroyCommandBuffers();
std::string shaderDir = "glsl";
#if defined(VK_EXAMPLE_PS5)
VkSemaphore ps5CaptureComplete{ VK_NULL_HANDLE };
VkSemaphore ps5CaptureSwapchainImage(VkSemaphore renderComplete);
/** @brief The host reference build is recording every frame (PS5_RECORD, ps5/tools/record-reel.sh) */
static bool ps5Recording();
#endif
protected:
// Returns the path to the root of the glsl, hlsl or slang shader directory.
std::string getShadersPath() const;
// Frame counter to display fps
uint32_t frameCounter = 0;
uint32_t lastFPS = 0;
std::chrono::time_point<std::chrono::high_resolution_clock> lastTimestamp, tPrevEnd;
// Vulkan instance, stores all per-application states
VkInstance instance{ VK_NULL_HANDLE };
std::vector<std::string> supportedInstanceExtensions;
// Physical device (GPU) that Vulkan will use
VkPhysicalDevice physicalDevice{ VK_NULL_HANDLE };
// Stores physical device properties (for e.g. checking device limits)
VkPhysicalDeviceProperties deviceProperties{};
// Stores the features available on the selected physical device (for e.g. checking if a feature is available)
VkPhysicalDeviceFeatures deviceFeatures{};
// Stores all available memory (type) properties for the physical device
VkPhysicalDeviceMemoryProperties deviceMemoryProperties{};
/** @brief Set of physical device features to be enabled for this example (must be set in the derived constructor) */
VkPhysicalDeviceFeatures enabledFeatures{};
/** @brief Set of device extensions to be enabled for this example (must be set in the derived constructor) */
std::vector<const char*> enabledDeviceExtensions;
/** @brief Set of instance extensions to be enabled for this example (must be set in the derived constructor) */
std::vector<const char*> enabledInstanceExtensions;
/** @brief Set of layer settings to be enabled for this example (must be set in the derived constructor) */
std::vector<VkLayerSettingEXT> enabledLayerSettings;
/** @brief Optional pNext structure for passing extension structures to device creation */
void* deviceCreatepNextChain = nullptr;
/** @brief Logical device, application's view of the physical device (GPU) */
VkDevice device{ VK_NULL_HANDLE };
// Handle to the device graphics queue that command buffers are submitted to
VkQueue queue{ VK_NULL_HANDLE };
// Depth buffer format (selected during Vulkan initialization)
VkFormat depthFormat{VK_FORMAT_UNDEFINED};
// Command buffer pool
VkCommandPool cmdPool{ VK_NULL_HANDLE };
// Command buffers used for rendering
std::array<VkCommandBuffer, maxConcurrentFrames> drawCmdBuffers;
// Global render pass for frame buffer writes
VkRenderPass renderPass{ VK_NULL_HANDLE };
// List of available frame buffers (same as number of swap chain images)
std::vector<VkFramebuffer>frameBuffers;
// Descriptor set pool
VkDescriptorPool descriptorPool{ VK_NULL_HANDLE };
// List of shader modules created (stored for cleanup)
std::vector<VkShaderModule> shaderModules;
// Pipeline cache object
VkPipelineCache pipelineCache{ VK_NULL_HANDLE };
// Wraps the swap chain to present images (framebuffers) to the windowing system
VulkanSwapChain swapChain;
// Synchronization related objects and variables
// These are used to have multiple frame buffers "in flight" to get some CPU/GPU parallelism
uint32_t currentImageIndex{ 0 };
uint32_t currentBuffer{ 0 };
std::array<VkSemaphore, maxConcurrentFrames> presentCompleteSemaphores{};
std::vector<VkSemaphore> renderCompleteSemaphores{};
std::array<VkFence, maxConcurrentFrames> waitFences;
bool requiresStencil{ false };
public:
bool prepared = false;
bool resized = false;
uint32_t width = 1280;
uint32_t height = 720;
vks::UIOverlay ui;
CommandLineParser commandLineParser;
/** @brief Last frame time measured using a high performance timer (if available) */
float frameTimer = 1.0f;
vks::Benchmark benchmark;
/** @brief Encapsulated physical and logical vulkan device */
vks::VulkanDevice *vulkanDevice{};
/** @brief Example settings that can be changed e.g. by command line arguments */
struct Settings {
/** @brief Activates validation layers (and message output) when set to true */
bool validation = false;
/** @brief Set to true if fullscreen mode has been requested via command line */
bool fullscreen = false;
/** @brief Set to true if v-sync will be forced for the swapchain */
bool vsync = false;
/** @brief Enable UI overlay */
bool overlay = true;
} settings;
/** @brief State of gamepad input (only used on Android) */
struct {
glm::vec2 axisLeft = glm::vec2(0.0f);
glm::vec2 axisRight = glm::vec2(0.0f);
} gamePadState;
/** @brief State of mouse/touch input */
struct {
struct {
bool left = false;
bool right = false;
bool middle = false;
} buttons;
glm::vec2 position;
} mouseState;
VkClearColorValue defaultClearColor = { { 0.025f, 0.025f, 0.025f, 1.0f } };
static std::vector<const char*> args;
// Defines a frame rate independent timer value clamped from -1.0...1.0
// For use in animations, rotations, etc.
float timer = 0.0f;
// Multiplier for speeding up (or slowing down) the global timer
float timerSpeed = 0.25f;
bool paused = false;
Camera camera;
std::string title = "Vulkan Example";
std::string name = "vulkanExample";
uint32_t apiVersion = VK_API_VERSION_1_0;
/** @brief Default depth stencil attachment used by the default render pass */
struct {
VkImage image;
VkDeviceMemory memory;
VkImageView view;
} depthStencil{};
/** @brief Can be used by samples that use the core version of dynamic rendering */
bool useDynamicRendering{ false };
VkPhysicalDeviceDynamicRenderingFeatures baseDynamicRenderingFeatures{ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DYNAMIC_RENDERING_FEATURES, .dynamicRendering = VK_TRUE };
// OS specific
#if defined(_WIN32)
HWND window;
HINSTANCE windowInstance;
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
// true if application has focused, false if moved to background
bool focused = false;
struct TouchPos {
int32_t x;
int32_t y;
} touchPos{};
bool touchDown = false;
double touchTimer = 0.0;
int64_t lastTapTime = 0;
#elif defined (__OHOS__)
NativeResourceManager* s_nativeResourceManager;
bool focused = false;
struct TouchPos {
int32_t x;
int32_t y;
} touchPos;
bool touchDown = false;
double touchTimer = 0.0;
int64_t lastTapTime = 0;
int64_t touchDownTime = 0;
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
void* view;
#if defined(VK_USE_PLATFORM_METAL_EXT)
CAMetalLayer* metalLayer;
#endif
#if defined(VK_EXAMPLE_XCODE_GENERATED)
bool quit = false;
#endif
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
bool quit = false;
IDirectFB *dfb = nullptr;
IDirectFBDisplayLayer *layer = nullptr;
IDirectFBWindow *window = nullptr;
IDirectFBSurface *surface = nullptr;
IDirectFBEventBuffer *event_buffer = nullptr;
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
wl_display *display = nullptr;
wl_registry *registry = nullptr;
wl_compositor *compositor = nullptr;
struct xdg_wm_base *shell = nullptr;
wl_seat *seat = nullptr;
wl_pointer *pointer = nullptr;
wl_keyboard *keyboard = nullptr;
wl_surface *surface = nullptr;
struct xdg_surface *xdg_surface;
struct xdg_toplevel *xdg_toplevel;
bool quit = false;
bool configured = false;
#elif defined(_DIRECT2DISPLAY)
bool quit = false;
#elif defined(VK_USE_PLATFORM_XCB_KHR)
bool quit = false;
xcb_connection_t *connection;
xcb_screen_t *screen;
xcb_window_t window;
xcb_intern_atom_reply_t *atom_wm_delete_window;
#elif defined(VK_USE_PLATFORM_HEADLESS_EXT)
bool quit = false;
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
screen_context_t screen_context = nullptr;
screen_window_t screen_window = nullptr;
screen_event_t screen_event = nullptr;
bool quit = false;
#endif
#if defined(VK_EXAMPLE_PS5)
/** @brief PS5: how the launcher runs this sample (ps5/src/main.cpp); also in the host reference build (ps5/README.md) */
struct {
/** @brief Frames to draw before the sample ends itself; 0: until OPTIONS is pressed */
uint32_t frameBudget = 0;
/** @brief Where the last frame of the budget is saved (a PPM at half the display's size); empty: none */
std::string screenshotPath;
/** @brief Frames drawn so far */
uint32_t framesDrawn = 0;
/** @brief When half the budget had been drawn, and when the last frame began (steady_clock seconds):
* the frame rate after start-up, without the last frame's screenshot */
double halfwayTime = 0.0;
double lastFrameTime = 0.0;
/** @brief The launcher's own menu: ImGui windows of its own instead of the sample's overlay window */
bool ownOverlay = false;
/** @brief OPTIONS ends the sample, back to the menu; in a title with one program, it shows and hides the overlay */
bool optionsEnds = true;
/** @brief A test run's variant of the sample ("uikit.aurora" runs uikit with "aurora"); empty otherwise */
std::string variant;
/** @brief A recording's camera orbit in a test run, degrees a second (test-run.txt's "orbit") */
float orbit = 0.0f;
} ps5;
/** @brief Reads the pad: the sticks move the camera, the buttons drive the UI overlay, OPTIONS ends the sample */
void ps5HandleInput();
#endif
/** @brief Default base class constructor */
VulkanExampleBase();
virtual ~VulkanExampleBase();
/** @brief Setup the vulkan instance, enable required extensions and connect to the physical device (GPU) */
bool initVulkan();
#if defined(_WIN32)
void setupConsole(std::string title);
void setupDPIAwareness();
HWND setupWindow(HINSTANCE hinstance, WNDPROC wndproc);
void handleMessages(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
#elif defined(VK_USE_PLATFORM_ANDROID_KHR)
static int32_t handleAppInput(struct android_app* app, AInputEvent* event);
static void handleAppCommand(android_app* app, int32_t cmd);
#elif defined(VK_USE_PLATFORM_OHOS)
static void handleAppInput(OH_NativeXComponent *component, void *window, void* pThis);
// Static callback for libuv async handle
static void onMessageCallbackStatic(uv_async_t* handle);
// Instance method called from static callback
void onMessageCallback();
#elif (defined(VK_USE_PLATFORM_IOS_MVK) || defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT))
void* setupWindow(void* view);
void displayLinkOutputCb();
void mouseDragged(float x, float y);
void windowWillResize(float x, float y);
void windowDidResize();
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
IDirectFBSurface *setupWindow();
void handleEvent(const DFBWindowEvent *event);
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
struct xdg_surface *setupWindow();
void initWaylandConnection();
void setSize(int width, int height);
static void registryGlobalCb(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version);
void registryGlobal(struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version);
static void registryGlobalRemoveCb(void *data, struct wl_registry *registry, uint32_t name);
static void seatCapabilitiesCb(void *data, wl_seat *seat, uint32_t caps);
void seatCapabilities(wl_seat *seat, uint32_t caps);
static void pointerEnterCb(void *data, struct wl_pointer *pointer, uint32_t serial, struct wl_surface *surface, wl_fixed_t sx, wl_fixed_t sy);
static void pointerLeaveCb(void *data, struct wl_pointer *pointer, uint32_t serial, struct wl_surface *surface);
static void pointerMotionCb(void *data, struct wl_pointer *pointer, uint32_t time, wl_fixed_t sx, wl_fixed_t sy);
void pointerMotion(struct wl_pointer *pointer, uint32_t time, wl_fixed_t sx, wl_fixed_t sy);
static void pointerButtonCb(void *data, struct wl_pointer *wl_pointer, uint32_t serial, uint32_t time, uint32_t button, uint32_t state);
void pointerButton(struct wl_pointer *wl_pointer, uint32_t serial, uint32_t time, uint32_t button, uint32_t state);
static void pointerAxisCb(void *data, struct wl_pointer *wl_pointer, uint32_t time, uint32_t axis, wl_fixed_t value);
void pointerAxis(struct wl_pointer *wl_pointer, uint32_t time, uint32_t axis, wl_fixed_t value);
static void keyboardKeymapCb(void *data, struct wl_keyboard *keyboard, uint32_t format, int fd, uint32_t size);
static void keyboardEnterCb(void *data, struct wl_keyboard *keyboard, uint32_t serial, struct wl_surface *surface, struct wl_array *keys);
static void keyboardLeaveCb(void *data, struct wl_keyboard *keyboard, uint32_t serial, struct wl_surface *surface);
static void keyboardKeyCb(void *data, struct wl_keyboard *keyboard, uint32_t serial, uint32_t time, uint32_t key, uint32_t state);
void keyboardKey(struct wl_keyboard *keyboard, uint32_t serial, uint32_t time, uint32_t key, uint32_t state);
static void keyboardModifiersCb(void *data, struct wl_keyboard *keyboard, uint32_t serial, uint32_t mods_depressed, uint32_t mods_latched, uint32_t mods_locked, uint32_t group);
#elif defined(_DIRECT2DISPLAY)
//
#elif defined(VK_USE_PLATFORM_XCB_KHR)
xcb_window_t setupWindow();
void initxcbConnection();
void handleEvent(const xcb_generic_event_t *event);
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
void setupWindow();
void handleEvent();
#else
void setupWindow();
#endif
/** @brief (Virtual) Creates the application wide Vulkan instance */
virtual VkResult createInstance();
/** @brief (Pure virtual) Render function to be implemented by the sample application */
virtual void render() = 0;
/** @brief (Virtual) Called after a key was pressed, can be used to do custom key handling */
virtual void keyPressed(uint32_t);
/** @brief (Virtual) Called after the mouse cursor moved and before internal events (like camera rotation) is handled */
virtual void mouseMoved(double x, double y, bool &handled);
/** @brief (Virtual) Called when the window has been resized, can be used by the sample application to recreate resources */
virtual void windowResized();
/** @brief (Virtual) Setup default depth and stencil views */
virtual void setupDepthStencil();
/** @brief (Virtual) Setup default framebuffers for all requested swapchain images */
virtual void setupFrameBuffer();
/** @brief (Virtual) Setup a default renderpass */
virtual void setupRenderPass();
/** @brief (Virtual) Called after the physical device features have been read, can be used to set features to enable on the device */
virtual void getEnabledFeatures();
/** @brief (Virtual) Called after the physical device extensions have been read, can be used to enable extensions based on the supported extension listing*/
virtual void getEnabledExtensions();
/** @brief Begin dynamic rendering with the default setup for the base class color and depth attachment */
void beginDynamicRendering(VkCommandBuffer cmdBuffer);
/** @brief End dynamic rendering with the default setup for the base class color and depth attachment */
void endDynamicRendering(VkCommandBuffer cmdBuffer);
/** @brief Prepares all Vulkan resources and functions required to run the sample */
virtual void prepare();
/** @brief Loads a SPIR-V shader file for the given shader stage */
VkPipelineShaderStageCreateInfo loadShader(std::string fileName, VkShaderStageFlagBits stage);
void windowResize();
/** @brief Entry point for the main render loop */
void renderLoop();
/** @brief Adds the drawing commands for the ImGui overlay to the given command buffer */
void drawUI(const VkCommandBuffer commandBuffer);
/** Prepare the next frame for workload submission by acquiring the next swap chain image and waiting for the previous command buffer to finish */
void prepareFrame(bool waitForFence = true);
/** @brief Presents the current image to the swap chain */
void submitFrame(bool skipQueueSubmit = false);
/** @brief (Virtual) Called when the UI overlay is updating, can be used to add custom elements to the overlay */
virtual void OnUpdateUIOverlay(vks::UIOverlay *overlay);
#if defined(_WIN32)
virtual void OnHandleMessage(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
#endif
};
#include "Entrypoints.h"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,442 @@
// RommPS: the credits card and the Codec call. See credits.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "credits.hpp"
#include "avatar_png.h"
#include "core/tween.hpp"
#include "qrcodegen.h"
#include "rommps_api.hpp"
#include "stb_image.h" // implemented by base/VulkanglTFModel.cpp
#include <algorithm>
#include <cmath>
#include <cstring>
#if !defined(PS5_HOST_REFERENCE)
extern "C" int sceSystemServiceLaunchWebBrowser(const char *uri, void *param);
#endif
namespace rommps
{
namespace
{
namespace ui = hui::ui;
namespace tween = hui::tween;
using hui::Action;
using hui::audio::Cue;
using hui::gfx::Align;
using hui::gfx::Rect;
// ------------------------------------------------------------------ the call
enum class Kind
{
say, // a line, typed out
ask, // a yes-or-no question
watch, // a video, in the console's browser
end,
};
struct Step
{
Kind kind;
int speaker; // 0 the author (left), 1 the voice (right)
const char *text; // the line, the question, or the video's title
int next; // the step after (a question's yes)
int no = -1; // a question's no
const char *video = nullptr; // a YouTube id: its embed player in the browser, its page for a phone
};
// Original lines, in the spirit of the famous call; the videos are links.
constexpr Step kScript[] = {
/* 0 */ {Kind::say, 1, "Don't be alarmed. We've been watching your saves for some time now.", 1},
/* 1 */ {Kind::say, 0, "Who is this? How did you get this frequency?", 2},
/* 2 */ {Kind::say, 1, "Every slot you overwrite. Every state you load. Each one a memory, thrown away the moment a "
"better one comes along.", 3},
/* 3 */ {Kind::say, 1, "Left alone, they drift apart. One console remembers the castle you conquered. Another, a "
"door still locked.", 4},
/* 4 */ {Kind::say, 0, "So you keep them in sync.", 5},
/* 5 */ {Kind::say, 1, "We give them context. One truth, kept on a server of your choosing.", 6},
/* 6 */ {Kind::say, 0, "And when two truths disagree?", 7},
/* 7 */ {Kind::say, 1, "Then you decide. We don't choose what you remember.", 8},
/* 8 */ {Kind::say, 1, "We only make sure you never lose it.", 9},
/* 9 */ {Kind::say, 0, "...", 10},
/* 10 */ {Kind::say, 1, "One more thing.", 11},
/* 11 */ {Kind::ask, 1, "Do you want to know the truth about the Patriots AI?", 12, 18},
/* 12 */ {Kind::say, 1, "Then listen carefully. And remember what you hear.", 13},
/* 13 */ {Kind::watch, 1, "The truth about the Patriots AI", 14, -1, "C31XYgr8gp0"},
/* 14 */ {Kind::say, 1, "Now you know what we are. Or what we were meant to be.", 15},
/* 15 */ {Kind::ask, 1, "Do you want to learn more about AI altogether?", 16, 19},
/* 16 */ {Kind::watch, 1, "Learn more about AI", 17, -1, "-gGLvg0n-uY"},
/* 17 */ {Kind::say, 1, "Stay curious. That's what keeps a memory alive.", 20},
/* 18 */ {Kind::say, 1, "Then forget we ever spoke. Your saves are waiting.", 20},
/* 19 */ {Kind::say, 1, "Then go play. We'll keep your saves safe.", 20},
/* 20 */ {Kind::end, 1, "", 20},
};
constexpr float kRing = 2.4f; // seconds the call rings
constexpr float kOpen = 0.45f; // the screen opening and closing
constexpr float kCharsPerSecond = 34.0f;
const Color kGreen = Color::rgb(0x8dffb0);
const Color kDimGreen = Color::rgb(0x2f8f55);
const Color kDark = Color::rgb(0x020805);
// Opens a link in the console's web browser; RommPS keeps running behind it.
bool open_in_browser(const char *url)
{
#if defined(PS5_HOST_REFERENCE)
(void)url;
return false;
#else
return sceSystemServiceLaunchWebBrowser(url, nullptr) == 0;
#endif
}
float hold_for(const char *text)
{
return 1.4f + static_cast<float>(std::strlen(text)) / 28.0f;
}
// A cheap hash for the static.
float noise(int x, int y, int frame)
{
std::uint32_t h = static_cast<std::uint32_t>(x * 374761393 + y * 668265263 + frame * 2147483647);
h = (h ^ (h >> 13)) * 1274126177u;
return static_cast<float>((h ^ (h >> 16)) & 0xffff) / 65535.0f;
}
void draw_portrait_frame(hui::gfx::DrawList &list, const Rect &r, bool speaking, float clock)
{
const float glow = speaking ? 0.55f + 0.25f * std::sin(clock * 9.0f) : 0.25f;
list.glow(r, 6, 26, kGreen.with_alpha(0.25f * glow));
list.bordered_rect(r.inset(-8), 4, Color::rgb(0x000000, 0.0f), 3, kGreen.with_alpha(0.35f + 0.5f * glow));
}
} // namespace
// ------------------------------------------------------------------ QR
QrCode QrCode::encode(const std::string &text)
{
QrCode out;
std::vector<std::uint8_t> qr(qrcodegen_BUFFER_LEN_MAX), tmp(qrcodegen_BUFFER_LEN_MAX);
if (!qrcodegen_encodeText(text.c_str(), tmp.data(), qr.data(), qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, true))
return out;
out.size = qrcodegen_getSize(qr.data());
out.modules.resize(static_cast<std::size_t>(out.size * out.size));
for (int y = 0; y < out.size; ++y)
for (int x = 0; x < out.size; ++x)
out.modules[static_cast<std::size_t>(y * out.size + x)] = qrcodegen_getModule(qr.data(), x, y);
return out;
}
void draw_qr(hui::gfx::DrawList &list, const QrCode &qr, const Rect &rect)
{
list.rounded_rect(rect, 10, Color::rgb(0xffffff));
if (qr.size == 0)
return;
const float cell = std::floor(rect.w / static_cast<float>(qr.size + 4)); // two modules of quiet zone a side
const float x0 = rect.x + std::floor((rect.w - cell * static_cast<float>(qr.size)) * 0.5f);
const float y0 = rect.y + std::floor((rect.h - cell * static_cast<float>(qr.size)) * 0.5f);
const Color ink = Color::rgb(0x000000);
for (int y = 0; y < qr.size; ++y)
{
// Runs of dark modules as one rectangle each.
int x = 0;
while (x < qr.size)
{
if (!qr.dark(x, y))
{
++x;
continue;
}
int end = x;
while (end < qr.size && qr.dark(end, y))
++end;
list.rounded_rect({x0 + static_cast<float>(x) * cell, y0 + static_cast<float>(y) * cell,
static_cast<float>(end - x) * cell, cell},
0, ink);
x = end;
}
}
}
// ------------------------------------------------------------------ credits
std::uint32_t avatar_texture(hui::gfx::Renderer *renderer)
{
static std::uint32_t texture = 0;
static bool tried = false;
if (texture || tried || !renderer)
return texture;
tried = true;
int w = 0, h = 0, channels = 0;
unsigned char *rgba = stbi_load_from_memory(kAvatarPng, static_cast<int>(sizeof kAvatarPng), &w, &h, &channels, 4);
if (rgba)
{
texture = renderer->create_texture(w, h, rgba);
stbi_image_free(rgba);
}
return texture;
}
void draw_credits(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const Rect &rect,
bool focused, float clock)
{
static const QrCode qr = QrCode::encode(kSourceUrl);
const Rect avatar{rect.x, rect.y, 128, 128};
if (focused)
{
list.glow(avatar, 64, 26, look.accent.with_alpha(0.45f + 0.2f * std::sin(clock * 4.0f)));
list.bordered_rect(avatar.inset(-7), 70, Color::rgb(0x000000, 0.0f), 4, look.focus.with_alpha(1.0f));
}
if (const std::uint32_t texture = avatar_texture(nullptr))
list.image(texture, avatar, hui::gfx::kFullUv, Color::rgb(0xffffff), 64);
else
list.circle(avatar.cx(), avatar.cy(), 64, look.panel_muted.with_alpha(0.3f));
const float tx = avatar.x + avatar.w + 24;
ui::text(list, fonts.regular, "Made with love", tx, rect.y + 52, 22, look.panel_muted);
ui::text(list, fonts.semibold, "by s0liton", tx, rect.y + 86, 26, look.panel_text);
ui::text(list, fonts.regular, focused ? "Press Cross" : "", tx, rect.y + 120, 18, look.accent);
const float q = rect.h;
draw_qr(list, qr, {rect.x + rect.w - q, rect.y, q, q});
}
// ------------------------------------------------------------------ the call
void CodecCall::start()
{
active_ = true;
age_ = 0.0f;
step_ = -1;
step_age_ = 0.0f;
typed_ = 0;
choice_ = 0;
opened_ = false;
closing_ = -1.0f;
}
void CodecCall::go(int step, hui::ui::Feedback &feedback)
{
step_ = step;
step_age_ = 0.0f;
typed_ = 0;
choice_ = 0;
opened_ = false;
const Step &s = kScript[step];
if (s.kind == Kind::end)
{
feedback.play(Cue::modal_close);
closing_ = 0.0f;
}
else if (s.kind == Kind::watch)
{
// The payload's page hosting YouTube's player, filling the browser
// (an embed opened on its own fails with YouTube's error 153).
const std::string page = "http://127.0.0.1:" + std::to_string(payload_port()) + "/video?v=" + s.video;
opened_ = open_in_browser(page.c_str());
feedback.play(Cue::launch);
}
}
void CodecCall::update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
if (!active_)
return;
age_ += dt;
if (closing_ >= 0.0f)
{
closing_ += dt;
if (closing_ >= kOpen)
active_ = false;
return;
}
if (input.is_pressed(Action::back))
{
feedback.play(Cue::modal_close);
closing_ = 0.0f;
return;
}
if (step_ < 0)
{
// Ringing: the call's sound once (RommPS's notify cue), then the line opens.
if (age_ - dt <= 0.0f)
feedback.play(Cue::notify, 1.0f, 0.0f, 0.8f);
if (age_ >= kRing || input.is_pressed(Action::confirm))
{
go(0, feedback);
feedback.play(Cue::open, 1.3f);
}
return;
}
const Step &s = kScript[step_];
step_age_ += dt;
if (s.kind == Kind::watch)
{
// Back from the browser (or done with the phone): Cross carries on.
if (input.is_pressed(Action::confirm) && step_age_ > 0.5f)
{
feedback.play(Cue::open, 1.3f);
go(s.next, feedback);
}
return;
}
const int length = static_cast<int>(std::strlen(s.text));
const int typed = std::min(length, static_cast<int>(step_age_ * kCharsPerSecond));
if (typed / 3 != typed_ / 3 && typed < length)
feedback.play(Cue::tick, s.speaker ? 0.8f : 1.25f, s.speaker ? 0.4f : -0.4f, 0.35f);
typed_ = typed;
const float done_at = static_cast<float>(length) / kCharsPerSecond;
const bool confirm = input.is_pressed(Action::confirm);
if (typed_ < length)
{
if (confirm)
{
step_age_ = done_at; // show it all first
typed_ = length;
}
return;
}
if (s.kind == Kind::ask)
{
if (input.nav == hui::Direction::left || input.nav == hui::Direction::right)
{
const int next = input.nav == hui::Direction::left ? 0 : 1;
if (next != choice_)
{
choice_ = next;
feedback.play(Cue::focus, next ? 0.9f : 1.1f);
}
}
if (confirm)
{
feedback.play(Cue::select);
go(choice_ == 0 ? s.next : s.no, feedback);
}
return;
}
if (confirm || step_age_ > done_at + hold_for(s.text))
go(s.next, feedback);
}
void CodecCall::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, float clock) const
{
if (!active_)
return;
const float open = closing_ >= 0.0f ? 1.0f - tween::clamp01(closing_ / kOpen) : tween::clamp01(age_ / kOpen);
const float w = hui::gfx::kVirtualWidth, h = hui::gfx::kVirtualHeight;
list.rounded_rect({0, 0, w, h}, 0, Color::rgb(0x000000, std::min(1.0f, open * 3.0f)));
// A CRT opening from its middle line.
const float shown = h * tween::cubic_out(open);
list.push_clip({0, (h - shown) * 0.5f, w, std::max(2.0f, shown)});
list.rounded_rect({0, 0, w, h}, 0, kDark);
const bool ringing = step_ < 0;
const Step *step = ringing ? nullptr : &kScript[step_];
const int speaker = ringing ? -1 : step->speaker;
const int frame = static_cast<int>(clock * 24.0f);
// Left: the author.
const Rect left{230, 170, 330, 420};
draw_portrait_frame(list, left, speaker == 0, clock);
if (const std::uint32_t texture = avatar_texture(nullptr))
list.image(texture, left, hui::gfx::kFullUv, Color::rgb(0xc8ffd8, speaker == 0 || ringing ? 1.0f : 0.75f), 0);
ui::text(list, fonts.mono, "S0LITON", left.cx(), left.y + left.h + 50, 26, kGreen, Align::center, 4.0f);
// Right: no face, only static.
const Rect right{1360, 170, 330, 420};
draw_portrait_frame(list, right, speaker == 1, clock);
if (!ringing)
{
constexpr int kCols = 22, kRows = 28;
const float cw = right.w / kCols, ch = right.h / kRows;
const float level = speaker == 1 ? 0.95f : 0.55f;
for (int y = 0; y < kRows; ++y)
for (int x = 0; x < kCols; ++x)
{
const float n = noise(x, y, frame);
list.rounded_rect({right.x + static_cast<float>(x) * cw, right.y + static_cast<float>(y) * ch, cw, ch}, 0,
Color{0.35f * n, 0.9f * n, 0.5f * n, level});
}
}
ui::text(list, fonts.mono, "???", right.cx(), right.y + right.h + 50, 26, kGreen, Align::center, 4.0f);
// Middle: the frequency, and a meter for whoever speaks.
const Rect panel{660, 230, 600, 300};
list.bordered_rect(panel, 8, Color::rgb(0x041a0c), 2, kDimGreen);
ui::text(list, fonts.mono, "PTT", panel.x + 30, panel.y + 48, 22, kDimGreen, Align::left, 3.0f);
ui::text(list, fonts.mono, "MEMORY", panel.x + panel.w - 30, panel.y + 48, 22, kDimGreen, Align::right, 3.0f);
const bool blink = ringing && std::fmod(clock, 0.8f) < 0.4f;
ui::text(list, fonts.mono, "140.85", panel.cx(), panel.y + 190, 112, blink ? kGreen.with_alpha(0.35f) : kGreen,
Align::center, 4.0f);
if (ringing)
ui::text(list, fonts.mono, "CALL", panel.cx(), panel.y + 262, 30, blink ? kGreen : kDimGreen, Align::center, 8.0f);
else
{
// Bars on the speaker's side, moving while the line is typed.
const bool talking = step->kind != Kind::watch && typed_ < static_cast<int>(std::strlen(step->text));
for (int i = 0; i < 12; ++i)
{
const float t = talking ? 0.3f + 0.7f * noise(i, speaker, frame / 2) : 0.15f;
const float bh = 10.0f + 60.0f * t;
const float bx = speaker == 0 ? panel.x + 40 + static_cast<float>(i) * 18 : panel.x + panel.w - 40 - 12 -
static_cast<float>(i) * 18;
list.rounded_rect({bx, panel.y + panel.h - 24 - bh, 12, bh}, 2, kGreen.with_alpha(0.4f + 0.6f * t));
}
}
// The words.
if (step && step->kind == Kind::watch)
{
// The video: in the browser, and on a phone through the QR code.
static const char *qr_for = nullptr;
static QrCode qr;
if (qr_for != step->video)
{
qr_for = step->video;
qr = QrCode::encode(std::string("https://www.youtube.com/watch?v=") + step->video);
}
ui::text(list, fonts.mono, "INCOMING TRANSMISSION", 260, 700, 24, kGreen, Align::left, 4.0f);
ui::text(list, fonts.regular, step->text, 260, 770, 40, Color::rgb(0xe9fff0));
ui::paragraph(list, fonts.regular,
opened_ ? "Playing in the browser. Close it to come back here, then press Cross."
: "The browser didn't open. Scan the code to watch on your phone, then press Cross.",
260, 840, 28, 1050, 40, kGreen.with_alpha(0.85f), 3);
draw_qr(list, qr, {1420, 650, 260, 260});
ui::text(list, fonts.mono, "SCAN TO WATCH", 1550, 950, 18, kDimGreen, Align::center, 3.0f);
}
else if (step && step->kind != Kind::end)
{
ui::text(list, fonts.mono, step->speaker ? "???" : "S0LITON", 260, 760, 26, kGreen, Align::left, 3.0f);
const std::string text(step->text, static_cast<std::size_t>(typed_));
ui::paragraph(list, fonts.regular, text, 260, 820, 36, 1400, 50, Color::rgb(0xe9fff0), 3);
if (step->kind == Kind::ask && typed_ >= static_cast<int>(std::strlen(step->text)))
{
const char *answers[] = {"YES", "NO"};
for (int i = 0; i < 2; ++i)
{
const Rect r{260.0f + static_cast<float>(i) * 260.0f, 880, 220, 70};
const bool on = i == choice_;
list.bordered_rect(r, 6, on ? kGreen : Color::rgb(0x041a0c), 3, on ? kGreen : kDimGreen);
ui::text(list, fonts.mono, answers[i], r.cx(), r.cy() + 12, 32, on ? kDark : kGreen, Align::center, 6.0f);
}
}
}
const char *keys = step && step->kind == Kind::ask ? "LEFT RIGHT CHOOSE CROSS ANSWER CIRCLE HANG UP"
: step && step->kind == Kind::watch ? "CROSS CONTINUE CIRCLE HANG UP"
: "CIRCLE HANG UP CROSS NEXT";
ui::text(list, fonts.mono, keys, w - 120, h - 60, 20, kDimGreen, Align::right, 2.0f);
// Scanlines over it all.
for (float y = 0; y < h; y += 4.0f)
list.rounded_rect({0, y, w, 1.5f}, 0, Color::rgb(0x000000, 0.28f));
list.pop_clip();
}
} // namespace rommps
@@ -0,0 +1,67 @@
// RommPS: the credits card in Settings (who made it, a QR code to its
// source) and the Codec call behind the author's portrait.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "core/input.hpp"
#include "gfx/renderer.hpp"
#include "rommps_look.hpp"
#include "ui/feedback.hpp"
#include "ui/fonts.hpp"
#include <cstdint>
#include <string>
#include <vector>
namespace rommps
{
inline constexpr const char *kSourceUrl = "https://github.com/s0liton/RommPS";
// A QR code's modules, true for dark. Empty if the text didn't fit.
struct QrCode
{
int size = 0;
std::vector<bool> modules;
static QrCode encode(const std::string &text);
bool dark(int x, int y) const { return modules[static_cast<std::size_t>(y * size + x)]; }
};
// Draws a QR code on a white plate (its quiet zone included) filling rect.
void draw_qr(hui::gfx::DrawList &list, const QrCode &qr, const hui::gfx::Rect &rect);
// The author's portrait, as a texture once the renderer is there (0 before).
std::uint32_t avatar_texture(hui::gfx::Renderer *renderer);
void draw_credits(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const hui::gfx::Rect &rect,
bool focused, float clock);
// A call on 140.85: the author's portrait, a voice with no face, and a
// conversation about what a save file is, an original homage to a famous
// Codec call. It plays by itself; Cross skips a line, Circle hangs up. At the
// end it asks two questions, and a yes opens a video in the console's browser
// (with its QR code on screen, for a phone); back from the browser, Cross
// carries on.
class CodecCall
{
public:
void start();
bool active() const { return active_; }
void update(const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, float clock) const;
private:
void go(int step, hui::ui::Feedback &feedback);
bool active_ = false;
float age_ = 0.0f; // since the call started
int step_ = -1; // -1 while it rings
float step_age_ = 0.0f;
int typed_ = 0; // characters of the line shown
int choice_ = 0; // a question's answer: 0 yes, 1 no
bool opened_ = false; // the browser took the video
float closing_ = -1.0f;
};
} // namespace rommps
@@ -0,0 +1,999 @@
// RommPS: the Library tab. See library_page.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "library_page.hpp"
#include "ui/components/component.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdio>
namespace rommps
{
namespace
{
namespace ui = hui::ui;
namespace tween = hui::tween;
using hui::Action;
using hui::Direction;
using hui::audio::Cue;
using hui::gfx::Align;
using hui::gfx::Rect;
constexpr float kMargin = 96.0f;
// The platform tiles start right under the page's heading.
constexpr float kPlatformTop = 196.0f;
// Platform tiles
constexpr int kTileCols = 4;
constexpr float kTileW = 408.0f, kTileH = 220.0f, kTileGap = 32.0f, kTilePitch = kTileH + kTileGap;
// A grid of games: its columns and cards follow the shape of its covers
constexpr int kMinCols = 4, kMaxCols = 8;
constexpr float kGridTop = 400.0f, kGridW = 1620.0f, kCardTallest = 235.0f, kColGap = 24.0f, kRowGap = 25.0f;
// Search results, in a row
constexpr float kResultH = 224.0f, kResultGap = 24.0f, kResultsEnd = 1824.0f;
constexpr float kBarX = 1726.0f, kBarW = 98.0f, kBarTop = 392.0f, kBarH = 600.0f;
// The A to Z bar's slots: # (digits and the rest) then A to Z.
constexpr int kSlots = 27;
constexpr float kSearchDelay = 0.35f; // seconds after the last key before searching
using Slots = std::array<int, kSlots>; // each slot's first game, -1 for none
int slot_of(const std::string &key)
{
const char c = key.empty() ? '#' : key[0];
if (c >= 'a' && c <= 'z')
return 1 + (c - 'a');
if (c >= 'A' && c <= 'Z')
return 1 + (c - 'A');
return 0;
}
Slots slots_of(const GameList &l)
{
Slots s;
s.fill(-1);
for (const auto &[key, offset] : l.letters)
{
int &first = s[static_cast<std::size_t>(slot_of(key))];
if (first < 0 || offset < first)
first = offset;
}
return s;
}
char slot_char(int slot)
{
return slot == 0 ? '#' : static_cast<char>('A' + slot - 1);
}
void pop_utf8(std::string &s)
{
while (!s.empty() && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80)
s.pop_back();
if (!s.empty())
s.pop_back();
}
// The end of a text that fits a width.
std::string tail(const hui::gfx::Font &font, const std::string &text, float size, float width)
{
// A character at a time: a cut inside one would draw as "?"
std::size_t from = 0;
while (from < text.size() && font.measure(std::string_view(text).substr(from), size) > width)
from = char_boundary(text, from + 1);
return from ? "\xE2\x80\xA6" + text.substr(char_boundary(text, from + 1)) : text;
}
struct GridLayout
{
float shape; // of the cards, width over height
int cols;
float card_w, card_h, col_pitch, row_pitch;
};
// As many columns as cards of the grid's shape fit across (8 of 3:4 covers,
// 6 of square ones, 5 of the wide SNES and N64 boxes), the cards a little
// shorter when that fills the width better. Every card the same size.
GridLayout grid_layout(const GameList &l)
{
const float shape = std::clamp(l.shape > 0 ? l.shape : kPortrait, 0.7f, kWidest);
const int cols = std::clamp(static_cast<int>(std::lround((kGridW + kColGap) / (kCardTallest * shape + kColGap))),
kMinCols, kMaxCols);
const float w = std::min(kCardTallest * shape, (kGridW - kColGap * static_cast<float>(cols - 1)) / static_cast<float>(cols));
return {shape, cols, w, w / shape, w + kColGap, w / shape + kRowGap};
}
// A cover in a grid's card: its own shape, centred. One much wider than the
// card keeps most of the card's height, cropped at the sides.
Rect in_card(float shape, const Rect &card)
{
Rect r = fit_cover(shape, card);
const float least = card.h * 0.75f;
if (r.h < least)
r.y = card.y + (card.h - least) * 0.5f, r.h = least;
return r;
}
} // namespace
// ------------------------------------------------------------------ shared
std::string human_size(double bytes)
{
char text[32];
const char *units[] = {"B", "KB", "MB", "GB", "TB"};
int unit = 0;
while (bytes >= 1024.0 && unit < 4)
{
bytes /= 1024.0;
++unit;
}
std::snprintf(text, sizeof text, unit ? "%.1f %s" : "%.0f %s", bytes, units[unit]);
return text;
}
std::string details_line(const Details &d)
{
std::string out;
auto add = [&](const std::string &part) {
if (!part.empty())
out += (out.empty() ? "" : " \xC2\xB7 ") + part;
};
if (d.year)
add(std::to_string(d.year));
add(d.publishers.empty() ? d.developers : d.publishers);
add(d.genres);
return out;
}
std::string play_time(double ms)
{
const int minutes = static_cast<int>(ms / 60000.0 + 0.5);
char text[32];
if (minutes < 60)
std::snprintf(text, sizeof text, "%d min", std::max(1, minutes));
else if (minutes % 60 == 0)
std::snprintf(text, sizeof text, "%d h", minutes / 60);
else
std::snprintf(text, sizeof text, "%d h %d min", minutes / 60, minutes % 60);
return text;
}
float cover_shape(App &app, const Game *g, float fallback)
{
double since = 0;
const float own = g && !g->cover.empty() ? app.cover_aspect(g->cover, &since) : 0.0f;
if (own <= 0)
return fallback;
const float t = tween::smoothstep(tween::clamp01(static_cast<float>(app.clock() - since) / 0.3f));
return fallback + (std::clamp(own, kNarrowest, kWidest) - fallback) * t;
}
Rect fit_cover(float shape, const Rect &box)
{
shape = std::clamp(shape, kNarrowest, kWidest);
float w = box.h * shape, h = box.h;
if (w > box.w)
w = box.w, h = box.w / shape;
return {box.x + (box.w - w) * 0.5f, box.y + (box.h - h) * 0.5f, w, h};
}
bool draw_logo(hui::gfx::DrawList &list, App &app, const std::string &slug, const Rect &box, float alpha)
{
const Cover &logo = app.platform_logo(slug);
if (!logo.texture)
return false;
list.image(logo.texture, fit_cover(logo.aspect > 0 ? logo.aspect : 1.0f, box), hui::gfx::kFullUv,
Color::rgb(0xFFFFFF, alpha));
return true;
}
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
const Rect &rect, float alpha, float radius, bool name)
{
static const Cover kNone;
const Cover &c = g ? app.cover(g->cover) : kNone;
if (c.texture)
{
// The middle of the cover in the rectangle's shape: cropped, never stretched.
Rect uv = hui::gfx::kFullUv;
const float want = rect.h > 0 ? rect.w / rect.h : 1.0f;
if (c.aspect > want)
uv.w = want / c.aspect, uv.x = (1.0f - uv.w) * 0.5f;
else if (c.aspect > 0 && c.aspect < want)
uv.h = c.aspect / want, uv.y = (1.0f - uv.h) * 0.5f;
list.image(c.texture, rect, uv, Color::rgb(0xffffff, alpha), radius);
return;
}
// No cover (yet): a tinted card with the name.
list.rounded_rect(rect, radius, look.covers ? Color::rgb(0x1c2140, 0.75f * alpha) : look.panel.with_alpha(look.panel.a * alpha));
list.bordered_rect(rect, radius, Color::rgb(0x000000, 0.0f), 1.5f, look.outline.with_alpha(look.outline.a * alpha));
if (g && name)
ui::paragraph(list, fonts.semibold, g->name, rect.x + 16, rect.y + 44, rect.w > 160 ? 22.0f : 18.0f, rect.w - 32,
rect.w > 160 ? 30.0f : 25.0f, look.panel_text.with_alpha(0.75f * alpha), 4);
}
void draw_badge(hui::gfx::DrawList &list, const Look &look, App &app, const Game *g, const Rect &rect, Color accent)
{
if (!g)
return;
const Download *d = app.download_for(g->id);
if (d && d->active())
{
const Rect bar{rect.x + 12, rect.y + rect.h - 20, rect.w - 24, 8};
list.rounded_rect(bar, 4, Color::rgb(0x000000, 0.55f));
list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent);
}
else if (app.on_console(*g))
{
const float cx = rect.x + rect.w - 24, cy = rect.y + 24;
list.circle(cx, cy, 14, look.success);
list.line(cx - 6, cy + 1, cx - 2, cy + 5, 3.2f, Color::rgb(0x0b0d16));
list.line(cx - 2, cy + 5, cx + 6, cy - 5, 3.2f, Color::rgb(0x0b0d16));
}
}
void draw_version(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &, const Game *g,
const Rect &rect)
{
if (!g || g->version.empty())
return;
const float size = rect.w > 160 ? 17.0f : 15.0f, pad = 10;
const std::string text = fonts.semibold.font->fit(g->version, size, rect.w - 24 - 2 * pad);
const float w = fonts.semibold.font->measure(text, size) + 2 * pad;
const Rect tag{rect.x + 12, rect.y + rect.h - size - 30, w, size + 14};
list.rounded_rect(tag, tag.h * 0.5f, Color::rgb(0x0b0d16, 0.78f));
ui::text(list, fonts.semibold, text, tag.x + pad, tag.y + size + 3, size, Color::rgb(0xffffff));
}
// ------------------------------------------------------------------ the page
LibraryPage::LibraryPage()
{
keys_.style.bindings = hui::ui::KeyboardBindings::standard();
keys_.style.bindings.layout = Action::count;
keys_.style.bindings.done = Action::jump_next; // R2 shows the results
keys_.style.done_label = "Search";
keys_.style.max_length = 60;
keys_.on_text = [this](std::string_view text) {
query_ += text;
typed_age_ = 0.0f;
};
keys_.on_backspace = [this] {
pop_utf8(query_);
typed_age_ = 0.0f;
};
keys_.set_bounds({360, 610, 1200, 370});
}
void LibraryPage::enter(App &)
{
mode_age_ = 0.0f;
if (mode_ == kSearch)
mode_ = before_search_;
}
void LibraryPage::refuse(hui::ui::Feedback &feedback)
{
feedback.play(Cue::error, 1.0f, 0.0f, 0.6f);
feedback.rumble(0.25f, 0.05f);
nudge_.trigger();
}
const Game *LibraryPage::focused(App &app) const
{
if (mode_ == kGrid)
return grid(app).at(index_);
return nullptr;
}
std::string LibraryPage::backdrop_cover(App &app) const
{
if (const Game *g = focused(app))
return g->cover;
if (mode_ == kPlatforms)
{
const auto &platforms = app.platforms();
if (tile_ >= 0 && tile_ < static_cast<int>(platforms.size()))
if (const Game *first = app.games(platforms[static_cast<std::size_t>(tile_)].id, "").at(0))
return first->cover;
}
return "";
}
bool LibraryPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
mode_age_ += dt;
shown_age_ += dt;
nudge_.update(dt, 9.0f);
bool details = false;
switch (mode_)
{
case kPlatforms:
update_platforms(app, input, feedback);
break;
case kGrid:
update_grid(app, input, feedback, details);
break;
case kSearch:
update_search(app, input, dt, feedback);
break;
}
// Scrolling.
const int tile_rows = std::max(1, static_cast<int>((1000.0f - kPlatformTop - 24.0f) / kTilePitch));
const int tile_row = tile_ / kTileCols;
if (tile_row < tiles_scroll_.target)
tiles_scroll_.target = static_cast<float>(tile_row);
if (tile_row > tiles_scroll_.target + static_cast<float>(tile_rows - 1))
tiles_scroll_.target = static_cast<float>(tile_row - tile_rows + 1);
tiles_scroll_.update(dt, 14.0f);
if (mode_ == kGrid)
grid_scroll_.target = static_cast<float>(std::max(0, index_ / grid_layout(grid(app)).cols - 1));
grid_scroll_.update(dt, 14.0f);
const Game *g = focused(app);
const int id = g ? g->id : -1;
if (id != shown_id_)
{
shown_id_ = id;
shown_age_ = 0.0f;
}
return details;
}
// ------------------------------------------------------------------ platforms
void LibraryPage::update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback)
{
const auto &platforms = app.platforms();
const int ntiles = static_cast<int>(platforms.size());
tile_ = std::clamp(tile_, 0, std::max(0, ntiles - 1));
// The covers of the tiles in view.
const int first_row = static_cast<int>(tiles_scroll_.value);
for (int i = first_row * kTileCols; i < std::min(ntiles, (first_row + 3) * kTileCols); ++i)
app.ensure(app.games(platforms[static_cast<std::size_t>(i)].id, ""), 0, 0);
const bool repeat = input.nav_repeat;
switch (input.nav)
{
case Direction::left:
case Direction::right:
{
const int col = tile_ % kTileCols;
const int next = tile_ + (input.nav == Direction::right ? 1 : -1);
if ((input.nav == Direction::right ? col < kTileCols - 1 : col > 0) && next >= 0 && next < ntiles)
{
tile_ = next;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
}
case Direction::up:
if (tile_ - kTileCols >= 0)
{
tile_ -= kTileCols;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::down:
if (tile_ + kTileCols < ntiles)
{
tile_ += kTileCols;
feedback.play(Cue::focus);
}
else if (tile_ / kTileCols < (ntiles - 1) / kTileCols)
{
tile_ = ntiles - 1;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::none:
break;
}
if (input.is_pressed(Action::confirm) && ntiles > 0)
{
feedback.play(Cue::open);
open_grid(app, platforms[static_cast<std::size_t>(tile_)].id, "");
}
if (input.is_pressed(Action::north))
{
feedback.play(Cue::modal_open);
open_search(0);
}
}
void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app,
Color accent, float clock, float age) const
{
const auto &platforms = app.platforms();
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
// Platforms
const float top = kPlatformTop;
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
list.push_opacity(in);
char label[64];
std::snprintf(label, sizeof label, "Platforms \xC2\xB7 %d", static_cast<int>(platforms.size()));
ui::text(list, fonts.semibold, label, kMargin, top, 24, look.text);
if (platforms.empty())
ui::text(list, fonts.regular, "Loading your platforms", kMargin, top + 60, 26, look.muted);
const float grid_top = top + 24;
list.push_clip({0, grid_top - 20, 1920, 1080 - grid_top + 20});
for (int i = 0; i < static_cast<int>(platforms.size()); ++i)
{
const Platform &p = platforms[static_cast<std::size_t>(i)];
const int row = i / kTileCols, col = i % kTileCols;
const float y = grid_top + (static_cast<float>(row) - tiles_scroll_.value) * kTilePitch;
if (y > 1080 || y + kTileH < grid_top - 20)
continue;
const bool on = i == tile_;
Rect r{kMargin + static_cast<float>(col) * (kTileW + kTileGap) + (on ? shake : 0.0f), y, kTileW, kTileH};
if (on)
list.glow(r, 26, 26, accent.with_alpha(0.4f));
draw_panel(list, look, r, 26);
// A few of its covers, fanned on the right.
GameList &games = app.games(p.id, "");
for (int k = 2; k >= 0; --k)
{
const Game *g = games.at(k);
if (!g)
continue;
// Each its own shape, as tall as fits 120 wide, standing on the same line.
const float shape = cover_shape(app, g);
const float h = std::min(133.0f, 120.0f / shape), w = h * shape;
const Rect c{r.x + r.w - 28 - w - static_cast<float>(k) * 40, r.y + 161 + static_cast<float>(k) * 10 - h, w, h};
list.shadow({c.x, c.y + 8, c.w, c.h}, 10, 16, Color::rgb(0x000000, 0.35f));
draw_cover(list, fonts, look, app, g, c, 1.0f - 0.15f * static_cast<float>(k), 10, false);
}
// The system's logo, small, in the corner under the covers.
draw_logo(list, app, p.slug, {r.x + r.w - 28 - 30, r.y + 172, 30, 30}, 0.9f);
ui::paragraph(list, fonts.semibold, p.name, r.x + 28, r.y + 56, 28, 165, 34, look.panel_text, 2);
char count[48];
std::snprintf(count, sizeof count, p.rom_count == 1 ? "%d game" : "%d games", p.rom_count);
ui::text(list, fonts.regular, count, r.x + 28, r.y + 150, 22, look.panel_muted);
ui::text(list, fonts.semibold, fonts.semibold.font->fit(p.profile.empty() ? "No emulator" : p.profile, 18, 200),
r.x + 28, r.y + 190, 18, p.profile.empty() ? look.panel_muted.with_alpha(0.6f) : accent, Align::left,
1.5f);
if (on)
list.bordered_rect(r.inset(-6), 30, Color::rgb(0x000000, 0.0f), 4, look.focus.with_alpha(1.0f));
}
list.pop_clip();
list.pop_opacity();
}
// ------------------------------------------------------------------ a grid
void LibraryPage::open_grid(App &app, int platform_id, const std::string &search)
{
grid_platform_ = platform_id;
grid_search_ = search;
mode_ = kGrid;
mode_age_ = 0.0f;
index_ = 0;
on_letters_ = false;
grid_scroll_.snap(0.0f);
app.ensure(grid(app), 0, kMaxCols * 4);
}
int LibraryPage::letter_at(const GameList &l, int index) const
{
const Slots s = slots_of(l);
int best = -1, best_offset = -1;
for (int i = 0; i < kSlots; ++i)
if (s[static_cast<std::size_t>(i)] >= 0 && s[static_cast<std::size_t>(i)] <= index &&
s[static_cast<std::size_t>(i)] >= best_offset)
{
best = i;
best_offset = s[static_cast<std::size_t>(i)];
}
return best;
}
void LibraryPage::jump_letter(GameList &l, int slot, hui::ui::Feedback &feedback)
{
const Slots s = slots_of(l);
if (slot < 0 || slot >= kSlots || s[static_cast<std::size_t>(slot)] < 0)
{
refuse(feedback);
return;
}
index_ = std::min(s[static_cast<std::size_t>(slot)], std::max(0, l.total - 1));
feedback.play(Cue::focus);
}
void LibraryPage::update_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details)
{
GameList &l = grid(app);
if (&l == focus_list_ && l.hidden.size() != focus_hidden_)
index_ = l.shown(focus_raw_);
if (l.ready())
index_ = std::min(index_, std::max(0, l.total - 1)); // the list can come out shorter than first thought
settle_shape(app, l);
move_in_grid(app, input, feedback, details);
GameList &now = grid(app);
focus_list_ = &now;
focus_raw_ = now.ready() ? now.raw(index_) : index_;
focus_hidden_ = now.hidden.size();
}
void LibraryPage::settle_shape(App &app, GameList &l) const
{
if (l.shape_settled || !l.ready())
return;
// The covers of the first rows: the grid takes their middle shape.
std::vector<float> seen;
int wanted = 0;
for (int i = 0; i < std::min(l.total, 2 * kMaxCols); ++i)
if (const Game *g = l.at(i); g && !g->cover.empty())
{
++wanted;
if (const float a = app.cover_aspect(g->cover); a > 0)
seen.push_back(std::clamp(a, kNarrowest, kWidest));
}
if (!seen.empty())
{
std::nth_element(seen.begin(), seen.begin() + static_cast<std::ptrdiff_t>(seen.size() / 2), seen.end());
l.shape = seen[seen.size() / 2];
}
// It settles once those covers are all here, or enough of them are and
// the grid is in use, so it never changes under the focus.
const int known = static_cast<int>(seen.size());
l.shape_settled = known >= wanted || (known >= 4 && (index_ > 0 || mode_age_ > 1.5f)) || mode_age_ > 4.0f;
}
void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details)
{
GameList &l = grid(app);
const int cols = grid_layout(l).cols;
// The games around where the grid is going, not the rows it scrolls past
// on the way (a jump to a letter animates through everything between).
const int target_row = std::max(0, index_ / cols - 2);
app.ensure(l, target_row * cols, (target_row + 7) * cols);
const int total = std::max(0, l.total);
const Slots slots = slots_of(l);
const bool repeat = input.nav_repeat;
if (input.is_pressed(Action::back))
{
feedback.play(Cue::back);
if (on_letters_)
on_letters_ = false;
else
{
mode_ = kPlatforms;
mode_age_ = 0.0f;
}
return;
}
if (input.is_pressed(Action::north))
{
feedback.play(Cue::modal_open);
open_search(grid_platform_);
return;
}
if (on_letters_)
{
if (input.nav == Direction::up || input.nav == Direction::down)
{
const int step = input.nav == Direction::down ? 1 : -1;
int next = letter_ + step;
while (next >= 0 && next < kSlots && slots[static_cast<std::size_t>(next)] < 0)
next += step;
if (next >= 0 && next < kSlots)
{
letter_ = next;
feedback.play(Cue::focus, 1.0f, 0.0f, 0.6f);
}
else if (!repeat)
refuse(feedback);
}
else if (input.nav == Direction::left || input.is_pressed(Action::confirm))
{
jump_letter(l, letter_, feedback);
on_letters_ = false;
}
else if (input.nav == Direction::right && !repeat)
refuse(feedback);
return;
}
if (total == 0)
return;
switch (input.nav)
{
case Direction::left:
if (index_ % cols > 0)
{
--index_;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::right:
if (index_ % cols < cols - 1 && index_ + 1 < total)
{
++index_;
feedback.play(Cue::focus);
}
else if (!l.letters.empty())
{
on_letters_ = true;
letter_ = std::max(0, letter_at(l, index_));
feedback.play(Cue::tab);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::up:
if (index_ - cols >= 0)
{
index_ -= cols;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::down:
if (index_ + cols < total)
{
index_ += cols;
feedback.play(Cue::focus);
}
else if (index_ / cols < (total - 1) / cols)
{
index_ = total - 1;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::none:
break;
}
if (input.is_pressed(Action::jump_prev) || input.is_pressed(Action::jump_next))
{
const int here = std::max(0, letter_at(l, index_));
const int step = input.is_pressed(Action::jump_next) ? 1 : -1;
int next = here + step;
// Back: the start of this letter first, when the focus is past it.
if (step < 0 && here >= 0 && slots[static_cast<std::size_t>(here)] >= 0 &&
slots[static_cast<std::size_t>(here)] < index_)
next = here;
while (next >= 0 && next < kSlots && slots[static_cast<std::size_t>(next)] < 0)
next += step;
jump_letter(l, next, feedback);
}
if (input.is_pressed(Action::confirm))
{
if (l.at(index_))
{
details = true;
feedback.play(Cue::open);
}
else
refuse(feedback);
}
}
void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app,
Color accent, float clock, float age) const
{
GameList &l = grid(app);
const Game *g = l.at(index_);
const Platform *own = app.platform_by_id(grid_platform_);
const Platform *p = app.platform_by_id(g ? g->platform_id : grid_platform_);
if (!p)
p = own;
// The heading: where we are, then the focused game.
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
list.push_opacity(in);
std::string where = grid_search_.empty() ? (own ? own->name : "Library")
: "\xE2\x80\x9C" + grid_search_ + "\xE2\x80\x9D \xC2\xB7 " +
(own ? "in " + own->name : "every platform");
char count[48] = "";
if (l.ready())
std::snprintf(count, sizeof count, l.total == 1 ? "1 game" : "%d games", l.total);
// What was typed, in curly quotes (plain_text turns them straight in a font without them)
ui::text(list, fonts.semibold, ui::upper(fonts.semibold.font->fit(plain_text(where.c_str()), 20, 1300)), kMargin, 196, 20,
accent, Align::left, 4.0f);
ui::text(list, fonts.regular, count, 1824, 196, 22, look.muted, Align::right);
const float fade = tween::smoothstep(tween::clamp01(shown_age_ / 0.25f));
if (g)
{
list.push_opacity(fade);
ui::text(list, fonts.display, fonts.display.font->fit(g->name, 60, 1600), kMargin - 3 + 12.0f * (1.0f - fade), 268,
60, look.text);
std::string meta = human_size(g->size);
if (g->versions > 1)
meta = g->version + ", 1 of " + std::to_string(g->versions) + " versions \xC2\xB7 " + meta;
if (!grid_search_.empty() || !own)
meta += " \xC2\xB7 " + (p ? p->name : std::string());
meta += " \xC2\xB7 " + (p && !p->profile.empty() ? p->profile : std::string("No emulator"));
// RomM's details when it has some: year, publisher, genres first.
const Details &info = app.details(g->id);
const std::string about = details_line(info);
if (!about.empty())
{
if (info.play_ms > 0)
meta += " \xC2\xB7 played " + play_time(info.play_ms);
ui::text(list, fonts.regular, fonts.regular.font->fit(about, 24, 1300), kMargin, 314, 24, look.muted);
}
else
ui::text(list, fonts.regular, fonts.regular.font->fit(meta, 24, 1300), kMargin, 314, 24, look.muted);
const Download *d = app.download_for(g->id);
if (d && d->active())
{
const Rect bar{kMargin, 336, 420, 8};
list.rounded_rect(bar, 4, look.muted.with_alpha(0.25f));
list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent);
if (d->syncing())
ui::text(list, fonts.semibold, "Syncing saves", bar.x + bar.w + 20, 344, 20, look.muted);
}
else
{
// "Downloaded" when it is; with the details on the line above, the file and emulator follow.
const bool here = app.on_console(*g);
const float w = here ? ui::text(list, fonts.semibold, "Downloaded", kMargin, 352, 20, look.success) + 14 : 0.0f;
if (!about.empty())
ui::text(list, fonts.regular, fonts.regular.font->fit((here ? "\xC2\xB7 " : "") + meta, 20, 1200),
kMargin + w, 352, 20, look.muted);
}
list.pop_opacity();
}
else if (!l.ready())
ui::text(list, fonts.display, l.error.empty() ? "Loading" : "Couldn't load these games", kMargin - 3, 268, 60,
look.text);
else if (l.total == 0)
{
ui::text(list, fonts.display, grid_search_.empty() ? "No games here" : "Nothing matches", kMargin - 3, 268, 60,
look.text);
ui::text(list, fonts.regular, grid_search_.empty() ? "" : "Try fewer letters, or search every platform",
kMargin, 314, 24, look.muted);
}
if (!l.error.empty())
ui::text(list, fonts.regular, fonts.regular.font->fit(l.error, 20, 1200), kMargin, 352, 20, look.danger);
list.pop_opacity();
// The grid.
const float appear = tween::stagger(age, 2, 0.08f, 0.6f);
list.push_opacity(appear);
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
const int total = std::max(0, l.total);
const GridLayout layout = grid_layout(l);
const int cols = layout.cols;
const int first_row = std::max(0, static_cast<int>(grid_scroll_.value) - 1);
const int rows = static_cast<int>((1080.0f - kGridTop) / layout.row_pitch) + 3;
list.push_clip({0, kGridTop - 30, kBarX - 10, 1080 - kGridTop + 30});
Rect focus_rect{};
for (int row = first_row; row < first_row + rows; ++row)
for (int col = 0; col < cols; ++col)
{
const int i = row * cols + col;
if (i >= total)
break;
const float y = kGridTop + (static_cast<float>(row) - grid_scroll_.value) * layout.row_pitch + 24 * (1.0f - appear);
if (y > 1080 || y + layout.card_h < kGridTop - 40)
continue;
const Game *game = l.at(i);
const Rect card{kMargin + static_cast<float>(col) * layout.col_pitch, y, layout.card_w, layout.card_h};
const Rect r = game ? in_card(cover_shape(app, game, layout.shape), card) : card;
if (i == index_)
{
focus_rect = r;
continue; // drawn last, on top
}
// Rows above where the grid is going are dimmed: one scrolling in
// brightens as soon as the move starts, not once it has settled.
const float above = tween::clamp01(grid_scroll_.target - static_cast<float>(row));
const float dim = 0.85f - 0.5f * above;
if (game)
{
draw_cover(list, fonts, look, app, game, r, dim, 14);
draw_badge(list, look, app, game, r, accent);
draw_version(list, fonts, look, game, r);
}
else
list.rounded_rect(r, 14, look.panel.with_alpha(look.panel.a * 0.6f));
}
if (focus_rect.w > 0)
{
const float grow = 1.08f;
Rect r{focus_rect.x - focus_rect.w * (grow - 1) * 0.5f + (on_letters_ ? 0.0f : shake),
focus_rect.y - focus_rect.h * (grow - 1) * 0.5f, focus_rect.w * grow, focus_rect.h * grow};
list.shadow({r.x, r.y + 16, r.w, r.h}, 18, 30, Color::rgb(0x000000, 0.55f));
if (!on_letters_)
list.glow(r, 18, 24, accent.with_alpha(0.4f + 0.15f * ui::breathe(clock)));
draw_cover(list, fonts, look, app, l.at(index_), r, 1.0f, 18);
draw_badge(list, look, app, l.at(index_), r, accent);
draw_version(list, fonts, look, l.at(index_), r);
list.bordered_rect(r.inset(-5), 22, Color::rgb(0x000000, 0.0f), 4,
look.focus.with_alpha(on_letters_ ? 0.35f : 1.0f));
}
list.pop_clip();
// The covers of the two rows below are asked for too (after the ones on
// screen), so scrolling down finds them ready.
for (int i = (first_row + rows) * cols; i < (first_row + rows + 2) * cols && i < total; ++i)
if (const Game *game = l.at(i))
app.cover(game->cover);
// The row below fades out under the button hints.
list.gradient_rect({0, 930, kBarX - 10, 150}, 0, look.page.with_alpha(0.0f), look.page.with_alpha(0.85f));
// The A to Z bar.
if (!l.letters.empty())
{
const Slots slots = slots_of(l);
const int here = letter_at(l, index_);
list.rounded_rect({kBarX, kBarTop, kBarW, kBarH}, 24, look.panel);
list.bordered_rect({kBarX, kBarTop, kBarW, kBarH}, 24, Color::rgb(0x000000, 0.0f), 1.5f, look.outline);
const float pitch = (kBarH - 24) / kSlots;
for (int s = 0; s < kSlots; ++s)
{
const float cy = kBarTop + 12 + pitch * (static_cast<float>(s) + 0.5f);
const bool has = slots[static_cast<std::size_t>(s)] >= 0;
const bool focused = on_letters_ && s == letter_;
if (focused)
list.rounded_rect({kBarX + 16, cy - pitch * 0.5f - 2, kBarW - 32, pitch + 4}, 8, look.highlight);
else if (s == here)
list.rounded_rect({kBarX + 22, cy - pitch * 0.5f, kBarW - 44, pitch}, 8, accent.with_alpha(0.25f));
const char text[2] = {slot_char(s), 0};
ui::text(list, fonts.semibold, text, kBarX + kBarW * 0.5f, cy + 7, 19,
focused ? look.on_highlight
: has ? (s == here ? look.panel_text : look.panel_muted)
: look.panel_muted.with_alpha(0.25f),
Align::center);
}
}
list.pop_opacity();
}
// ------------------------------------------------------------------ search
void LibraryPage::open_search(int platform_id)
{
before_search_ = mode_ == kSearch ? before_search_ : mode_;
mode_ = kSearch;
mode_age_ = 0.0f;
search_platform_ = platform_id;
query_.clear();
applied_.clear();
typed_age_ = 0.0f;
keys_.set_length(0);
keys_.enter();
}
void LibraryPage::update_search(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
keys_.style.theme = *look(current_look()).theme;
keys_.set_length(static_cast<int>(query_.size()));
const hui::ui::Event e = keys_.handle(input, feedback);
keys_.update(dt);
typed_age_ += dt;
if (query_ != applied_ && typed_age_ > kSearchDelay)
applied_ = query_;
if (!applied_.empty())
app.ensure(app.games(search_platform_, applied_), 0, 9);
if (e == hui::ui::Event::cancelled)
{
mode_ = before_search_;
mode_age_ = 0.0f;
}
else if (e == hui::ui::Event::activated)
{
if (query_.empty())
refuse(feedback);
else
open_grid(app, search_platform_, query_);
}
}
void LibraryPage::draw_search(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app,
Color accent, float clock) const
{
const Platform *p = app.platform_by_id(search_platform_);
const float in = tween::stagger(mode_age_, 0, 0.06f, 0.5f);
list.push_opacity(in);
ui::text(list, fonts.semibold, ui::upper(p ? "Search " + p->name : std::string("Search every platform")), kMargin,
196, 20, accent, Align::left, 4.0f);
const Rect field{kMargin, 220, 1300, 84};
draw_panel(list, look, field, 22);
const float size = 36.0f;
const std::string shown = tail(*fonts.regular.font, query_, size, field.w - 80);
const float w = query_.empty() ? 0.0f
: ui::text(list, fonts.regular, shown, field.x + 32, field.cy() + 13, size, look.panel_text);
if (query_.empty())
ui::text(list, fonts.regular, "A few letters of the name", field.x + 32, field.cy() + 13, size,
look.panel_muted.with_alpha(0.6f));
if (std::fmod(clock, 1.0f) < 0.6f)
list.rounded_rect({field.x + 34 + w, field.y + 20, 3, field.h - 40}, 1.5f, accent);
// The first results, as you type.
std::string status = "Type to search";
const GameList *l = applied_.empty() ? nullptr : &app.games(search_platform_, applied_);
if (l)
{
char text[64];
if (!l->ready())
std::snprintf(text, sizeof text, "Searching");
else
std::snprintf(text, sizeof text, l->total == 1 ? "1 game" : "%d games", l->total);
status = l->error.empty() ? text : l->error;
}
ui::text(list, fonts.semibold, fonts.semibold.font->fit(status, 24, 440), 1824, field.cy() + 9, 24, look.muted,
Align::right);
if (l)
{
float x = kMargin;
for (int i = 0; i < 9; ++i)
{
const Game *g = l->at(i);
if (!g)
break;
const Rect r{x, 340, kResultH * cover_shape(app, g), kResultH};
if (r.x + r.w > kResultsEnd)
break;
x += r.w + kResultGap;
draw_cover(list, fonts, look, app, g, r, 1.0f, 14);
draw_badge(list, look, app, g, r, accent);
draw_version(list, fonts, look, g, r);
}
}
ui::Canvas canvas{list, fonts, 0, clock};
keys_.draw(canvas);
list.pop_opacity();
}
// ------------------------------------------------------------------ drawing
void LibraryPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock, float age) const
{
switch (mode_)
{
case kPlatforms:
draw_platforms(list, fonts, look, app, accent, clock, std::min(age, mode_age_));
break;
case kGrid:
draw_grid(list, fonts, look, app, accent, clock, std::min(age, mode_age_));
break;
case kSearch:
draw_search(list, fonts, look, app, accent, clock);
break;
}
}
std::vector<hui::ui::Hint> LibraryPage::hints() const
{
using hui::ui::Button;
switch (mode_)
{
case kPlatforms:
return {{Button::l1, "Pages", Button::r1},
{Button::triangle, "Search"},
{Button::cross, "Open"}};
case kGrid:
if (on_letters_)
return {{Button::cross, "Jump"}, {Button::circle, "Back"}};
return {{Button::l2, "Letters", Button::r2},
{Button::triangle, "Search"},
{Button::cross, "Details"},
{Button::circle, "Back"}};
case kSearch:
return {{Button::square, "Delete"}, {Button::r2, "Show all"}, {Button::circle, "Back"}};
}
return {};
}
} // namespace rommps
@@ -0,0 +1,139 @@
// RommPS: the Library tab.
//
// - Platforms: a grid of RomM's platforms, each with a few of its covers.
// Cross opens one, Triangle searches every platform.
// - A platform (or a search's results): the focused game's details above a
// grid of covers that loads as it scrolls, and an A to Z bar on the right
// (Right from the last column to reach it, L2 / R2 to jump a letter).
// Triangle searches the platform, Circle goes back.
// - Search: the kit's keyboard, the first results updating as you type;
// R2 (or Done) shows them all.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "core/input.hpp"
#include "core/tween.hpp"
#include "rommps_app.hpp"
#include "rommps_look.hpp"
#include "ui/components/keyboard.hpp"
#include "ui/feedback.hpp"
#include "ui/fonts.hpp"
#include "ui/glyphs.hpp"
#include "ui/motion.hpp"
#include <string>
#include <vector>
namespace rommps
{
// Covers keep their own shape (width over height) within these: narrower or
// wider ones are cropped, never stretched.
constexpr float kPortrait = 0.75f, kNarrowest = 0.6f, kWidest = 1.45f;
// A game's cover shape: fallback until its cover has been loaded, then its
// own, eased in over a moment so what's around it slides rather than jumps.
float cover_shape(App &app, const Game *g, float fallback = kPortrait);
// The largest rectangle of a shape that fits in box, centred in it.
hui::gfx::Rect fit_cover(float shape, const hui::gfx::Rect &box);
// A game's cover, or a card with its name while there's none.
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
const hui::gfx::Rect &rect, float alpha, float radius, bool name = true);
// A platform's logo from RomM, as large as fits box; nothing while it loads or
// when RomM has none. Whether one was drawn.
bool draw_logo(hui::gfx::DrawList &list, App &app, const std::string &slug, const hui::gfx::Rect &box, float alpha);
// "Downloaded", or a running download's progress, on a card.
void draw_badge(hui::gfx::DrawList &list, const Look &look, App &app, const Game *g, const hui::gfx::Rect &rect,
Color accent);
// When RomM has more than one version of the game, what sets this one apart
// ("USA, Rev 1"), in a tag along the cover's bottom edge.
void draw_version(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const Game *g,
const hui::gfx::Rect &rect);
std::string human_size(double bytes);
// "1994 · Nintendo · Platform, Adventure" ("" while there's nothing to say).
std::string details_line(const Details &d);
// "1 h 30 min" of play time.
std::string play_time(double ms);
class LibraryPage
{
public:
LibraryPage();
LibraryPage(const LibraryPage &) = delete;
LibraryPage &operator=(const LibraryPage &) = delete;
void enter(App &app);
// True when Cross asks for the focused game's details.
bool update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock, float age) const;
// The game the focus is on (nullptr on a platform tile or a placeholder).
const Game *focused(App &app) const;
// A cover to colour the backdrop with ("" for none).
std::string backdrop_cover(App &app) const;
std::vector<hui::ui::Hint> hints() const;
private:
enum Mode
{
kPlatforms,
kGrid,
kSearch
};
// ---- platforms
void update_platforms(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback);
void draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock, float age) const;
// ---- a grid of games
void open_grid(App &app, int platform_id, const std::string &search);
void update_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
// Picks the grid's shape from its first covers, until it settles.
void settle_shape(App &app, GameList &l) const;
void move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
void draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock, float age) const;
GameList &grid(App &app) const { return app.games(grid_platform_, grid_search_); }
int letter_at(const GameList &l, int index) const; // index into l.letters, -1 for none
void jump_letter(GameList &l, int letter, hui::ui::Feedback &feedback);
// ---- search
void open_search(int platform_id);
void update_search(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw_search(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock) const;
void refuse(hui::ui::Feedback &feedback);
Mode mode_ = kPlatforms;
Mode before_search_ = kPlatforms;
float mode_age_ = 0.0f;
hui::ui::Pulse nudge_;
// platforms: the tiles
int tile_ = 0;
hui::tween::Spring tiles_scroll_; // in rows
// a grid
int grid_platform_ = 0;
std::string grid_search_;
int index_ = 0;
// The focused game by RomM's offset, so it stays put when a page turns out
// to hold files that aren't games, which shifts the shown positions.
const GameList *focus_list_ = nullptr;
int focus_raw_ = 0;
std::size_t focus_hidden_ = 0;
bool on_letters_ = false;
int letter_ = 0; // the A to Z bar's focus, an index into the list's letters
hui::tween::Spring grid_scroll_; // in rows
hui::tween::Spring focus_x_, focus_y_;
int shown_id_ = -1;
float shown_age_ = 0.0f;
// search
int search_platform_ = 0;
std::string query_, applied_;
float typed_age_ = 0.0f;
hui::ui::Keyboard keys_;
};
} // namespace rommps
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,442 @@
// RommPS: what the screen shows, kept up to date from the RomM Sync payload.
//
// The payload does the work (sync, downloads, RomM) and serves its state on
// http://127.0.0.1:<port>/api (src/web.c in the repository). This keeps a
// copy for the screen: status, platforms, the games of the platforms looked
// at, downloads and covers, refreshed on timers through the API client
// (platform/ps5/app/src/rommps_api.hpp) so a frame never waits for it.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "gfx/draw_list.hpp"
#include "gfx/renderer.hpp"
#include "rommps_api.hpp"
#include <atomic>
#include <cstdint>
#include <deque>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace hui::ui
{
struct Fonts;
}
namespace rommps
{
using hui::gfx::Color;
// Colours taken from a cover, for the backdrop and highlights.
struct Palette
{
Color dark = Color::rgb(0x14182e);
Color mid = Color::rgb(0x2b3466);
Color accent = Color::rgb(0x76d6ff);
};
struct Cover
{
std::uint32_t texture = 0; // 0 until it's loaded (or if there is none)
float aspect = 0; // its width over its height, 0 until it's loaded
Palette palette;
bool requested = false;
bool failed = false;
double used = 0; // when it was last drawn
double retry_at = 0; // a fetch that failed (not a missing cover) is tried again then
int retries = 0; // tries again so far this launch (kCoverRetries at most)
};
struct Game
{
int id = 0;
int platform_id = 0;
std::string name, fs_name, cover; // cover: the RomM asset path, "" if none
double size = 0;
bool installed = false;
bool multi = false;
bool not_game = false; // a file RomM scanned beside the games (metadata.txt...), or a Switch update or DLC: hidden
bool archived = false; // a .rar (or any archive, for Switch) nothing here plays: not downloaded
std::string version; // what sets this version apart, when RomM has others of the game
int versions = 0; // how many versions RomM has, this one included; 0 if just one
};
// More about a game (/api/roms/<id>/details): RomM's metadata, the user's own
// data and the play time RomM has recorded. Empty fields weren't known.
struct Details
{
bool loaded = false, failed = false;
int year = 0;
std::string publishers, developers, genres, players, summary, status, last_played;
double rating = 0; // RomM's average, on its own scale (shown /10 or /100)
double main_story_s = 0; // How Long to Beat, the main story
double play_ms = 0; // every device's sessions RomM has
int sessions = 0, completion = 0, my_rating = 0;
std::string archived; // why it can't be downloaded: it's an archive
int updates = 0, dlc = 0; // a Switch game's, downloaded with it
std::string title_id; // a Switch game's ("0100ABF008968000"), "" otherwise
struct Addon
{
enum Kind { update, dlc, game } kind = update; // game: another one in its folder (a collection)
std::string name, title_id;
double size = 0;
};
// The update first, then the DLC; then the other games of a collection's
// folder and theirs, which come with the download but aren't this game's.
std::vector<Addon> addons;
int bundle = 0; // how many of addons are those
};
struct Platform
{
int id = 0;
std::string name, slug, profile; // profile: the emulator, "" if none plays it
int rom_count = 0;
};
// A list of games from RomM, A to Z: a platform's, a search's (in one
// platform, or every platform when platform_id is 0). Loaded a page at a time
// as it's looked at.
struct GameList
{
static constexpr int kPage = 60;
int platform_id = 0;
std::string search;
int total = -1; // games to show; unknown until the first page arrives
std::vector<Game> games; // RomM's whole list, by RomM's offset; id 0 isn't loaded yet
std::vector<unsigned char> pages; // per page: 0 not asked, 1 asked, 2 here
std::vector<std::pair<std::string, int>> letters; // first letter -> the index of its first game
std::vector<std::pair<std::string, int>> raw_letters; // the same, by RomM's offset
std::vector<int> hidden; // RomM offsets of the files that aren't games, sorted
std::string error;
// The shape of the grid's cards (width over height), from the covers at
// the top; 0 until the library has seen some. Settled: it stays.
float shape = 0;
bool shape_settled = false;
bool ready() const { return total >= 0; }
// Positions in the list as shown, which skips what isn't a game, and
// RomM's offsets. What isn't a game is known once its page is here.
int raw(int index) const
{
for (int h : hidden)
if (h <= index)
++index;
return index;
}
int shown(int raw_index) const
{
int before = 0;
for (int h : hidden)
before += h < raw_index;
return raw_index - before;
}
const Game *at(int index) const
{
if (index < 0 || index >= total)
return nullptr;
const int r = raw(index);
return r < static_cast<int>(games.size()) && games[static_cast<std::size_t>(r)].id
? &games[static_cast<std::size_t>(r)]
: nullptr;
}
void reindex() // after a page arrives
{
hidden.clear();
for (std::size_t i = 0; i < games.size(); ++i)
if (games[i].id && games[i].not_game)
hidden.push_back(static_cast<int>(i));
total = static_cast<int>(games.size() - hidden.size());
letters.clear();
for (const auto &[key, offset] : raw_letters)
letters.push_back({key, shown(offset)});
}
};
struct Download
{
int id = 0, rom_id = 0;
std::string name, platform, status, error;
double done = 0, total = 0, speed = 0;
// What the sync after the download brought.
int saves = 0, states = 0, save_conflicts = 0;
bool saves_failed = false;
float progress() const { return total > 0 ? static_cast<float>(done / total) : 0.0f; }
// The payload's states: queued, downloading, cancelling, syncing (the
// game's saves, once it's downloaded), then done, error or cancelled.
bool running() const { return status == "downloading"; }
bool syncing() const { return status == "syncing"; }
bool active() const { return status == "queued" || running() || status == "cancelling" || syncing(); }
bool cancellable() const { return active() && !syncing(); }
bool finished() const { return !active(); }
bool retryable() const { return status == "error" || status == "cancelled"; }
std::string saves_note() const;
};
struct Status
{
bool known = false; // an answer arrived at least once
bool reachable = false; // the payload answered the last time
bool paired = false, setup_complete = false;
std::string version, server, user, ip, error;
std::string server_trouble; // RomM isn't answering: what the payload says, "" while it does
int port = 8780;
int tracked_saves = 0, conflicts = 0, sync_count = 0;
bool syncing = false;
std::string phase;
double last_run = 0; // the payload's clock, seconds
double uptime = 0;
std::string last_result; // "OK", or the last sync's error
int last_up = 0, last_down = 0;
std::string server_version;
// Every cover kept on the console (covers.h in the payload).
std::string covers_state;
int covers_done = 0, covers_total = 0;
// Pairing in progress (/api/pair/start): "pending" while RomM waits for
// the code to be approved, "error" when it failed, "" otherwise.
std::string pairing, pairing_message, user_code, verification_url;
// Games left in the folders RetroArch used before 2.0, and those
// folders' names (/api/retroarch/move moves them to the new ones).
int retroarch_move = 0;
std::string retroarch_folders;
};
// The payload's settings that RommPS shows (/api/config).
struct Config
{
bool known = false;
std::string server_url;
bool tls_verify = true;
int sync_interval_min = 0;
bool save_sync = true; // false: RomM only as a game source
bool sync_on_game_exit = true, sync_on_game_start = false, sync_on_change = false;
bool notify = true, update_check = true;
std::string states = "off"; // off, upload, sync
std::string conflict_policy = "ask"; // ask, newest, local, server
std::string ps2_cards = "per_game"; // per_game, backup
bool cover_cache = true;
int download_concurrency = 1;
};
// The payload's own updates (/api/update).
struct Update
{
bool known = false;
std::string state, message, error, current, latest;
bool available = false, autostart = false;
// false: RomM Sync doesn't update itself; a new RommPS brings it (PS5).
bool self_update = true;
double done = 0, total = 0;
bool busy() const { return state == "checking" || state == "downloading" || state == "installing" || state == "restarting"; }
};
class App
{
public:
App();
// Once per frame: delivers finished requests, starts the timed ones and
// turns decoded covers into textures (renderer may be null in tests).
void tick(float dt, hui::gfx::Renderer *renderer);
const Status &status() const { return status_; }
// RommPS carries the RomM Sync payload of its own release
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
// none is running, puts it in place of an older one (never of a newer
// one), and keeps the copies the console starts it from up to date.
static constexpr const char *kMinPayload = "1.3.0";
bool payload_too_old() const;
void start_payload(); // sends the payload to the HEN's loader
// Sending it, or RomM Sync is running and not answering yet (starting up,
// or waking from rest mode): either way it'll answer in a moment.
bool payload_starting() const { return start_state_ == 1 || payload_waiting_; }
const std::string &payload_error() const { return start_error_; }
// The payload into the HEN's autostart folder, so it starts with the console.
void install_autostart(Api::Callback done);
const std::vector<Platform> &platforms() const { return platforms_; }
const std::vector<Download> &queue() const { return queue_; }
const Download *download_for(int rom_id) const;
const Platform *platform_by_id(int id) const;
// A list of games (made the first time it's asked for), and the pages
// holding [first, last] loaded. Lists stay for a while, a few at most.
GameList &games(int platform_id, const std::string &search);
void ensure(GameList &list, int first, int last);
// The games on this console, most recently played first.
const std::vector<Game> &installed() const { return installed_; }
// Whether a game is on the console now. A list's copy can be older than
// a game deleted by hand, so this goes by the latest installed list.
bool on_console(const Game &g) const { return installed_known_ ? on_console_.count(g.id) > 0 : g.installed; }
// Asks the payload about one game, so a sheet's actions are right even
// before the next look at every game (the payload does that sparingly).
void check_on_console(int rom_id);
// A game's details; asks for them the first time.
const Details &details(int rom_id);
void refresh_installed();
// Asks for the platforms again: which emulator plays each, and its
// folder, change when emulators are set up here or in the web UI.
void reload_platforms()
{
if (!platforms_busy_)
refresh_platforms();
}
// A cover's texture and palette; asks for it the first time.
const Cover &cover(const std::string &path);
// A platform's logo from RomM, by its slug ("psx"), as a cover.
const Cover &platform_logo(const std::string &slug);
// A cover's width over its height, kept after its texture is let go;
// 0 until it has been loaded once. since: when it became known.
float cover_aspect(const std::string &path, double *since = nullptr) const;
double clock() const { return clock_; }
void download(int rom_id, const std::string &name);
void cancel_download(int job_id);
void retry_download(int job_id);
void remove_game(int rom_id);
void clear_finished_queue();
void sync_now();
const Config &config() const { return config_; }
void refresh_config();
// Changes settings: fields is a JSON object's members ("\"notify\":true").
// The copy here changes at once; a refusal puts the payload's back.
void set_config(const std::string &fields, const std::function<void(Config &)> &apply);
const Update &update() const { return update_; }
void refresh_update();
void check_update();
void install_update();
// Pairing: asks RomM for a code to approve (status().pairing follows it).
void pair_start(const std::string &server_url, bool tls_verify);
void pair_forget();
// Background syncs wait until the setup is finished again.
void setup_restart();
// Asks for the status now rather than at the next tick.
void refresh_now() { refresh_status(); }
// Any other call to the payload, for the setup's steps. A long one (the
// sync preview) goes on a worker of its own, so the status never waits.
void get(const std::string &path, Api::Callback done, int timeout_ms = 15000)
{
(timeout_ms > 15000 ? slow_api_ : api_).get(path, std::move(done), timeout_ms);
}
void post(const std::string &path, const std::string &body, Api::Callback done)
{
api_.post(path, body, std::move(done));
}
void say(const std::string &message);
// A message for the screen to show for a few seconds ("" for none).
const std::string &toast() const { return toast_; }
float toast_age() const { return toast_age_; }
private:
void refresh_status();
// Whether a RomM Sync is running, from the lock it holds while it runs:
// 1 yes, 0 no, -1 can't tell.
static int payload_lock();
// Turns autostart on wherever it's off and brings the copies the console
// starts RomM Sync from up to the one RommPS carries.
void repair_autostart();
void evict_covers(hui::gfx::Renderer *renderer);
void send_page(const std::string &key, int page);
void refresh_platforms();
void refresh_queue();
Api api_;
Api slow_api_; // calls that may take minutes
// Covers on workers of their own, so lists never wait behind them; asked
// for only while they're on screen (wanted_), newest first.
static constexpr int kCoverWorkers = 3;
static constexpr int kCoversInFlight = 6;
Api covers_api_[kCoverWorkers]; // each to the payload on 127.0.0.1
std::vector<std::string> wanted_; // drawn since the last tick, in draw order
// Pages of games, on workers of their own too; asked for at the tick if
// the screen still wants them.
static constexpr int kPageWorkers = 2;
Api pages_api_[kPageWorkers];
std::vector<std::pair<std::string, int>> wanted_pages_; // since the last tick
int pages_in_flight_ = 0, next_page_worker_ = 0;
int covers_in_flight_ = 0, next_cover_worker_ = 0;
Status status_;
std::vector<Platform> platforms_;
std::vector<Download> queue_;
std::vector<Game> installed_;
std::unordered_set<int> on_console_; // installed_'s ids
bool installed_known_ = false;
double installed_sig_ = -1; // the payload's, from /api/status: changes when a game comes or goes
std::unordered_map<int, Details> details_;
struct CachedList
{
std::unique_ptr<GameList> list;
double used = 0;
};
std::unordered_map<std::string, CachedList> lists_;
double clock_ = 0;
Config config_;
Update update_;
float update_timer_ = 0;
bool update_busy_ = false;
std::unordered_map<std::string, Cover> covers_;
struct Shape
{
float aspect = 0;
double since = 0;
};
std::unordered_map<std::string, Shape> shapes_; // every cover loaded this launch
struct Pending
{
std::string path, bytes;
};
std::deque<Pending> to_decode_;
float status_timer_ = 0, queue_timer_ = 0;
bool status_busy_ = false, queue_busy_ = false, platforms_busy_ = false;
bool was_paired_ = false;
const std::string &bundled_payload();
std::string bundled_;
bool bundled_read_ = false;
std::atomic<int> start_state_{0}; // 0 idle, 1 sending, 2 sent, 3 failed
std::string start_error_; // written before start_state_ becomes 3
bool tried_start_ = false, updated_payload_ = false, repaired_ = false;
bool payload_waiting_ = false;
int unanswered_ = 0; // status requests in a row RomM Sync didn't answer
int active_queue_ = 0;
std::string toast_;
float toast_age_ = 0;
};
// The program's one App, created on first use.
App &app();
// Text the screen's fonts can draw: the characters they have stay ("Pokémon",
// "Luigi’s Mansion"), the others fold to their nearest ASCII ("Ō" reads "O" in
// a font without it), and only one with no stand-in at all reads "?".
std::string plain_text(const char *text);
// The faces plain_text asks: the ones the screen draws text with. Set once the
// kit has loaded them, before the payload's first answer is read.
void set_text_fonts(const hui::ui::Fonts &fonts);
// The first byte at or after at that starts a character of UTF-8 text: where
// to cut it without halving one.
inline std::size_t char_boundary(const std::string &text, std::size_t at)
{
while (at < text.size() && (static_cast<unsigned char>(text[at]) & 0xC0) == 0x80)
++at;
return at;
}
} // namespace rommps
// The program hands the kit's renderer over (rommps.cpp is compiled in a
// namespace of its own, so this is plain C).
extern "C" void rommps_set_renderer(void *renderer);
extern "C" void *rommps_renderer();
@@ -0,0 +1,194 @@
// RommPS: the screen's look. See rommps_look.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "rommps_look.hpp"
#include "core/save_file.hpp"
#include "ui/components/component.hpp"
#include <string>
#include <sys/stat.h>
#include <vector>
// Set for a whole test run (ps5/src/main.cpp): its pictures never depend on
// what was picked by hand.
extern bool ps5TestRun;
#ifndef PS5_APP_ROOT
#define PS5_APP_ROOT "/app0"
#endif
namespace rommps
{
namespace
{
const char *const kFolder = PS5_APP_ROOT "/hui";
const char *const kPath = PS5_APP_ROOT "/hui/rommps-look.txt";
const char *const kSoundsPath = PS5_APP_ROOT "/hui/rommps-sounds.txt";
const hui::ui::Theme *theme_named(const char *id)
{
for (const hui::ui::Theme &t : hui::ui::themes())
if (std::string(t.id) == id)
return &t;
return &hui::ui::default_theme();
}
Color or_else(Color a, Color b)
{
return a.a > 0.0f ? a : b;
}
// The design RommPS was drawn for: white on the cover's aurora, frosted panels.
Look cover_colours()
{
Look l;
l.id = "covers";
l.name = "Cover colours";
l.family = "Follows the game you're looking at";
l.covers = true;
l.theme = theme_named("acrylic");
l.backdrop.mode = hui::gfx::BackdropMode::aurora;
l.page = Color::rgb(0x05060c);
l.text = Color::rgb(0xffffff);
l.muted = Color::rgb(0xffffff, 0.72f);
l.panel = Color::rgb(0x0b1026, 0.5f);
l.panel_text = l.text;
l.panel_muted = l.muted;
l.outline = Color::rgb(0xffffff, 0.2f);
l.accent = Color::rgb(0x76d6ff);
l.button = Color::rgb(0xffffff);
l.on_button = Color::rgb(0x0b0d16);
l.focus = Color::rgb(0xffffff);
l.highlight = Color::rgb(0xffffff);
l.on_highlight = Color::rgb(0x0b0d16);
l.danger = Color::rgb(0xf87171);
l.success = Color::rgb(0x4ade80);
l.warning = Color::rgb(0xfbbf24);
l.radius = 28.0f;
l.dark = true;
return l;
}
Look from_theme(const hui::ui::Theme &t)
{
Look l;
l.id = t.id;
l.name = t.name;
l.family = t.family;
l.theme = &t;
l.backdrop = t.backdrop;
l.page = t.page;
l.text = or_else(t.page_text, t.text);
l.muted = or_else(t.page_text_muted, t.text_muted);
l.panel = t.surface;
l.panel_text = t.text;
l.panel_muted = t.text_muted;
l.outline = t.outline;
l.accent = t.accent;
l.button = t.primary;
l.on_button = t.on_primary;
l.focus = t.focus;
l.highlight = t.primary;
l.on_highlight = t.on_primary;
l.danger = t.danger;
l.success = t.success;
l.warning = t.warning;
l.radius = t.radius_card;
l.dark = t.dark;
return l;
}
const std::vector<Look> &all()
{
static const std::vector<Look> looks = [] {
std::vector<Look> v{cover_colours()};
for (const hui::ui::Theme &t : hui::ui::themes())
v.push_back(from_theme(t));
return v;
}();
return looks;
}
int g_current = -1; // -1: not read yet
int g_sounds = -1; // the same
} // namespace
int look_count()
{
return static_cast<int>(all().size());
}
const Look &look(int index)
{
const std::vector<Look> &v = all();
return v[index >= 0 && index < static_cast<int>(v.size()) ? static_cast<std::size_t>(index) : 0];
}
int current_look()
{
if (ps5TestRun && g_current < 0)
g_current = 0; // a test run starts from the default, and may change it
if (g_current < 0)
{
g_current = 0;
std::string id;
if (hui::save::read_file(kPath, &id, 64))
{
while (!id.empty() && (id.back() == '\n' || id.back() == '\r' || id.back() == ' '))
id.pop_back();
for (int i = 0; i < look_count(); ++i)
if (id == look(i).id)
g_current = i;
}
}
return g_current;
}
void set_look(int index)
{
if (index < 0 || index >= look_count())
return;
g_current = index;
if (ps5TestRun)
return;
hui::save::ensure_directory(kFolder);
chmod(kFolder, 0777);
if (hui::save::write_atomic(kPath, std::string(look(index).id) + "\n").empty())
chmod(kPath, 0666);
}
bool ui_sounds()
{
if (g_sounds < 0)
{
std::string v;
g_sounds = ps5TestRun || !hui::save::read_file(kSoundsPath, &v, 16) || v.rfind("off", 0) != 0;
}
return g_sounds != 0;
}
void set_ui_sounds(bool on)
{
g_sounds = on;
if (ps5TestRun)
return;
hui::save::ensure_directory(kFolder);
chmod(kFolder, 0777);
if (hui::save::write_atomic(kSoundsPath, on ? "on\n" : "off\n").empty())
chmod(kSoundsPath, 0666);
}
void draw_panel(hui::gfx::DrawList &list, const Look &look, const hui::gfx::Rect &r, float radius)
{
if (radius < 0.0f)
radius = look.radius;
list.shadow({r.x, r.y + 16, r.w, r.h}, radius, 34, Color::rgb(0x000000, look.dark ? 0.4f : 0.12f));
list.rounded_rect(r, radius, look.panel);
list.bordered_rect(r, radius, Color::rgb(0x000000, 0.0f), 1.5f, look.outline);
}
} // namespace rommps
@@ -0,0 +1,65 @@
// RommPS: the screen's look. "Cover colours" (the backdrop and accents follow
// the focused game's cover), or one of the UI kit's themes. Every colour the
// screen draws with comes from here, so a light theme reads as well as a dark
// one. The choice is kept in the title's folder (/app0/hui/rommps-look.txt).
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "gfx/backdrop_spec.hpp"
#include "gfx/draw_list.hpp"
#include "ui/glyphs.hpp"
#include "ui/theme.hpp"
namespace rommps
{
using hui::gfx::Color;
struct Look
{
const char *id = "";
const char *name = "";
const char *family = "";
bool covers = false; // the backdrop and accent follow the focused cover
const hui::ui::Theme *theme; // what the kit's components (the keyboard) are drawn in
hui::gfx::BackdropSpec backdrop;
Color page; // the page's own colour, for fades over it
Color text; // text straight on the page
Color muted; // secondary text on the page
Color panel; // panels and cards
Color panel_text; // text on a panel
Color panel_muted;
Color outline; // panel edges
Color accent; // highlights, selections (Cover colours: the cover's)
Color button; // the main action's body
Color on_button; // ... and its text
Color focus; // the focus ring
Color highlight; // a focused row's plate
Color on_highlight;
Color danger, success, warning;
float radius = 28.0f; // panels
bool dark = true;
hui::ui::GlyphStyle glyphs() const
{
return dark ? hui::ui::GlyphStyle::dark() : hui::ui::GlyphStyle::light();
}
};
int look_count();
const Look &look(int index);
// The look in use: the saved one, Cover colours until one is picked.
int current_look();
// Uses the look from now on and saves the choice (silently, a test run excepted).
void set_look(int index);
// The interface's sounds (focus, select, back...): on unless turned off in
// Settings, kept beside the theme. Rumble isn't affected.
bool ui_sounds();
void set_ui_sounds(bool on);
// A panel in the look: its shadow, body and edge.
void draw_panel(hui::gfx::DrawList &list, const Look &look, const hui::gfx::Rect &r, float radius = -1.0f);
} // namespace rommps
@@ -0,0 +1,267 @@
// RommPS: a list of setting rows. See rows.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "rows.hpp"
#include "rommps_app.hpp"
#include <algorithm>
#include <cstdio>
#include <cctype>
namespace rommps
{
using hui::Action;
using hui::Direction;
using hui::audio::Cue;
using hui::gfx::Rect;
void RowList::set_focus(int row, bool snap)
{
focus_ = std::max(0, row);
if (snap)
position_.snap(static_cast<float>(focus_));
}
void RowList::refuse(hui::ui::Feedback &feedback)
{
feedback.play(Cue::error, 1.0f, 0.0f, 0.6f);
feedback.rumble(0.25f, 0.05f);
nudge_.trigger();
}
RowEvent RowList::handle(const std::vector<Row> &rows, const hui::InputFrame &input, hui::ui::Feedback &feedback)
{
RowEvent event;
const int count = static_cast<int>(rows.size());
if (count == 0)
return event;
focus_ = std::clamp(focus_, 0, count - 1);
// A section's name can't hold the focus: the first row after it does
// (or, at the end of the list, the last one before it).
if (rows[static_cast<std::size_t>(focus_)].kind == RowKind::header)
{
int at = focus_;
while (at < count && rows[static_cast<std::size_t>(at)].kind == RowKind::header)
++at;
if (at == count)
for (at = focus_; at > 0 && rows[static_cast<std::size_t>(at)].kind == RowKind::header;)
--at;
focus_ = at;
}
const Row &row = rows[static_cast<std::size_t>(focus_)];
switch (input.nav)
{
case Direction::up:
case Direction::down:
{
const int step = input.nav == Direction::down ? 1 : -1;
int next = focus_ + step;
while (next >= 0 && next < count && rows[static_cast<std::size_t>(next)].kind == RowKind::header)
next += step;
if (next >= 0 && next < count)
{
focus_ = next;
feedback.play(Cue::focus);
}
else if (!input.nav_repeat)
refuse(feedback);
break;
}
case Direction::left:
case Direction::right:
if (row.kind == RowKind::choice && row.enabled)
{
event.row = focus_;
event.step = input.nav == Direction::right ? 1 : -1;
feedback.play(Cue::slider, input.nav == Direction::right ? 1.06f : 0.94f);
}
else if (!input.nav_repeat)
refuse(feedback);
break;
case Direction::none:
break;
}
if (input.is_pressed(Action::confirm))
{
if (!row.enabled || row.kind == RowKind::info)
refuse(feedback);
else if (row.kind == RowKind::choice)
{
event.row = focus_;
event.step = 1;
feedback.play(Cue::slider, 1.06f);
}
else
{
event.row = focus_;
event.activated = true;
feedback.play(row.kind == RowKind::toggle ? Cue::toggle : Cue::select);
}
}
return event;
}
void RowList::update(float dt, int count, float visible_height)
{
focus_ = std::clamp(focus_, 0, std::max(0, count - 1));
position_.target = static_cast<float>(focus_);
position_.update(dt, 22.0f);
// Keep the focused row a row away from the edges.
const float top = static_cast<float>(focus_ - 1) * kRowHeight;
const float bottom = static_cast<float>(focus_ + 2) * kRowHeight;
const float most = std::max(0.0f, static_cast<float>(count) * kRowHeight - visible_height);
if (top < scroll_.target)
scroll_.target = top;
if (bottom > scroll_.target + visible_height)
scroll_.target = bottom - visible_height;
scroll_.target = std::clamp(scroll_.target, 0.0f, most);
scroll_.update(dt, 14.0f);
nudge_.update(dt, 9.0f);
}
void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const std::vector<Row> &rows,
const Rect &bounds, float clock) const
{
namespace ui = hui::ui;
list.push_clip({bounds.x - 40, bounds.y, bounds.w + 80, bounds.h});
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
const float plate_y = bounds.y + position_.value * kRowHeight - scroll_.value;
if (!rows.empty())
list.rounded_rect({bounds.x + shake, plate_y + 6, bounds.w, kRowHeight - 12}, 22, look.highlight);
for (int i = 0; i < static_cast<int>(rows.size()); ++i)
{
const Row &row = rows[static_cast<std::size_t>(i)];
const float y = bounds.y + static_cast<float>(i) * kRowHeight - scroll_.value;
if (y + kRowHeight < bounds.y || y > bounds.y + bounds.h)
continue;
if (row.kind == RowKind::header)
{
std::string caps = plain_text(row.label.c_str());
for (char &ch : caps)
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
ui::text(list, fonts.semibold, caps, bounds.x + 32, y + kRowHeight - 20, 18, look.muted);
continue;
}
const bool focused = i == focus_;
const float dim = row.enabled ? 1.0f : 0.45f;
const Color ink = focused ? look.on_highlight : row.danger ? look.danger : look.text;
const Color soft = focused ? look.on_highlight.with_alpha(0.7f) : look.muted;
const float x = bounds.x + 32 + (focused ? shake : 0.0f);
const float right = bounds.x + bounds.w - 32 + (focused ? shake : 0.0f);
const bool two_lines = !row.detail.empty();
const float label_y = y + (two_lines ? 42.0f : 56.0f);
// The right-hand part first, so the label knows how much room it has.
float used = 0.0f;
if (row.kind == RowKind::toggle)
{
const Rect track{right - 68, y + kRowHeight * 0.5f - 18, 68, 36};
list.rounded_rect(track, 18, row.on ? (focused ? look.on_highlight : look.accent) : soft.with_alpha(0.3f * dim));
const float knob = row.on ? track.x + track.w - 18 : track.x + 18;
list.circle(knob, track.cy(), 13, row.on ? (focused ? look.highlight : look.page) : ink.with_alpha(dim));
used = 90.0f;
}
else if (!row.value.empty())
{
const float size = 24.0f;
const float chevrons = row.kind == RowKind::choice ? 30.0f : 0.0f;
const std::string value = fonts.semibold.font->fit(plain_text(row.value.c_str()), size, bounds.w * 0.45f);
const float w = ui::text(list, fonts.semibold, value, right - chevrons, y + kRowHeight * 0.5f + 9, size,
(row.kind == RowKind::info ? soft : ink).with_alpha(dim), hui::gfx::Align::right);
if (row.kind == RowKind::choice && row.enabled)
{
const float cy = y + kRowHeight * 0.5f;
const float lx = right - chevrons - w - 22, rx = right - 8;
const Color c = focused ? ink : soft;
list.line(lx + 6, cy - 9, lx, cy, 3.0f, c);
list.line(lx, cy, lx + 6, cy + 9, 3.0f, c);
list.line(rx - 6, cy - 9, rx, cy, 3.0f, c);
list.line(rx, cy, rx - 6, cy + 9, 3.0f, c);
}
used = w + chevrons + (row.kind == RowKind::choice ? 50.0f : 24.0f);
}
// A chevron for a row that opens: right when closed, down when open.
const float indent = row.expander ? 30.0f : 0.0f;
if (row.expander)
{
const float cx = x + 8, cy = label_y - 9;
const Color c = ink.with_alpha(dim);
if (row.expander == 1)
{
list.line(cx - 3, cy - 8, cx + 4, cy, 3.0f, c);
list.line(cx + 4, cy, cx - 3, cy + 8, 3.0f, c);
}
else
{
list.line(cx - 8, cy - 3, cx, cy + 4, 3.0f, c);
list.line(cx, cy + 4, cx + 8, cy - 3, 3.0f, c);
}
}
// The platform's logo, then the label.
float lx = x + indent;
if (row.logo_slot)
{
constexpr float kLogo = 34.0f;
if (row.logo)
{
const float w = std::min(kLogo * 1.6f, kLogo * row.logo_aspect), h = w / row.logo_aspect;
list.image(row.logo, {lx + (kLogo - std::min(w, kLogo)) * 0.5f, label_y - 9 - h * 0.5f, w, h},
hui::gfx::kFullUv, hui::gfx::Color::rgb(0xFFFFFF, dim));
}
lx += kLogo * 1.6f + 8;
}
const float room = std::max(200.0f, bounds.w - 64 - used - (lx - x));
// Rows carry names, paths and the service's errors as they came
ui::text(list, fonts.semibold, fonts.semibold.font->fit(plain_text(row.label.c_str()), 26, room), lx, label_y, 26,
ink.with_alpha(dim));
if (two_lines)
ui::text(list, fonts.regular, fonts.regular.font->fit(plain_text(row.detail.c_str()), 19, room), lx, label_y + 30, 19,
soft.with_alpha(dim));
}
list.pop_clip();
}
const char *label_of(std::span<const Named> options, const std::string &id)
{
for (const Named &o : options)
if (id == o.id)
return o.label;
return options.empty() ? "" : options.front().label;
}
const char *step_option(std::span<const Named> options, const std::string &id, int step)
{
const int n = static_cast<int>(options.size());
int at = 0;
for (int i = 0; i < n; ++i)
if (id == options[static_cast<std::size_t>(i)].id)
at = i;
return options[static_cast<std::size_t>(((at + step) % n + n) % n)].id;
}
std::string interval_label(int minutes)
{
char text[32];
if (minutes <= 0)
return "Off";
if (minutes < 60)
std::snprintf(text, sizeof text, "%d min", minutes);
else if (minutes % 60 == 0)
std::snprintf(text, sizeof text, "%d h", minutes / 60);
else
std::snprintf(text, sizeof text, "%d h %d min", minutes / 60, minutes % 60);
return text;
}
int step_interval(int minutes, int step)
{
constexpr int n = static_cast<int>(std::size(kIntervals));
int at = 0; // the nearest option at or below the current value
for (int i = 0; i < n; ++i)
if (kIntervals[i] <= minutes)
at = i;
return kIntervals[((at + step) % n + n) % n];
}
} // namespace rommps
@@ -0,0 +1,94 @@
// RommPS: a list of setting rows for the pad: switches, choices and actions.
// Settings and the setup's steps build their rows every frame from what the
// payload says, and react to what handle() reports.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <cstdint>
#include "core/input.hpp"
#include "core/tween.hpp"
#include "rommps_look.hpp"
#include "ui/feedback.hpp"
#include "ui/fonts.hpp"
#include "ui/motion.hpp"
#include <span>
#include <string>
#include <vector>
namespace rommps
{
enum class RowKind
{
toggle, // Cross switches it
choice, // left and right step through its options
action, // Cross does it
info, // only shows something
header // a section's name
};
struct Row
{
RowKind kind = RowKind::action;
std::string label;
std::string value; // a choice's option, an action's or info's right-hand text
std::string detail; // a second, smaller line
bool on = false; // a toggle's state
bool enabled = true;
bool danger = false; // an action that can't be undone
int expander = 0; // 1: closed, 2: open: a chevron before the label (Cross opens what's under it)
// A small icon before the label (a platform's logo): the room is kept with
// logo_slot even while its texture (0) is still loading.
bool logo_slot = false;
std::uint32_t logo = 0;
float logo_aspect = 1.0f;
};
struct RowEvent
{
int row = -1;
int step = 0; // a choice: -1 or +1
bool activated = false; // a toggle or action: Cross
};
class RowList
{
public:
static constexpr float kRowHeight = 92.0f;
RowEvent handle(const std::vector<Row> &rows, const hui::InputFrame &input, hui::ui::Feedback &feedback);
void update(float dt, int count, float visible_height);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const std::vector<Row> &rows,
const hui::gfx::Rect &bounds, float clock) const;
int focus() const { return focus_; }
void set_focus(int row, bool snap = true);
void refuse(hui::ui::Feedback &feedback);
private:
int focus_ = 0;
hui::tween::Spring position_, scroll_;
hui::ui::Pulse nudge_;
};
// The options the payload's settings take, as Settings and the setup show them.
struct Named
{
const char *id;
const char *label;
};
inline constexpr Named kPolicies[] = {
{"ask", "Ask me"}, {"newest", "Keep the newest"}, {"local", "Keep this console's"}, {"server", "Keep RomM's"}};
inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Back up to RomM"}, {"sync", "Back up and restore"}};
inline constexpr int kIntervals[] = {0, 15, 30, 60, 120, 360};
const char *label_of(std::span<const Named> options, const std::string &id);
// The option before (-1) or after (+1) id, wrapping round.
const char *step_option(std::span<const Named> options, const std::string &id, int step);
std::string interval_label(int minutes);
int step_interval(int minutes, int step);
} // namespace rommps
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,374 @@
// RommPS: the Settings tab. See settings_page.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "settings_page.hpp"
#include "cJSON.h"
#include "core/tween.hpp"
#include <algorithm>
#include <cstdio>
#include <memory>
namespace rommps
{
namespace
{
namespace ui = hui::ui;
namespace tween = hui::tween;
using hui::gfx::Rect;
constexpr float kMargin = 96.0f;
constexpr float kArmedFor = 4.0f; // seconds a first press waits for the second
const Rect kRows{kMargin - 32, 290, 1150, 640};
const Rect kAbout{1300, 290, 524, 690};
std::string flag_json(const char *key, bool on)
{
return std::string("\"") + key + "\":" + (on ? "true" : "false");
}
std::string int_json(const char *key, int value)
{
return std::string("\"") + key + "\":" + std::to_string(value);
}
std::string str_json(const char *key, const char *value)
{
return std::string("\"") + key + "\":\"" + value + "\"";
}
} // namespace
void SettingsPage::enter(App &app)
{
app.refresh_config();
app.refresh_update();
armed_ = -1;
on_credits_ = false;
}
std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
{
const Config &c = app.config();
const Update &u = app.update();
std::vector<Row> r(kCount);
const Look &l = look(current_look());
r[kSaveSync] = {RowKind::toggle, "Save sync", "", c.save_sync ? "" : "RomM is only for games",
c.save_sync};
r[kLook] = {RowKind::choice, "Theme", l.name, l.family};
r[kSounds] = {RowKind::toggle, "Interface sounds", "", "", ui_sounds()};
r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""};
r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit};
r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "",
c.sync_on_game_start};
r[kOnChange] = {RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change};
r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), ""};
r[kPolicy] = {RowKind::choice, "Resolving save conflicts", label_of(kPolicies, c.conflict_policy),
"What RommPS will do by default whenever a save conflict exists between this PS5 and your RomM Server"};
r[kConcurrency] = {RowKind::choice, "Concurrent Game Downloads", std::to_string(c.download_concurrency), ""};
r[kNotify] = {RowKind::toggle, "Notifications", "", "", c.notify};
r[kUpdateCheck] = {RowKind::toggle, "Check for updates daily", "", "", c.update_check};
{
const Status &s = app.status();
char detail[96];
if (!c.cover_cache)
std::snprintf(detail, sizeof detail, "Covers come from RomM as you browse");
else if (s.covers_state == "paused")
std::snprintf(detail, sizeof detail, "Paused while you play or download (%d of %d saved)", s.covers_done,
s.covers_total);
else if (s.covers_state == "full")
std::snprintf(detail, sizeof detail, "The cover space is nearly full (%d of %d saved)", s.covers_done,
s.covers_total);
else if (s.covers_total > 0 && s.covers_done < s.covers_total)
std::snprintf(detail, sizeof detail, "Saving covers: %d of %d", s.covers_done, s.covers_total);
else if (s.covers_total > 0)
std::snprintf(detail, sizeof detail, "All %d covers are on the console", s.covers_total);
else
std::snprintf(detail, sizeof detail, "%s", "");
r[kCovers] = {RowKind::toggle, "Keep all covers on the console", "", detail, c.cover_cache};
}
Row &update = r[kUpdate];
update.kind = RowKind::action;
update.label = "RommPS " + (u.current.empty() ? app.status().version : u.current);
if (!u.self_update)
{
// The app brings its background service with it: the store updates both.
update.label = std::string("RommPS ") + App::kMinPayload;
update.value = u.state == "checking" ? "Checking" : "Check for updates";
update.enabled = u.state != "checking";
update.detail = u.available ? "RommPS " + u.latest + " is out: update it in ProsperoStore"
: u.state == "error" ? (u.error.empty() ? u.message : u.error)
: u.latest.empty() ? "Updates come from ProsperoStore"
: "Up to date";
}
else if (u.busy())
{
update.value = u.message.empty() ? "Working" : u.message;
if (u.total > 0)
{
char text[32];
std::snprintf(text, sizeof text, " %d%%", static_cast<int>(100.0 * u.done / u.total));
update.value += text;
}
update.enabled = false;
}
else if (u.available)
{
update.value = "Install " + u.latest;
update.detail = "RommPS restarts with the new version";
}
else
{
update.value = "Check for updates";
update.detail = u.state == "error" ? (u.error.empty() ? u.message : u.error)
: u.state == "pending" ? "Restart the console to finish installing"
: u.latest.empty() ? ""
: "Up to date";
}
r[kEmulators] = {RowKind::action, "Configure Emulators", "", ""};
r[kSetup] = {RowKind::action, "Re-run setup wizard", "", ""};
r[kForget] = {RowKind::action, "Unpair from RomM", "", "Files on this console are kept"};
r[kForget].danger = true;
r[kDiagnostics] = {RowKind::action, "Diagnostic bundle", diag_ == 1 ? "Making" : "",
diag_ == 2 ? "Saved to " + diag_where_
: diag_ == 3 ? "Couldn't make it"
: "Gather logs to report an issue. The bundle does not collect any personally identifiable information."};
r[kDiagnostics].enabled = diag_ != 1;
if (armed_ == kSetup || armed_ == kForget)
r[static_cast<std::size_t>(armed_)].value = "Press again to confirm";
if (!c.known)
for (int i = kInterval; i <= kCovers; ++i)
r[static_cast<std::size_t>(i)].enabled = false;
// In sections.
static const struct
{
const char *name;
int ids[7];
} kSections[] = {
{"Sync", {kSaveSync, kInterval, kOnExit, kOnStart, kOnChange, kPolicy, kStates}},
{"Downloads", {kConcurrency, kCovers, -1}},
{"Theme, sounds and notifications", {kLook, kSounds, kNotify, -1}},
{"Emulators", {kEmulators, -1}},
{"Updates", {kUpdate, kUpdateCheck, -1}},
{"RomM", {kSetup, kForget, -1}},
{"Help", {kDiagnostics, -1}},
};
std::vector<Row> out;
if (ids)
ids->clear();
for (const auto &section : kSections)
{
out.push_back({RowKind::header, section.name});
if (ids)
ids->push_back(kHeader);
for (int id : section.ids)
{
if (id < 0)
break;
// Save sync off: just the switch to turn it back on.
if (!c.save_sync && id >= kInterval && id <= kPolicy)
continue;
out.push_back(r[static_cast<std::size_t>(id)]);
if (ids)
ids->push_back(id);
}
}
return out;
}
const char *SettingsPage::cross_hint(const App &app) const
{
if (on_credits_)
return "Answer";
const std::vector<Row> r = rows(app);
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, static_cast<int>(r.size()) - 1))];
switch (row.kind)
{
case RowKind::toggle:
return "Switch";
case RowKind::choice:
return "Change";
default:
return "Choose";
}
}
void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
avatar_texture(static_cast<hui::gfx::Renderer *>(rommps_renderer()));
std::vector<int> ids;
const std::vector<Row> r = rows(app, &ids);
const int count = static_cast<int>(r.size());
armed_age_ += dt;
// Below the last row: the author's portrait, and the call behind it.
if (on_credits_)
{
list_.update(dt, count, kRows.h);
if (input.nav == hui::Direction::up || input.is_pressed(hui::Action::back))
{
on_credits_ = false;
feedback.play(hui::audio::Cue::focus);
}
else if (input.is_pressed(hui::Action::confirm))
{
call_.start();
feedback.play(hui::audio::Cue::modal_open);
}
else if (input.nav != hui::Direction::none && !input.nav_repeat)
list_.refuse(feedback);
return;
}
if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == count - 1)
{
on_credits_ = true;
feedback.play(hui::audio::Cue::focus);
return;
}
const int focused_id = ids[static_cast<std::size_t>(std::clamp(list_.focus(), 0, count - 1))];
if (armed_ >= 0 && (armed_age_ > kArmedFor || focused_id != armed_))
armed_ = -1;
const RowEvent e = list_.handle(r, input, feedback);
list_.update(dt, count, kRows.h);
if (e.row < 0)
return;
const int id = ids[static_cast<std::size_t>(e.row)];
const Config &c = app.config();
switch (id)
{
case kSaveSync:
app.set_config(flag_json("save_sync", !c.save_sync), [](Config &k) { k.save_sync = !k.save_sync; });
break;
case kLook:
set_look((current_look() + e.step + look_count()) % look_count());
break;
case kSounds:
set_ui_sounds(!ui_sounds());
break;
case kInterval:
{
const int minutes = step_interval(c.sync_interval_min, e.step);
app.set_config(int_json("sync_interval_min", minutes), [minutes](Config &k) { k.sync_interval_min = minutes; });
break;
}
case kOnExit:
app.set_config(flag_json("sync_on_game_exit", !c.sync_on_game_exit),
[](Config &k) { k.sync_on_game_exit = !k.sync_on_game_exit; });
break;
case kOnStart:
app.set_config(flag_json("sync_on_game_start", !c.sync_on_game_start),
[](Config &k) { k.sync_on_game_start = !k.sync_on_game_start; });
break;
case kOnChange:
app.set_config(flag_json("sync_on_change", !c.sync_on_change),
[](Config &k) { k.sync_on_change = !k.sync_on_change; });
break;
case kStates:
{
const std::string id = step_option(rommps::kStates, c.states, e.step);
app.set_config(str_json("states", id.c_str()), [id](Config &k) { k.states = id; });
break;
}
case kPolicy:
{
const std::string id = step_option(kPolicies, c.conflict_policy, e.step);
app.set_config(str_json("conflict_policy", id.c_str()), [id](Config &k) { k.conflict_policy = id; });
break;
}
case kConcurrency:
{
const int n = (c.download_concurrency - 1 + e.step + 4) % 4 + 1;
app.set_config(int_json("download_concurrency", n), [n](Config &k) { k.download_concurrency = n; });
break;
}
case kNotify:
app.set_config(flag_json("notify", !c.notify), [](Config &k) { k.notify = !k.notify; });
break;
case kUpdateCheck:
app.set_config(flag_json("update_check", !c.update_check), [](Config &k) { k.update_check = !k.update_check; });
break;
case kCovers:
app.set_config(flag_json("cover_cache", !c.cover_cache), [](Config &k) { k.cover_cache = !k.cover_cache; });
break;
case kUpdate:
if (app.update().available && app.update().self_update)
app.install_update();
else
app.check_update();
break;
case kEmulators:
emulators_asked_ = true;
break;
case kDiagnostics:
diag_ = 1;
app.post("/api/diagnostics", "{}", [this](const Response &r) {
std::unique_ptr<cJSON, void (*)(cJSON *)> j(r.ok() ? r.json() : nullptr, cJSON_Delete);
const cJSON *usb = cJSON_GetObjectItemCaseSensitive(j.get(), "usb"),
*path = cJSON_GetObjectItemCaseSensitive(j.get(), "path");
const cJSON *at = cJSON_IsString(usb) ? usb : path;
diag_ = cJSON_IsString(at) ? 2 : 3;
diag_where_ = cJSON_IsString(at) ? at->valuestring : "";
});
break;
case kSetup:
case kForget:
if (armed_ != id)
{
armed_ = id;
armed_age_ = 0.0f;
break;
}
armed_ = -1;
if (id == kSetup)
app.setup_restart();
else
app.pair_forget();
break;
default:
break;
}
}
void SettingsPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
float clock, float age) const
{
const float in = tween::stagger(age, 1, 0.08f, 0.6f);
list.push_opacity(in);
ui::text(list, fonts.display, "Settings", kMargin, 240, 64, look.text);
list_.draw(list, fonts, look, rows(app), {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock);
// About
const float appear = tween::stagger(age, 3, 0.08f, 0.6f);
list.push_opacity(appear);
const Rect panel{kAbout.x, kAbout.y + 24 * (1.0f - appear), kAbout.w, kAbout.h};
draw_panel(list, look, panel);
const Status &s = app.status();
const float x = panel.x + 36;
float y = panel.y + 64;
auto item = [&](const char *label, const std::string &value) {
ui::text(list, fonts.semibold, label, x, y, 16, look.panel_muted, hui::gfx::Align::left, 3.0f);
// The server's address is as it was typed
ui::text(list, fonts.regular, fonts.regular.font->fit(value.empty() ? "-" : plain_text(value.c_str()), 24, panel.w - 72), x,
y + 36, 24, look.panel_text);
y += 80;
};
item("ROMM SERVER", s.server);
item("SIGNED IN AS", s.user);
item("ROMM VERSION", s.server_version);
char web[96];
std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port);
item("ALSO ON YOUR PHONE OR COMPUTER", s.ip.empty() ? "" : web);
const Update &u = app.update();
item("STARTS WITH THE CONSOLE", !u.known ? "" : u.autostart ? "Yes" : "No: reopen RommPS to set it up");
list.line(panel.x + 36, panel.y + panel.h - 196, panel.x + panel.w - 36, panel.y + panel.h - 196, 1.5f, look.outline);
draw_credits(list, fonts, look, {x, panel.y + panel.h - 168, panel.w - 72, 136}, on_credits_, clock);
list.pop_opacity();
list.pop_opacity();
}
} // namespace rommps
@@ -0,0 +1,75 @@
// RommPS: the Settings tab, in sections: when and what it syncs, downloads,
// the look and notifications, emulators, updates and the RomM connection; an
// About panel beside them.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <string>
#include "credits.hpp"
#include "rommps_app.hpp"
#include "rows.hpp"
namespace rommps
{
class SettingsPage
{
public:
void enter(App &app);
void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock,
float age) const;
// What Cross does on the focused row, for the hints.
const char *cross_hint(const App &app) const;
// True once after Emulators was chosen: the screen opens that setup step.
bool take_emulators_request()
{
const bool asked = emulators_asked_;
emulators_asked_ = false;
return asked;
}
// The Codec call behind the author's portrait; the screen draws it over everything.
CodecCall &call() { return call_; }
const CodecCall &call() const { return call_; }
private:
enum Id
{
kSaveSync,
kLook,
kSounds,
kInterval,
kOnExit,
kOnStart,
kOnChange,
kStates,
kPolicy,
kConcurrency,
kNotify,
kUpdateCheck,
kCovers,
kUpdate,
kEmulators,
kSetup,
kForget,
kDiagnostics,
kCount,
kHeader = -1 // a section's name
};
// The rows as shown, sections included; ids[i] is row i's Id.
std::vector<Row> rows(const App &app, std::vector<int> *ids = nullptr) const;
RowList list_;
bool on_credits_ = false;
bool emulators_asked_ = false; // the focus is on the author's portrait, below the rows
CodecCall call_;
// The diagnostic bundle: 0 not asked, 1 being made, 2 saved (where_), 3 failed.
int diag_ = 0;
std::string diag_where_;
int armed_ = -1; // a row that does something lasting, waiting for its second press
float armed_age_ = 0.0f;
};
} // namespace rommps
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,246 @@
// RommPS: the setup, on the console. The same steps as the web page's wizard:
//
// 1. the RomM server's address, typed on the kit's on-screen keyboard;
// 2. the code to approve in RomM (its device pairing), until it's approved;
// 3. the emulators found on the console, then each system they play: which
// one plays it when several can, and its folders (the ones found, or
// folders the user picks);
// 4. what the first sync would do, and a word about backups;
// 5. when to sync, then the first sync.
//
// The step follows the payload: unpaired is step 1 or 2, paired but not set
// up is 3 onwards.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "rommps_app.hpp"
#include "rows.hpp"
#include "ui/components/keyboard.hpp"
#include <array>
#include <map>
#include <set>
#include <string>
#include <vector>
namespace rommps
{
class SetupPage
{
public:
SetupPage();
SetupPage(const SetupPage &) = delete;
SetupPage &operator=(const SetupPage &) = delete;
// Starts over from where the payload is (each time the setup appears).
void enter();
// Settings' Emulators: just this step, from the emulators set up now;
// Save or Circle goes back to Settings.
void edit_emulators();
bool editing() const { return editing_; }
void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock) const;
// The hints for the step on screen.
std::vector<hui::ui::Hint> hints() const;
private:
enum Step
{
kServer,
kApprove,
kEmulators,
kPreview,
kFinish
};
struct System
{
std::string key, label; // label: the emulator, and its core when it has one
std::vector<std::string> slugs; // every RomM slug it plays
};
// A system on the RomM server no emulator here plays: off, or the folders picked for it.
struct Own
{
std::vector<std::string> slugs;
std::string name;
int games = -1; // the server's count, -1 for an emulator's system
bool on = false;
std::string folder[3]; // games, saves, states
std::string id; // for an emulator with no preset: its profile, "<emulator>-<system>"
};
struct Emulator
{
std::string root, name, kind, id;
std::vector<std::string> cores;
std::vector<System> systems;
bool on = true;
std::string sync = "all"; // its save sync: "all", "saves" (no states) or "off"
bool ready = true; // false: found, no preset yet (set up by its folders, like a system with no emulator)
std::string note;
// Its folders for each system when left as they are (detection's
// "dirs"): games, saves, states; and which it doesn't let you change.
std::map<std::string, std::array<std::string, 3>> dirs;
std::map<std::string, int> games; // games already in its folder, by system
std::set<std::string> fixed; // "rom_dir", "save_dir", "state_dir"
};
// What a row of the emulator step does.
struct RowRef
{
enum Kind
{
kNone,
kEmulator,
kEmuSync, // an emulator's save sync (unused since save sync is per platform)
kSysSync, // a platform's save sync
kMain,
kAlso,
kPs2,
kOwn,
kOwnFolder,
kFolders, // a system's folders in an emulator: as found, or picked
kSysFolder, // one of them (emulator: 0 games, 1 saves, 2 states)
kOwnMove, // an extra's games of its own: to the main emulator
kOwnDelete, // or deleted
kContinue
} kind = kNone;
int emulator = -1; // or, for kOwnFolder, which folder (0 games, 1 saves, 2 states)
std::string key, root;
};
struct Option
{
std::string root, label;
bool retroarch = false;
};
struct Conflict
{
std::string key;
std::vector<Option> options;
};
struct Preview
{
bool loaded = false, loading = false;
std::string error;
int games = 0, saves = 0, up = 0, down = 0, both = 0, in_sync = 0;
};
void go(Step step);
void follow_status(App &app);
void update_server(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void update_emulators(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void update_preview(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void update_finish(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void load_emulators(App &app);
// Whether each extra plays the main emulator's games through a mount, or
// why not (/api/paths), by "system|root".
void load_mounts(App &app);
void load_preview(App &app);
std::vector<Conflict> conflicts() const; // systems several emulators on play
std::vector<Conflict> systems() const; // every system an emulator on plays, by name
// The root of the emulator that keeps a system: the one picked ("" when
// it's off), else RetroArch, else the first.
std::string chosen(const Conflict &conflict) const;
// Whether an emulator plays a system, as the main one or an extra.
bool used(const Emulator &e) const;
// Rows above the emulators': their heading, or a message instead of them.
int lead() const { return 1; }
std::vector<Row> emulator_rows(const App &app, std::vector<RowRef> *refs = nullptr) const;
// Settings' Emulators: what Save sends, whether it differs from what was
// saved, and saving it.
std::string save_body() const;
bool dirty() const;
void save_emulators(App &app, hui::ui::Feedback &feedback);
// Leaving with changes not saved: Save, Discard or Keep editing.
std::vector<Row> leave_rows() const;
// ids: what each row is (the steps' rows depend on save_sync_).
std::vector<Row> preview_rows(std::vector<int> *ids = nullptr) const;
std::vector<Row> finish_rows(std::vector<int> *ids = nullptr) const;
bool has_ps2() const;
// The server's systems no ticked emulator plays, in the server's order.
std::vector<std::string> uncovered() const;
// The folder picker (/api/fs), for one of a system's folders.
void open_picker(App &app, const std::string &key, int which, const std::string &root = "");
void browse(App &app, const std::string &path);
void update_picker(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, float clock) const;
void draw_steps(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look) const;
void draw_server(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
float clock) const;
void draw_approve(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
float clock) const;
void draw_heading(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const char *title,
const std::string &text) const;
struct MountStatus
{
bool mounted = false;
std::string note;
int own = 0; // games of its own in the way
};
std::map<std::string, MountStatus> mounts_;
// Each system's memory card method (/api/paths): "shared" or "per_game",
// and why an emulator's cards don't sync (it keeps the other).
struct CardMethod
{
std::string method;
std::string note;
};
std::map<std::string, CardMethod> cards_;
std::string confirm_delete_; // "system|root" after the first Cross on Delete
Step step_ = kServer;
float step_age_ = 0.0f;
RowList rows_;
bool busy_ = false; // a step's request is out
std::string error_;
// 1-2
hui::ui::Keyboard keys_;
std::string url_;
bool url_known_ = false;
bool tls_verify_ = true;
// 3
std::vector<Emulator> emulators_;
bool emulators_loaded_ = false;
float retry_in_ = 0.0f; // after an error: looking again (the service may have been restarting)
std::map<std::string, std::string> choices_; // system key -> the root of its main emulator ("": off)
std::map<std::string, std::string> sync_; // system key -> its save sync: "all", "saves" or "off"
std::string expanded_; // the system whose details show
int focus_expander_ = 0; // the focused row's chevron (for the hint): 0, 1 closed, 2 open
std::map<std::string, std::set<std::string>> also_; // system key -> roots of its extra emulators
// Folders picked for a system in an emulator, instead of the ones found:
// system key -> emulator root -> games, saves, states ("" keeps that one).
std::map<std::string, std::map<std::string, std::array<std::string, 3>>> custom_dirs_;
bool editing_ = false;
std::string saved_; // save_body() + ps2_cards_ as last saved (Settings' Emulators)
bool leaving_ = false; // asking whether to save before going back
RowList leave_list_;
std::string ps2_cards_ = "per_game";
std::map<std::string, Own> own_; // by system key (first slug)
// Each platform's logo (texture, width over height) for the emulator rows,
// asked for in update, where the app can fetch them.
std::map<std::string, std::pair<std::uint32_t, float>> logos_;
std::vector<std::string> own_order_; // the server's order
std::vector<std::string> manual_; // own_ keys of emulators with no preset, in the order found
bool picking_ = false;
std::string pick_key_;
std::string pick_root_; // set: the folder is for custom_dirs_[pick_key_][pick_root_], not own_
int pick_which_ = 0;
std::string fs_path_, fs_parent_;
std::vector<std::string> fs_folders_;
bool fs_loading_ = false;
std::string fs_error_;
RowList pick_rows_;
// 4
Preview preview_;
bool backed_up_ = false;
bool save_sync_ = true; // false: RomM only as a game source, the sync steps skipped
// 5
Config finish_;
bool finish_known_ = false;
};
} // namespace rommps
@@ -0,0 +1,495 @@
// RommPS: lists about the sync. See sync_views.hpp.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "sync_views.hpp"
#include "cJSON.h"
#include "core/tween.hpp"
#include <algorithm>
#include <cstdio>
#include <ctime>
#include <memory>
namespace rommps
{
namespace
{
namespace ui = hui::ui;
using hui::Action;
using hui::audio::Cue;
using hui::gfx::Rect;
using Json = std::unique_ptr<cJSON, void (*)(cJSON *)>;
constexpr float kMargin = 96.0f;
constexpr float kArmedFor = 4.0f;
const Rect kRows{kMargin - 32, 300, 1792, 650};
const char *str(const cJSON *o, const char *key, const char *fallback = "")
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
return cJSON_IsString(v) ? v->valuestring : fallback;
}
double num(const cJSON *o, const char *key, double fallback = 0)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
return cJSON_IsNumber(v) ? v->valuedouble : fallback;
}
std::string json_str(const std::string &s)
{
std::string out = "\"";
for (char c : s)
{
if (c == '"' || c == '\\')
out += '\\';
out += c;
}
return out + "\"";
}
std::string ago(double t)
{
if (t <= 0)
return "never";
const double s = std::difftime(std::time(nullptr), static_cast<std::time_t>(t));
char text[32];
if (s < 60)
return "just now";
if (s < 3600)
std::snprintf(text, sizeof text, "%d min ago", static_cast<int>(s / 60));
else if (s < 86400)
std::snprintf(text, sizeof text, "%d h ago", static_cast<int>(s / 3600));
else
std::snprintf(text, sizeof text, s < 2 * 86400 ? "%d day ago" : "%d days ago", static_cast<int>(s / 86400));
return text;
}
// "2026-10-14T06:53:37+00:00" as "2026-10-14 06:53 UTC".
std::string when(const char *iso)
{
const std::string s = iso ? iso : "";
if (s.size() < 16)
return s;
return s.substr(0, 10) + " " + s.substr(11, 5) + " UTC";
}
std::string local_time(double t)
{
if (t <= 0)
return "never";
const std::time_t tt = static_cast<std::time_t>(t);
char text[32];
std::strftime(text, sizeof text, "%Y-%m-%d %H:%M", std::localtime(&tt));
return text;
}
std::string base_name(const std::string &path)
{
const std::string p = path.substr(0, path.find('#')); // a save folder synced as a zip
const std::size_t slash = p.find_last_of('/');
return slash == std::string::npos ? p : p.substr(slash + 1);
}
std::string size_text(double bytes)
{
char text[32];
if (bytes >= 1024 * 1024)
std::snprintf(text, sizeof text, "%.1f MB", bytes / (1024 * 1024));
else
std::snprintf(text, sizeof text, "%.0f KB", bytes / 1024);
return text;
}
// "uploaded" -> "Uploaded"
std::string capital(std::string s)
{
if (!s.empty() && s[0] >= 'a' && s[0] <= 'z')
s[0] = static_cast<char>(s[0] - 'a' + 'A');
return s;
}
const char *status_label(const std::string &status)
{
return status == "synced" ? "In sync"
: status == "changed" ? "Changed here"
: status == "conflict" ? "Conflict"
: status == "missing" ? "Missing"
: status == "no_game" ? "Game not here"
: status.c_str();
}
// A platform's name: RomM's, else the folder's own.
std::string platform_name(const App &app, const std::string &dir)
{
for (const Platform &p : app.platforms())
if (p.slug == dir)
return p.name;
std::string up = dir;
for (char &c : up)
if (c >= 'a' && c <= 'z')
c = static_cast<char>(c - 'a' + 'A');
return up.empty() ? std::string("Other") : up;
}
// One log entry as a row: the game, its result ("Downloaded") on the right,
// then "Save · RetroArch (mGBA) · 3 min ago".
Row event_row(const cJSON *e, bool with_game)
{
const std::string kind = str(e, "kind"), action = str(e, "action");
const std::string what = kind == "card" ? std::string("Memory card") : kind == "bios" ? std::string("BIOS") : capital(kind);
// BIOS files and emulators belong to no game: they're named by their kind.
const bool no_game = kind == "card" || num(e, "rom_id") <= 0;
std::string label = what;
if (with_game && !no_game)
{
const std::string game = plain_text(str(e, "game"));
label = game.empty() ? "Game " + std::to_string(static_cast<int>(num(e, "rom_id"))) : game;
}
std::string detail = with_game && !no_game ? what : std::string();
auto more = [&detail](const std::string &part) {
if (!part.empty())
detail += (detail.empty() ? "" : " \xC2\xB7 ") + part;
};
more(plain_text(str(e, "detail"))); // file names, the service's errors
if (action != "skipped")
more(plain_text(str(e, "emulator")));
more(ago(num(e, "t")));
Row r{RowKind::info, label, capital(action), detail};
r.danger = action == "failed" || action == "conflict";
return r;
}
// One tracked file as a row: the game (or the kind, inside a game's view),
// its status on the right, then "Save · synced 3 min ago".
Row file_row(const cJSON *f, const std::string &game)
{
const std::string kind = str(f, "kind");
std::string detail = (game.empty() ? std::string() : capital(kind) + " \xC2\xB7 ") + "synced " + ago(num(f, "synced_at"));
if (kind == "state" && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "from_here")) && num(f, "server_id") > 0)
detail += " \xC2\xB7 from another device";
Row r{RowKind::info, game.empty() ? capital(kind) : game, status_label(str(f, "status")), detail};
r.danger = std::string(str(f, "status")) == "missing";
return r;
}
} // namespace
void SyncView::open(App &app, Kind kind, int rom_id, const std::string &name)
{
active_ = true;
kind_ = kind;
rom_id_ = rom_id;
name_ = name;
choosing_ = -1;
armed_ = -1;
age_ = 0.0f;
busy_ = false;
list_.set_focus(0);
load(app);
}
void SyncView::load(App &app)
{
const int gen = ++generation_;
items_.clear();
conflicts_.clear();
switch (kind_)
{
case kConflicts:
title_ = "Conflicts";
subtitle_ = "The copy you don't keep is backed up.";
break;
case kActivity:
title_ = "Activity";
subtitle_ = "Newest first";
break;
case kTracked:
title_ = "Tracked saves";
subtitle_ = "";
break;
case kGame:
title_ = plain_text(name_.c_str());
subtitle_ = "Cross twice restores a version from RomM";
break;
}
add({RowKind::info, "Loading", "", ""});
waiting_ = 1;
if (kind_ == kConflicts)
{
app.get("/api/status", [this, gen](const Response &r) {
if (gen != generation_)
return;
Json j(r.json(), cJSON_Delete);
const cJSON *c;
cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "conflicts"))
{
Conflict k;
k.path = str(c, "path");
k.game = plain_text(*str(c, "rom_name") ? str(c, "rom_name") : base_name(k.path).c_str());
k.local = local_time(num(c, "local_mtime"));
k.server = when(str(c, "server_updated_at"));
conflicts_.push_back(std::move(k));
}
if (!r.ok())
conflicts_.clear();
show_conflicts();
if (!r.ok())
{
items_.clear();
add({RowKind::info, "Couldn't load them", "", r.message()});
}
});
}
else if (kind_ == kActivity)
{
app.get("/api/events?limit=200", [this, gen](const Response &r) {
if (gen != generation_)
return;
items_.clear();
Json j(r.json(), cJSON_Delete);
const bool ok = r.ok() && cJSON_IsArray(j.get());
const cJSON *e;
if (ok)
cJSON_ArrayForEach(e, j.get()) add(event_row(e, true));
if (items_.empty())
add({RowKind::info, ok ? "Nothing yet" : "Couldn't load the log", "",
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
});
}
else if (kind_ == kTracked)
{
app.get("/api/tracked", [this, gen, &app](const Response &r) {
if (gen != generation_)
return;
items_.clear();
Json j(r.json(), cJSON_Delete);
const bool ok = r.ok() && cJSON_IsArray(cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"));
struct Plat
{
std::string name;
const cJSON *games;
};
std::vector<Plat> plats;
const cJSON *p;
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(j.get(), "platforms"))
plats.push_back({platform_name(app, str(p, "platform")), cJSON_GetObjectItemCaseSensitive(p, "games")});
std::sort(plats.begin(), plats.end(), [](const Plat &a, const Plat &b) { return a.name < b.name; });
for (const Plat &pl : plats)
{
add({RowKind::header, pl.name});
const cJSON *g, *f;
cJSON_ArrayForEach(g, pl.games)
{
const std::string game = plain_text(str(g, "name"));
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, game));
}
}
if (items_.empty())
add({RowKind::info, ok ? "Nothing tracked yet" : "Couldn't load them", "",
ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()});
char sub[96];
std::snprintf(sub, sizeof sub, "%d saves, %d states", static_cast<int>(num(j.get(), "saves")),
static_cast<int>(num(j.get(), "states")));
if (ok)
subtitle_ = sub;
});
}
else
{
// A game: what's here, RomM's versions and the game's log, shown together.
waiting_ = 3;
game_here_.clear();
game_history_.clear();
game_log_.clear();
const std::string id = std::to_string(rom_id_);
auto done = [this, gen](std::string *slot, const Response &r) {
if (gen != generation_)
return;
*slot = r.ok() ? r.body : std::string("!") + r.message();
if (--waiting_ > 0)
return;
items_.clear();
Json here(cJSON_Parse(game_here_.c_str()), cJSON_Delete), history(cJSON_Parse(game_history_.c_str()), cJSON_Delete),
log(cJSON_Parse(game_log_.c_str()), cJSON_Delete);
add({RowKind::header, "On this console"});
const std::size_t before = items_.size();
const cJSON *p, *g, *f;
cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(here.get(), "platforms"))
cJSON_ArrayForEach(g, cJSON_GetObjectItemCaseSensitive(p, "games"))
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, ""));
if (items_.size() == before)
add({RowKind::info, "Nothing here yet", "", ""});
static const char *const kinds[2][2] = {{"saves", "Saves on RomM"}, {"states", "Save states on RomM"}};
for (int k = 0; k < 2; ++k)
{
add({RowKind::header, kinds[k][1]});
const cJSON *list = cJSON_GetObjectItemCaseSensitive(history.get(), kinds[k][0]);
std::vector<const cJSON *> versions;
const cJSON *v;
cJSON_ArrayForEach(v, list) versions.push_back(v);
std::sort(versions.begin(), versions.end(), [](const cJSON *a, const cJSON *b) {
return std::string(str(a, "updated_at")) > std::string(str(b, "updated_at"));
});
for (const cJSON *x : versions)
{
std::string from = *str(x, "device") ? str(x, "device") : str(x, "emulator");
std::string detail = plain_text(str(x, "file_name")) + " \xC2\xB7 " + size_text(num(x, "size"));
if (!from.empty())
detail += " \xC2\xB7 from " + plain_text(from.c_str());
add({RowKind::action, when(str(x, "updated_at")), "Restore", detail}, k ? kRestoreState : kRestoreSave,
static_cast<int>(num(x, "id")));
}
if (versions.empty())
add({RowKind::info, game_history_[0] == '!' ? "Couldn't ask RomM" : "Nothing on RomM yet", "",
game_history_[0] == '!' ? game_history_.substr(1) : ""});
}
add({RowKind::header, "Recent"});
const std::size_t log_at = items_.size();
const cJSON *e;
int n = 0;
cJSON_ArrayForEach(e, log.get()) if (n++ < 30) add(event_row(e, false));
if (items_.size() == log_at)
add({RowKind::info, "Nothing synced yet", "", ""});
};
app.get("/api/tracked?rom_id=" + id, [done, this](const Response &r) { done(&game_here_, r); });
app.get("/api/history?rom_id=" + id, [done, this](const Response &r) { done(&game_history_, r); });
app.get("/api/events?limit=30&rom_id=" + id, [done, this](const Response &r) { done(&game_log_, r); });
}
}
void SyncView::show_conflicts()
{
items_.clear();
choosing_ = -1;
for (std::size_t i = 0; i < conflicts_.size(); ++i)
{
const Conflict &c = conflicts_[i];
add({RowKind::action, c.game, "Choose", "This console: " + c.local + " \xC2\xB7 RomM: " + c.server}, kPickConflict,
static_cast<int>(i));
}
if (items_.empty())
add({RowKind::info, "No conflicts", "", ""});
}
std::vector<Row> SyncView::rows() const
{
std::vector<Row> out;
out.reserve(items_.size());
for (const Item &it : items_)
out.push_back(it.row);
if (armed_ >= 0 && armed_ < static_cast<int>(out.size()))
out[static_cast<std::size_t>(armed_)].value = "Press again to restore";
return out;
}
void SyncView::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback)
{
age_ += dt;
armed_age_ += dt;
if (input.is_pressed(Action::back))
{
feedback.play(Cue::back);
if (choosing_ >= 0)
show_conflicts();
else
active_ = false;
return;
}
const std::vector<Row> r = rows();
if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_))
armed_ = -1;
const RowEvent e = list_.handle(r, input, feedback);
list_.update(dt, static_cast<int>(r.size()), kRows.h);
if (e.row < 0 || !e.activated || busy_)
return;
const Item it = items_[static_cast<std::size_t>(e.row)];
switch (it.action)
{
case kPickConflict:
{
const Conflict &c = conflicts_[static_cast<std::size_t>(it.arg)];
items_.clear();
choosing_ = it.arg;
add({RowKind::info, c.game, "", "Which copy should stay?"});
add({RowKind::action, "Keep this console's", "", "Changed here " + c.local + ". RomM's copy is replaced."}, kKeepLocal);
add({RowKind::action, "Keep RomM's", "", "Changed on RomM " + c.server + ". This console's copy is backed up."},
kKeepServer);
add({RowKind::action, "Not now"}, kCancel);
list_.set_focus(1);
break;
}
case kKeepLocal:
case kKeepServer:
{
const Conflict c = conflicts_[static_cast<std::size_t>(choosing_)];
const bool local = it.action == kKeepLocal;
busy_ = true;
app.post("/api/conflicts/resolve",
"{\"path\":" + json_str(c.path) + ",\"keep\":\"" + (local ? "local" : "server") + "\"}",
[this, &app, local](const Response &r) {
busy_ = false;
app.say(r.ok() ? (local ? "Kept this console's save" : "Kept RomM's save") : "Couldn't settle it: " + r.message());
app.refresh_now();
load(app);
});
break;
}
case kCancel:
show_conflicts();
list_.set_focus(0);
break;
case kRestoreSave:
case kRestoreState:
if (armed_ != e.row)
{
armed_ = e.row;
armed_age_ = 0.0f;
break;
}
armed_ = -1;
busy_ = true;
app.post("/api/restore",
std::string("{\"kind\":\"") + (it.action == kRestoreSave ? "save" : "state") + "\",\"id\":" +
std::to_string(it.arg) + "}",
[this, &app](const Response &r) {
busy_ = false;
app.say(r.ok() ? "Restored. The current file was backed up" : "Couldn't restore it: " + r.message());
if (r.ok())
load(app);
});
break;
default:
list_.refuse(feedback);
break;
}
}
void SyncView::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app,
float clock) const
{
(void)app;
const float in = hui::tween::stagger(age_, 1, 0.08f, 0.6f);
list.push_opacity(in);
ui::text(list, fonts.display, fonts.display.font->fit(title_, 64, 1700), kMargin, 220, 64, look.text);
ui::text(list, fonts.regular, fonts.regular.font->fit(subtitle_, 24, 1700), kMargin, 268, 24, look.muted);
list_.draw(list, fonts, look, rows(), {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock);
list.pop_opacity();
}
std::vector<hui::ui::Hint> SyncView::hints() const
{
std::vector<hui::ui::Hint> h;
const int f = list_.focus();
const bool can = f >= 0 && f < static_cast<int>(items_.size()) && items_[static_cast<std::size_t>(f)].action != kNothing;
if (can)
h.push_back({hui::ui::Button::cross, choosing_ >= 0 ? "Choose" : kind_ == kGame ? "Restore" : "Choose"});
h.push_back({hui::ui::Button::circle, "Back"});
return h;
}
} // namespace rommps
@@ -0,0 +1,93 @@
// RommPS: lists about the sync, opened from Home and from a game's sheet.
//
// - Conflicts: saves changed here and on RomM since the last sync. Cross on
// one asks which copy to keep; the other is backed up.
// - Activity: the sync log (the payload's /api/events), newest first.
// - Tracked saves: the saves and states kept in sync, by platform, each
// with its status (/api/tracked).
// - A game's saves and states: what's on this console, the versions RomM
// keeps (Cross twice restores one) and the game's log.
//
// Each is a list of rows (rows.hpp) filled from the payload when it opens;
// Circle closes it.
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "rommps_app.hpp"
#include "rows.hpp"
#include "ui/glyphs.hpp"
#include <string>
#include <vector>
namespace rommps
{
class SyncView
{
public:
enum Kind
{
kConflicts,
kActivity,
kTracked,
kGame
};
void open(App &app, Kind kind, int rom_id = 0, const std::string &name = "");
void close() { active_ = false; }
bool active() const { return active_; }
Kind kind() const { return kind_; }
void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback);
void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock) const;
std::vector<hui::ui::Hint> hints() const;
private:
// What Cross does on a row.
enum Do
{
kNothing,
kPickConflict, // opens the choice for conflicts_[arg]
kKeepLocal,
kKeepServer,
kCancel,
kRestoreSave, // arg: RomM's save id
kRestoreState // arg: RomM's state id
};
struct Item
{
Row row;
Do action = kNothing;
int arg = 0;
};
struct Conflict
{
std::string path, game, local, server;
};
void load(App &app);
void show_conflicts();
void add(Row row, Do action = kNothing, int arg = 0) { items_.push_back({std::move(row), action, arg}); }
std::vector<Row> rows() const;
bool active_ = false;
Kind kind_ = kActivity;
int rom_id_ = 0;
std::string name_;
std::string title_, subtitle_;
std::vector<Item> items_;
std::vector<Conflict> conflicts_;
int choosing_ = -1; // the conflict whose choice is showing, or -1
int armed_ = -1; // a restore row waiting for its second press
float armed_age_ = 0.0f, age_ = 0.0f;
int generation_ = 0; // answers for an older open() are dropped
int waiting_ = 0; // requests still out
bool busy_ = false; // a resolve or restore in flight
// A game's view gathers three answers before it shows.
std::string game_here_, game_history_, game_log_;
RowList list_;
};
} // namespace rommps
@@ -0,0 +1,244 @@
/*
* RommPS - its program, made from the PS5 Vulkan Template's UI starter.
*
* One complete design of PS5_VKHomebrewUI's kit (ps5/ui/kit.hpp) fills the
* screen: its procedural backdrop, its scene, its frosted overlay and its post
* overlay, its sounds on the console's audio output, its rumble and its light
* bar colour. There is no switcher: every button is the design's, and holding
* OPTIONS for a second leaves (back to the menu, or out of a title of one
* program). The design grew from the kit's "aurora", in kit/screen.cpp.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "kit.hpp"
// Covers become textures through the kit's renderer (kit/rommps_app.cpp). C
// linkage, so it names the same function from inside this file's namespace.
extern "C" void rommps_set_renderer(void *renderer);
// RommPS's own interface sounds: assets/rommps/sounds/<cue>_NN.wav in the
// title (platform/ps5/app/assets/primary-menu-sounds in the repository;
// focus_01.wav, select_01.wav, back_01.wav, ...: the kit's cue names,
// audio/cues.cpp), 48 kHz 16- or 24-bit PCM. Each cue there replaces the kit's recordings of that cue;
// the cues it leaves out keep them.
std::string soundPackDir(const ps5ui::Kit &kit)
{
const size_t slash = kit.assets.rfind('/');
return (slash == std::string::npos ? std::string(".") : kit.assets.substr(0, slash)) + "/rommps/sounds";
}
bool cueOfFile(const std::string &file, hui::audio::Cue *cue)
{
if (file.size() <= 4 || file.compare(file.size() - 4, 4, ".wav") != 0) {
return false;
}
std::string stem = file.substr(0, file.size() - 4);
const size_t underscore = stem.rfind('_');
if (underscore != std::string::npos && underscore + 1 < stem.size() &&
stem.find_first_not_of("0123456789", underscore + 1) == std::string::npos) {
stem.resize(underscore);
}
for (int i = 0; i < (int)hui::audio::Cue::count; i++) {
if (stem == hui::audio::cue_name((hui::audio::Cue)i)) {
*cue = (hui::audio::Cue)i;
return true;
}
}
return false;
}
// Rebuilds the kit's sound bank with the pack's cues in place of the built-in ones.
void loadSoundPack(ps5ui::Kit &kit)
{
using namespace hui::audio;
const std::string packDir = soundPackDir(kit);
std::vector<std::string> files = hui::save::list_files(packDir);
if (files.empty()) {
return;
}
std::sort(files.begin(), files.end());
SoundBank bank;
bool replaced[(int)Cue::count] = {};
int loaded = 0, rejected = 0;
for (const std::string &name : files) {
Cue cue;
std::string data;
if (!cueOfFile(name, &cue) || !hui::save::read_file(packDir + "/" + name, &data, 16u << 20)) {
rejected++;
continue;
}
DecodedWav wav = decode_wav(data);
if (!wav.ok()) {
say("sound pack: %s: %s", name.c_str(), wav.error.c_str());
rejected++;
continue;
}
// Both sets, so a cue sounds the same whichever set asks for it
for (int set = 0; set < (int)SoundSet::count; set++) {
bank.add((SoundSet)set, cue, wav.samples, wav.frames);
}
replaced[(int)cue] = true;
loaded++;
}
if (loaded == 0) {
say("sound pack: nothing usable in %s (%d rejected)", packDir.c_str(), rejected);
return;
}
// The built-in recordings of the cues the pack leaves out
for (int set = 0; set < (int)SoundSet::count; set++) {
const std::string dir = kit.assets + "/audio/sfx/" + sound_set_name((SoundSet)set);
for (const std::string &name : hui::save::list_files(dir)) {
Cue cue;
std::string data;
if (!cueOfFile(name, &cue) || replaced[(int)cue] || !hui::save::read_file(dir + "/" + name, &data, 16u << 20)) {
continue;
}
DecodedWav wav = decode_wav(data);
if (wav.ok()) {
bank.add((SoundSet)set, cue, std::move(wav.samples), wav.frames);
}
}
}
kit.sounds = std::move(bank);
say("sound pack: %d sounds from %s (%d rejected)", loaded, packDir.c_str(), rejected);
}
// The design on screen: this title's own, in kit/screen.cpp
std::unique_ptr<hui::app::Concept> makeScreen(hui::app::Context &context)
{
return ps5ui::make_screen(context);
}
class VulkanExample : public ps5ui::KitExample
{
public:
// What a design receives: the fonts, the sample content, the live numbers
// it may show and the settings it may change
hui::Settings choices;
hui::app::Telemetry telemetry;
std::unique_ptr<hui::app::Context> context;
std::unique_ptr<hui::app::Concept> screen;
hui::app::Frame frame;
hui::ui::Feedback feedback;
ps5ui::HoldToLeave leaving;
hui::gfx::DrawList leavingList;
double fpsSeconds{ 0.0 };
int fpsFrames{ 0 };
VulkanExample() : KitExample()
{
title = "RommPS";
name = "rommps";
// The kit draws the whole screen and owns the pad
settings.overlay = false;
ps5.ownOverlay = true;
defaultClearColor = { { 0.0f, 0.0f, 0.0f, 1.0f } };
}
void prepare() override
{
VulkanExampleBase::prepare();
// The kit's sounds, without its music behind them
prepareKit({ .music = false });
// Before the first sound plays: the bank is replaced whole
loadSoundPack(kit);
rommps_set_renderer(static_cast<hui::gfx::Renderer *>(&kit.renderer));
context = std::make_unique<hui::app::Context>(hui::app::Context{ kit.fonts, kit.catalog, telemetry, choices });
screen = makeScreen(*context);
screen->enter();
kit.apply(choices);
feedback.play(hui::audio::Cue::welcome);
prepared = true;
}
// Holding OPTIONS for a second leaves; a press is the design's
void holdOptions(float dt)
{
leaving.label = ps5.optionsEnds ? "Back to the menu" : "Close";
if (!ps5.frameBudget && leaving.update(dt, (ps5_pad().held & PAD_OPTIONS) != 0)) {
quit = true;
}
}
void updateTelemetry()
{
if (benchmark.active) {
// A test run's pictures must not depend on the console's timing
telemetry.push(16.7f);
telemetry.fps = 59.9f;
telemetry.average_ms = 16.7f;
} else {
telemetry.push(frameTimer * 1000.0f);
fpsSeconds += frameTimer;
fpsFrames++;
if (fpsSeconds >= 0.5) {
telemetry.fps = (float)(fpsFrames / fpsSeconds);
telemetry.average_ms = (float)(fpsSeconds * 1000.0 / fpsFrames);
fpsSeconds = 0.0;
fpsFrames = 0;
}
telemetry.voices = kit.mixer.active_voices();
}
telemetry.draw_calls = kit.renderer.last_draw_calls();
telemetry.instances = kit.renderer.last_instances();
}
void render() override
{
if (!prepared) {
return;
}
prepareFrame();
const float dt = std::min(frameTimer, 0.05f);
// A test run ignores the pad: the design shows its entrance and rests,
// or takes the presses its test-run.txt gives ("press rommps <frame> r1")
hui::InputFrame input = kit.input();
if (ps5.frameBudget) {
input = kit.scripted("rommps", ps5.framesDrawn);
}
holdOptions(dt);
updateTelemetry();
// CIRCLE is the screen's (back, in the setup and the sheets): RommPS closes
// with the PS button, or by holding OPTIONS
screen->update(input, dt, feedback);
if (context->settings_changed) {
context->settings_changed = false;
kit.apply(choices);
}
kit.play(feedback, screen->info().sounds, choices.haptics);
feedback.clear();
if (choices.light_bar) {
kit.light_bar(screen->info().accent);
}
kit.tick(dt);
// backdrop, scene, [the glass copy], overlay, post: the order a design is drawn in
frame.reset();
frame.glass_texture = kit.renderer.glass_texture();
screen->draw(frame);
kit.renderer.begin();
kit.renderer.backdrop(frame.backdrop);
kit.renderer.draw(frame.scene);
if (frame.glass) {
kit.renderer.glass();
}
kit.renderer.draw(frame.overlay);
kit.renderer.backdrop(frame.post);
leavingList.clear();
leaving.draw(leavingList, kit.fonts, ps5ui::active_theme());
kit.renderer.draw(leavingList);
buildKitCommandBuffer();
submitFrame();
}
~VulkanExample() override
{
if (device) {
vkDeviceWaitIdle(device);
}
}
};
VULKAN_EXAMPLE_MAIN()
+34
View File
@@ -0,0 +1,34 @@
clone_folder: c:\dev\glm-cmake
os:
- Visual Studio 2013
platform:
- x86
- x86_64
build_script:
- md build_pure_11
- cd build_pure_11
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_LANG_EXTENSIONS=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build . --config Debug
- cmake --build . --config Release
- cd ..
- md build_simd_11
- cd build_simd_11
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_LANG_EXTENSIONS=ON ..
- cmake --build . --config Debug
- cmake --build . --config Release
- cd ..
- md build_pure_98
- cd build_pure_98
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build . --config Debug
- cmake --build . --config Release
- cd ..
- md build_simd_98
- cd build_simd_98
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON ..
- cmake --build . --config Debug
- cmake --build . --config Release
- cd ..
@@ -0,0 +1,56 @@
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
# Fortran module files
*.mod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# CMake
CMakeCache.txt
CMakeFiles
cmake_install.cmake
install_manifest.txt
*.cmake
# ^ May need to add future .cmake files as exceptions
# Test logs
Testing/*
# Test input
test/gtc/*.dds
# Project Files
Makefile
*.cbp
*.user
# Misc.
*.log
# local build(s)
build*
/.vs
/CMakeSettings.json
+84
View File
@@ -0,0 +1,84 @@
language: cpp
os:
- linux
- osx
matrix:
include:
- compiler: gcc
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- g++-4.9
env: COMPILER=g++-4.9
- compiler: clang
addons:
apt:
sources:
- ubuntu-toolchain-r-test
- llvm-toolchain-precise-3.8
packages:
- clang-3.8
env: COMPILER=clang++-3.8
compiler:
- clang
before_script:
script:
- mkdir ./build_pure_11
- cd ./build_pure_11
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build .
- ctest
- cd ..
- mkdir ./build_pure_98
- cd ./build_pure_98
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build .
- ctest
- cd ..
- mkdir ./build_pure_11_debug
- cd ./build_pure_11_debug
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build .
- ctest
- cd ..
- mkdir ./build_pure_98_debug
- cd ./build_pure_98_debug
- cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_FORCE_PURE=ON ..
- cmake --build .
- ctest
- cd ..
- mkdir ./build_simd_11
- cd ./build_simd_11
- if [[ $TRAVIS_OS_NAME == 'osx' ]]; then cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_ENABLE_SIMD_SSE3=ON ..; else cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_ENABLE_SIMD_AVX=ON ..; fi
- cmake --build .
- ctest
- cd ..
- mkdir ./build_simd_98
- cd ./build_simd_98
- if [[ $TRAVIS_OS_NAME == 'osx' ]]; then cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_ENABLE_SIMD_SSE3=ON ..; else cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Release -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_ENABLE_SIMD_AVX=ON ..; fi
- cmake --build .
- ctest
- cd ..
- mkdir ./build_simd_11_debug
- cd ./build_simd_11_debug
- if [[ $TRAVIS_OS_NAME == 'osx' ]]; then cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_ENABLE_SIMD_SSE3=ON ..; else cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_11=ON -DGLM_TEST_ENABLE_SIMD_AVX=ON ..; fi
- cmake --build .
- ctest
- cd ..
- mkdir ./build_simd_98_debug
- cd ./build_simd_98_debug
- if [[ $TRAVIS_OS_NAME == 'osx' ]]; then cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_ENABLE_SIMD_SSE3=ON ..; else cmake -DCMAKE_CXX_COMPILER=$COMPILER -DCMAKE_BUILD_TYPE=Debug -DGLM_TEST_ENABLE=ON -DGLM_TEST_ENABLE_CXX_98=ON -DGLM_TEST_ENABLE_SIMD_AVX=ON ..; fi
- cmake --build .
- ctest
- cd ..
Loaded 100 of 721 files, more files were not shown because too many files have changed in this diff. Show more