mirror of
https://github.com/kamb205/KB-RPCS3.git
synced 2026-10-17 23:00:50 +02:00
379 lines
12 KiB
C++
379 lines
12 KiB
C++
/*
|
|
* PS5 Vulkan Template - the UI module: PS5_VKHomebrewUI's kit on the base class.
|
|
*
|
|
* Copyright (C) 2026 Mihawk
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
#include "kit.hpp"
|
|
|
|
#include "platform.h"
|
|
|
|
#include "core/save_file.hpp"
|
|
|
|
#include <span>
|
|
|
|
namespace ps5ui {
|
|
|
|
namespace {
|
|
|
|
// The faces in ui::Fonts order, as the kit bakes them
|
|
const char *const faceFiles[6] = {
|
|
"inter-regular.huifont", "inter-semibold.huifont", "montserrat-medium.huifont",
|
|
"dejavu-sans-mono.huifont", "press-start-2p.huifont", "patrick-hand.huifont",
|
|
};
|
|
|
|
// The audio thread renders the mixer; posting to it never blocks the frame
|
|
void fillFromMixer(int16_t *frames, int count, void *user)
|
|
{
|
|
static_cast<hui::audio::Mixer *>(user)->render(frames, count);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Kit::~Kit()
|
|
{
|
|
release();
|
|
}
|
|
|
|
bool Kit::init(const hui::gfx::VkRendererConfig &config, const std::string &assets, const Options &options)
|
|
{
|
|
this->assets = assets;
|
|
// A button already down as this program starts (the CROSS that chose it on
|
|
// the screen before, still held or still buffered) is not a press for it:
|
|
// the tracker starts from the title's pad state, which pad_poll carries
|
|
// from program to program, as the samples' own input does (example_ps5.cpp)
|
|
hui::PadSample current;
|
|
current.buttons = ps5_pad().held;
|
|
current.connected = true;
|
|
tracker.update(std::span<const hui::PadSample>(¤t, 1), (std::uint64_t)(now_seconds() * 1e6));
|
|
if (!renderer.init(config)) {
|
|
say("ui kit: renderer failed, VkResult %d", (int)renderer.last_error());
|
|
return false;
|
|
}
|
|
ready_ = true;
|
|
hui::ui::FontRef *refs[6] = { &fonts.regular, &fonts.semibold, &fonts.display, &fonts.mono, &fonts.pixel, &fonts.hand };
|
|
for (int i = 0; i < 6; i++) {
|
|
std::string data;
|
|
const std::string path = assets + "/fonts/" + faceFiles[i];
|
|
if (!hui::save::read_file(path, &data) || !faces_[i].load(data)) {
|
|
say("ui kit: font %s: %s", path.c_str(), faces_[i].error().c_str());
|
|
return false;
|
|
}
|
|
refs[i]->font = &faces_[i];
|
|
refs[i]->texture = renderer.create_font_texture(faces_[i]);
|
|
if (refs[i]->texture == 0) {
|
|
say("ui kit: font %s: no texture, VkResult %d", faceFiles[i], (int)renderer.last_error());
|
|
return false;
|
|
}
|
|
}
|
|
if (options.covers && !catalog.build_covers(renderer, fonts)) {
|
|
say("ui kit: cover art failed, VkResult %d", (int)renderer.last_error());
|
|
return false;
|
|
}
|
|
if (options.sound) {
|
|
// The music attaches to the mixer before the audio thread starts
|
|
const int songs = options.music ? music.init(mixer, assets + "/audio/music", options.seed) : 0;
|
|
const auto bank = sounds.load(assets + "/audio/sfx");
|
|
audio = audio_start(fillFromMixer, &mixer);
|
|
say("ui kit: %d songs, %d sounds (%d rejected), output %s", songs, bank.files, bank.rejected,
|
|
audio ? "playing" : "none");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void Kit::release()
|
|
{
|
|
if (audio) {
|
|
audio_stop();
|
|
audio = false;
|
|
}
|
|
if (rumble_left_ > 0.0f) {
|
|
pad_vibrate(0.0f, 0.0f);
|
|
rumble_left_ = 0.0f;
|
|
}
|
|
if (ready_) {
|
|
renderer.release();
|
|
ready_ = false;
|
|
}
|
|
}
|
|
|
|
hui::InputFrame Kit::input()
|
|
{
|
|
const pad_reading *readings = nullptr;
|
|
const int count = pad_readings(&readings);
|
|
samples_.resize(count);
|
|
for (int i = 0; i < count; i++) {
|
|
const pad_reading &r = readings[i];
|
|
samples_[i] = { r.buttons, r.left_x, r.left_y, r.right_x, r.right_y, r.l2, r.r2, r.connected, r.timestamp_us };
|
|
}
|
|
return tracker.update(std::span<const hui::PadSample>(samples_), (std::uint64_t)(now_seconds() * 1e6));
|
|
}
|
|
|
|
hui::InputFrame Kit::scripted(const char *program, uint32_t frame) const
|
|
{
|
|
hui::InputFrame input;
|
|
input.connected = true;
|
|
for (const Ps5Press &press : ps5Presses) {
|
|
if (press.frame != frame || press.program != program) {
|
|
continue;
|
|
}
|
|
if (press.action == "left") {
|
|
input.nav = hui::Direction::left;
|
|
} else if (press.action == "right") {
|
|
input.nav = hui::Direction::right;
|
|
} else if (press.action == "up") {
|
|
input.nav = hui::Direction::up;
|
|
} else if (press.action == "down") {
|
|
input.nav = hui::Direction::down;
|
|
} else if (press.action == "cross") {
|
|
input.pressed |= hui::action_bit(hui::Action::confirm);
|
|
} else if (press.action == "r1") {
|
|
input.pressed |= hui::action_bit(hui::Action::page_next);
|
|
} else if (press.action == "l1") {
|
|
input.pressed |= hui::action_bit(hui::Action::page_prev);
|
|
}
|
|
input.held |= input.pressed;
|
|
}
|
|
return input;
|
|
}
|
|
|
|
void Kit::play(const hui::ui::Feedback &feedback, hui::audio::SoundSet set, bool haptics)
|
|
{
|
|
for (const hui::audio::CueEvent &event : feedback.cues) {
|
|
sounds.play(mixer, event.set == hui::audio::SoundSet::count ? set : event.set, event);
|
|
if (event.cue == hui::audio::Cue::complete || event.cue == hui::audio::Cue::welcome) {
|
|
music.duck();
|
|
}
|
|
}
|
|
if (haptics && feedback.rumble_strength > 0.0f && feedback.rumble_seconds > 0.0f) {
|
|
// The small motor gives the crisp tick a UI wants; the large one adds
|
|
// weight only to strong effects (the kit's own pad does the same)
|
|
const float strength = std::min(feedback.rumble_strength, 1.0f);
|
|
pad_vibrate(strength > 0.6f ? (strength - 0.6f) * 2.5f : 0.0f, strength);
|
|
rumble_left_ = feedback.rumble_seconds;
|
|
}
|
|
}
|
|
|
|
void Kit::tick(float dt)
|
|
{
|
|
if (rumble_left_ > 0.0f) {
|
|
rumble_left_ -= dt;
|
|
if (rumble_left_ <= 0.0f) {
|
|
pad_vibrate(0.0f, 0.0f);
|
|
}
|
|
}
|
|
music.pump(dt);
|
|
}
|
|
|
|
void Kit::apply(const hui::Settings &settings)
|
|
{
|
|
mixer.set_bus_gain(hui::audio::Bus::music, hui::Settings::gain(settings.music_volume));
|
|
mixer.set_bus_gain(hui::audio::Bus::sfx, hui::Settings::gain(settings.sfx_volume));
|
|
mixer.set_bus_gain(hui::audio::Bus::ui, hui::Settings::gain(settings.ui_volume));
|
|
hui::InputSettings input = tracker.settings();
|
|
input.swap_confirm = settings.swap_confirm;
|
|
tracker.set_settings(input);
|
|
}
|
|
|
|
void Kit::light_bar(hui::gfx::Color color)
|
|
{
|
|
const auto channel = [](float value) { return (uint8_t)(std::clamp(value, 0.0f, 1.0f) * 255.0f); };
|
|
const uint8_t r = channel(color.r), g = channel(color.g), b = channel(color.b);
|
|
const uint32_t packed = ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
|
|
if (packed != light_bar_) {
|
|
light_bar_ = packed;
|
|
pad_light_bar(r, g, b);
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
#ifndef PS5_APP_ROOT
|
|
#define PS5_APP_ROOT "/app0"
|
|
#endif
|
|
const char *const themeFolder = PS5_APP_ROOT "/hui";
|
|
const char *const themePath = PS5_APP_ROOT "/hui/theme.txt";
|
|
// Flat and charcoal: the look the start screen was drawn for
|
|
const char *const defaultTheme = "tiles";
|
|
|
|
int themeIndex(const std::string &id)
|
|
{
|
|
const auto all = hui::ui::themes();
|
|
for (size_t i = 0; i < all.size(); i++) {
|
|
if (id == all[i].id) {
|
|
return (int)i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
int loadedTheme = -2; // -2: not read yet
|
|
|
|
} // namespace
|
|
|
|
int active_theme_index()
|
|
{
|
|
if (ps5TestRun) {
|
|
return themeIndex(defaultTheme);
|
|
}
|
|
if (loadedTheme == -2) {
|
|
std::string id;
|
|
hui::save::read_file(themePath, &id, 64);
|
|
while (!id.empty() && (id.back() == '\n' || id.back() == '\r' || id.back() == ' ')) {
|
|
id.pop_back();
|
|
}
|
|
loadedTheme = themeIndex(id);
|
|
if (loadedTheme < 0) {
|
|
loadedTheme = themeIndex(defaultTheme);
|
|
}
|
|
}
|
|
return loadedTheme;
|
|
}
|
|
|
|
const hui::ui::Theme &active_theme()
|
|
{
|
|
const int index = active_theme_index();
|
|
return index >= 0 ? hui::ui::themes()[index] : hui::ui::default_theme();
|
|
}
|
|
|
|
void set_active_theme(int index)
|
|
{
|
|
const auto all = hui::ui::themes();
|
|
if (index < 0 || index >= (int)all.size()) {
|
|
return;
|
|
}
|
|
loadedTheme = index;
|
|
if (ps5TestRun) {
|
|
return; // a test run leaves the title's choice alone
|
|
}
|
|
hui::save::ensure_directory(themeFolder);
|
|
chmod(themeFolder, 0777);
|
|
const std::string error = hui::save::write_atomic(themePath, std::string(all[index].id) + "\n");
|
|
if (error.empty()) {
|
|
chmod(themePath, 0666);
|
|
}
|
|
say("theme: %s%s%s", all[index].id, error.empty() ? "" : ", not saved: ", error.c_str());
|
|
}
|
|
|
|
float draw_hints(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const hui::ui::Theme &theme,
|
|
std::initializer_list<hui::ui::Hint> hints, float x, float cy, int align, float size)
|
|
{
|
|
hui::ui::GlyphStyle style = theme.dark ? hui::ui::GlyphStyle::dark() : hui::ui::GlyphStyle::light();
|
|
style.label = theme.page_text_muted.a > 0.0f ? theme.page_text_muted : theme.text_muted;
|
|
hui::ui::HintLayout layout;
|
|
layout.size = size;
|
|
layout.text_size = size * 0.62f;
|
|
layout.cy = cy;
|
|
layout.item_gap = size * 1.1f;
|
|
const hui::ui::Hint *row = hints.begin();
|
|
const int count = (int)hints.size();
|
|
if (align == 0) {
|
|
x -= hui::ui::measure_hints(fonts, row, count, layout) * 0.5f;
|
|
}
|
|
return hui::ui::draw_hints(list, fonts, style, row, count, x, align > 0, layout);
|
|
}
|
|
|
|
bool HoldToLeave::update(float dt, bool held)
|
|
{
|
|
held_ = held ? held_ + dt : 0.0f;
|
|
return held_ >= seconds;
|
|
}
|
|
|
|
void HoldToLeave::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const hui::ui::Theme &theme) const
|
|
{
|
|
// A press is the program's: the plate shows only once the hold is clearly meant
|
|
const float delay = 0.15f;
|
|
if (held_ < delay) {
|
|
return;
|
|
}
|
|
const float shown = std::min(1.0f, (held_ - delay) / 0.12f);
|
|
const float progress = std::min(1.0f, held_ / seconds);
|
|
const float width = 120.0f + hui::ui::button_width(hui::ui::Button::options, 34.0f) + fonts.semibold.font->measure(label, 26.0f);
|
|
const hui::gfx::Rect plate{ 960.0f - width * 0.5f, 44.0f - (1.0f - shown) * 20.0f, width, 76.0f };
|
|
list.push_opacity(shown);
|
|
list.shadow(plate, 38.0f, 26.0f, theme.shadow.a > 0.0f ? theme.shadow.with_alpha(0.5f) : hui::gfx::Color::rgb(0x000000, 0.4f));
|
|
list.rounded_rect(plate, 38.0f, theme.surface.with_alpha(0.96f));
|
|
const float cx = plate.x + 46.0f, cy = plate.y + plate.h * 0.5f;
|
|
list.ring(cx, cy, 24.0f, 6.0f, theme.surface_high);
|
|
list.arc(cx, cy, 24.0f, 6.0f, 0.0f, 6.2831853f * progress, theme.accent);
|
|
hui::ui::GlyphStyle glyphs = theme.dark ? hui::ui::GlyphStyle::dark() : hui::ui::GlyphStyle::light();
|
|
hui::ui::draw_button(list, fonts, glyphs, hui::ui::Button::options, plate.x + 88.0f, cy, 34.0f);
|
|
hui::ui::text(list, fonts.semibold, label, plate.x + 100.0f + hui::ui::button_width(hui::ui::Button::options, 34.0f),
|
|
cy + 9.0f, 26.0f, theme.text);
|
|
list.pop_opacity();
|
|
}
|
|
|
|
KitExample::~KitExample()
|
|
{
|
|
// Before the base class destroys the device
|
|
kit.release();
|
|
}
|
|
|
|
void KitExample::prepareKit(Kit::Options options)
|
|
{
|
|
hui::gfx::VkRendererConfig config;
|
|
config.physical_device = physicalDevice;
|
|
config.device = device;
|
|
config.queue = queue;
|
|
config.queue_family = vulkanDevice->queueFamilyIndices.graphics;
|
|
config.pipeline_cache = pipelineCache;
|
|
config.frames_in_flight = maxConcurrentFrames;
|
|
if (useDynamicRendering) {
|
|
config.color_format = swapChain.colorFormat;
|
|
config.depth_format = depthFormat;
|
|
config.stencil_format = vks::tools::formatHasStencil(depthFormat) ? depthFormat : VK_FORMAT_UNDEFINED;
|
|
} else {
|
|
config.render_pass = renderPass;
|
|
}
|
|
if (benchmark.active) {
|
|
options.seed = 0;
|
|
} else if (options.seed == 0) {
|
|
options.seed = (std::uint64_t)(now_seconds() * 1e6);
|
|
}
|
|
if (!kit.init(config, getAssetPath() + "hui", options)) {
|
|
vks::tools::exitFatal("The UI kit could not start (klog has why)", -1);
|
|
}
|
|
}
|
|
|
|
void KitExample::buildKitCommandBuffer(const std::function<void(VkCommandBuffer)> &scene,
|
|
const std::function<void(VkCommandBuffer)> &offscreen)
|
|
{
|
|
VkCommandBuffer cmd = drawCmdBuffers[currentBuffer];
|
|
VkCommandBufferBeginInfo beginInfo = vks::initializers::commandBufferBeginInfo();
|
|
VK_CHECK_RESULT(vkBeginCommandBuffer(cmd, &beginInfo));
|
|
// The program's own passes first: the glass copies may replay what they drew
|
|
if (offscreen) {
|
|
offscreen(cmd);
|
|
}
|
|
// Uploads the frame's shapes and makes the glass copies, outside the pass
|
|
kit.renderer.prepare(cmd, currentBuffer);
|
|
if (useDynamicRendering) {
|
|
beginDynamicRendering(cmd);
|
|
} else {
|
|
VkClearValue clearValues[2]{};
|
|
clearValues[0].color = defaultClearColor;
|
|
clearValues[1].depthStencil = { 1.0f, 0 };
|
|
VkRenderPassBeginInfo passInfo = vks::initializers::renderPassBeginInfo();
|
|
passInfo.renderPass = renderPass;
|
|
passInfo.framebuffer = frameBuffers[currentImageIndex];
|
|
passInfo.renderArea.extent = { width, height };
|
|
passInfo.clearValueCount = 2;
|
|
passInfo.pClearValues = clearValues;
|
|
vkCmdBeginRenderPass(cmd, &passInfo, VK_SUBPASS_CONTENTS_INLINE);
|
|
}
|
|
if (scene) {
|
|
scene(cmd);
|
|
}
|
|
kit.renderer.draw(cmd, (int)width, (int)height);
|
|
drawUI(cmd);
|
|
if (useDynamicRendering) {
|
|
endDynamicRendering(cmd);
|
|
} else {
|
|
vkCmdEndRenderPass(cmd);
|
|
}
|
|
VK_CHECK_RESULT(vkEndCommandBuffer(cmd));
|
|
}
|
|
|
|
} // namespace ps5ui
|