mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-31 19:47:06 +01:00
873 lines
30 KiB
C++
873 lines
30 KiB
C++
// Copyright (c) 2026- PPSSPP Project.
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, version 2.0 or later versions.
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License 2.0 for more details.
|
|
|
|
// A copy of the GPL 2.0 should have been included with the program.
|
|
// If not, see http://www.gnu.org/licenses/
|
|
|
|
// Official git repository and contact information can be found at
|
|
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
|
|
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "Common/Data/Text/I18n.h"
|
|
#include "Common/File/FileUtil.h"
|
|
#include "Common/GPU/thin3d.h"
|
|
#include "Common/Input/InputState.h"
|
|
#include "Common/Log.h"
|
|
#include "Common/Render/DrawBuffer.h"
|
|
#include "Common/System/System.h"
|
|
#include "Common/TimeUtil.h"
|
|
#include "Common/UI/Context.h"
|
|
#include "Common/UI/View.h"
|
|
#include "Core/Config.h"
|
|
#include "Core/ConfigValues.h"
|
|
#include "Core/Core.h"
|
|
#include "Core/System.h"
|
|
#include "Core/HLE/PopsBoot.h"
|
|
#include "Core/HLE/sceCtrl.h"
|
|
#include "Core/HLE/sceKernelModule.h"
|
|
#include "Core/HLE/sceKernelThread.h"
|
|
#include "Core/HLE/sceRtc.h"
|
|
#include "Core/HLE/sceUtility.h"
|
|
#include "UI/ImposeHome.h"
|
|
|
|
// The artwork of the PSP's own HOME screen ("Do you want to quit the game?"), from the installed
|
|
// firmware's flash0:/kd/resource/impose.rsc. impose.prx has no font, so every string is a
|
|
// prerendered bitmap, one set per system language, each with its place on the 480x272 screen.
|
|
//
|
|
// impose.rsc, as of 6.61 (all little-endian u32 unless noted):
|
|
// 0x00 palette count (3), language block count (12), shared image count (21)
|
|
// 0x0C offsets from the start of the file: the palettes, then the language blocks (in PSP
|
|
// system language order), then the shared images
|
|
// palette: 16 u16 colors, ABGR1555. 0 is normal, 1 is the highlighted button, 2 a dimmer
|
|
// highlight.
|
|
// language block: image count (10), the circle button icon's x, y, the cross button
|
|
// icon's x, y, padding to 0x44, then per image: palette, byte size, x, y, width, height,
|
|
// offset of its 4bpp pixels (low nibble first) from 0x44 into the block. The images are
|
|
// the question, "Enter", "Back", "Please wait...", then Yes and No, each in palette 0, 1
|
|
// and 2.
|
|
// shared image: byte size, then ABGR1555 pixels, no dimensions (impose.prx knows them).
|
|
// The ones used here: 0 the battery (28x17, four levels stacked, full first), 2 and 3 the
|
|
// circle and cross button icons (16x16), 8 the line above and below the question (328x9),
|
|
// 16 a volume step, set and unset (8x24 each), 18 the speaker (32x28, then muted and
|
|
// dimmed versions), 10 the digits (16x15 each, stacked), 12 "/" and
|
|
// ":" (12x15 each), 14 "AM" and "PM" (32x16 each). 11, 13 and 15 are the same shifted by
|
|
// a pixel, since impose.prx copies 32 bits at a time and needs them for odd x. The rest are
|
|
// for impose's other overlays (volume, brightness, mute, hold).
|
|
//
|
|
// The status bar at the top, the lines and the volume bar aren't placed by impose.rsc: impose.prx
|
|
// draws them with these images, at the positions DrawStatusBar and DrawFrame use (read from
|
|
// impose_02g.prx, 6.61). The same goes for the button guide: the cross icon is always on the left
|
|
// and the circle on the right, and "Enter" and "Back" go next to whichever confirms and cancels.
|
|
class ImposeArt {
|
|
public:
|
|
enum class Item {
|
|
QUESTION,
|
|
ENTER,
|
|
BACK,
|
|
PLEASE_WAIT,
|
|
YES,
|
|
YES_SELECTED,
|
|
YES_DIM,
|
|
NO,
|
|
NO_SELECTED,
|
|
NO_DIM,
|
|
CIRCLE_ICON,
|
|
CROSS_ICON,
|
|
BATTERY_FULL, // Then 2 bars, 1 bar, empty.
|
|
DIGIT_0 = BATTERY_FULL + 4,
|
|
SLASH = DIGIT_0 + 10,
|
|
COLON,
|
|
AM,
|
|
PM,
|
|
LINE,
|
|
VOLUME_SET,
|
|
VOLUME_UNSET,
|
|
SPEAKER,
|
|
COUNT,
|
|
};
|
|
|
|
~ImposeArt();
|
|
|
|
// Reads impose.rsc from the installed firmware (g_Config.nandRootDirectory) for this PSP
|
|
// system language. Only touches the file on the first call or when the language changes.
|
|
// False if there's no firmware or the file isn't the expected format.
|
|
bool Load(int pspLanguage);
|
|
// Where an item goes on the PSP screen (480x272), mapped onto screen.
|
|
Bounds ItemBounds(Item item, const Bounds &screen) const;
|
|
// The button guide: where the circle or cross icon goes, and where "Enter" or "Back" goes
|
|
// when its button is the circle or the cross.
|
|
Bounds IconBounds(bool circle, const Bounds &screen) const;
|
|
Bounds LabelBounds(Item label, bool circle, const Bounds &screen) const;
|
|
void Draw(UIContext &dc, Item item, const Bounds &where);
|
|
|
|
// The date and time at the top right, as impose.prx draws them, with the battery to their
|
|
// right (batteryPercent < 0: none, as on a PSP running off the adapter with its battery out). dayFirst is the DD/MM/YYYY date format, twelveHour the 12-hour clock.
|
|
void DrawStatusBar(UIContext &dc, const Bounds &screen, int month, int day, int hour, int minute, bool dayFirst, bool twelveHour, int batteryPercent);
|
|
// The lines above and below the question, and the volume bar (volume 0-30) when volume >= 0.
|
|
void DrawFrame(UIContext &dc, const Bounds &screen, int volume);
|
|
// The same text, for drawing it without the firmware's glyphs: "12/31 9:05" or "31/12 9:05 PM".
|
|
static std::string ClockText(int month, int day, int hour, int minute, bool dayFirst, bool twelveHour);
|
|
// Which battery level impose.prx shows for a charge (0 full .. 3 empty).
|
|
static int BatteryLevel(int percent);
|
|
|
|
void DeviceLost();
|
|
|
|
// The color behind everything on the screen, which the text bitmaps are drawn on (ABGR8888).
|
|
static constexpr uint32_t BACKGROUND = 0xFF393939;
|
|
|
|
private:
|
|
struct Sprite {
|
|
int atlasY = 0;
|
|
int w = 0;
|
|
int h = 0;
|
|
int x = 0; // On the PSP screen.
|
|
int y = 0;
|
|
};
|
|
|
|
bool Parse(const std::vector<uint8_t> &file, int pspLanguage);
|
|
Draw::Texture *GetTexture(Draw::DrawContext *draw);
|
|
|
|
bool tried_ = false;
|
|
bool loaded_ = false;
|
|
int language_ = -1;
|
|
Sprite sprites_[(int)Item::COUNT]{};
|
|
int circleIconX_ = 0, circleIconY_ = 0;
|
|
int crossIconX_ = 0, crossIconY_ = 0;
|
|
// All the sprites, stacked vertically, RGBA8888.
|
|
std::vector<uint32_t> atlas_;
|
|
int atlasW_ = 0;
|
|
int atlasH_ = 0;
|
|
Draw::Texture *texture_ = nullptr;
|
|
};
|
|
|
|
namespace {
|
|
|
|
constexpr int PSP_W = 480;
|
|
constexpr int PSP_H = 272;
|
|
constexpr uint32_t NUM_PALETTES = 3;
|
|
constexpr uint32_t NUM_LANGUAGES = 12;
|
|
constexpr uint32_t NUM_TEXT_IMAGES = 10;
|
|
constexpr uint32_t TEXT_RECORDS = 0x44;
|
|
constexpr int BATTERY_SHARED_IMAGE = 0;
|
|
constexpr int CIRCLE_SHARED_IMAGE = 2;
|
|
constexpr int CROSS_SHARED_IMAGE = 3;
|
|
constexpr int LINE_SHARED_IMAGE = 8;
|
|
constexpr int DIGITS_SHARED_IMAGE = 10;
|
|
constexpr int VOLUME_SHARED_IMAGE = 16;
|
|
constexpr int SPEAKER_SHARED_IMAGE = 18;
|
|
constexpr int SEPARATORS_SHARED_IMAGE = 12;
|
|
constexpr int AMPM_SHARED_IMAGE = 14;
|
|
constexpr int ICON_SIZE = 16;
|
|
|
|
uint32_t ABGR1555ToRGBA(uint16_t c) {
|
|
uint32_t r = ((c >> 0) & 31) * 255 / 31;
|
|
uint32_t g = ((c >> 5) & 31) * 255 / 31;
|
|
uint32_t b = ((c >> 10) & 31) * 255 / 31;
|
|
uint32_t a = (c & 0x8000) ? 255 : 0;
|
|
return r | (g << 8) | (b << 16) | (a << 24);
|
|
}
|
|
|
|
// Bounds-checked reads from the file.
|
|
struct Reader {
|
|
const std::vector<uint8_t> &data;
|
|
bool ok = true;
|
|
|
|
uint32_t U32(size_t offset) {
|
|
if (offset + 4 > data.size()) {
|
|
ok = false;
|
|
return 0;
|
|
}
|
|
uint32_t v;
|
|
memcpy(&v, &data[offset], 4);
|
|
return v;
|
|
}
|
|
uint16_t U16(size_t offset) {
|
|
if (offset + 2 > data.size()) {
|
|
ok = false;
|
|
return 0;
|
|
}
|
|
return (uint16_t)(data[offset] | (data[offset + 1] << 8));
|
|
}
|
|
bool Has(size_t offset, size_t size) {
|
|
if (offset > data.size() || size > data.size() - offset) {
|
|
ok = false;
|
|
}
|
|
return ok;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
ImposeArt::~ImposeArt() {
|
|
DeviceLost();
|
|
}
|
|
|
|
void ImposeArt::DeviceLost() {
|
|
if (texture_) {
|
|
texture_->Release();
|
|
texture_ = nullptr;
|
|
}
|
|
}
|
|
|
|
bool ImposeArt::Load(int pspLanguage) {
|
|
if (pspLanguage < 0 || pspLanguage >= (int)NUM_LANGUAGES) {
|
|
pspLanguage = 1; // English.
|
|
}
|
|
if (tried_ && language_ == pspLanguage) {
|
|
return loaded_;
|
|
}
|
|
tried_ = true;
|
|
language_ = pspLanguage;
|
|
loaded_ = false;
|
|
DeviceLost();
|
|
|
|
if (g_Config.nandRootDirectory.empty()) {
|
|
return false;
|
|
}
|
|
const Path path = g_Config.nandRootDirectory / "flash0/kd/resource/impose.rsc";
|
|
std::string contents;
|
|
if (!File::ReadBinaryFileToString(path, &contents)) {
|
|
INFO_LOG(Log::System, "No %s, drawing the HOME screen ourselves", path.c_str());
|
|
return false;
|
|
}
|
|
std::vector<uint8_t> file(contents.begin(), contents.end());
|
|
loaded_ = Parse(file, pspLanguage);
|
|
if (loaded_) {
|
|
INFO_LOG(Log::System, "HOME screen: using the firmware's artwork from %s", path.c_str());
|
|
} else {
|
|
WARN_LOG(Log::System, "HOME screen: %s isn't in the expected format, drawing it ourselves", path.c_str());
|
|
}
|
|
return loaded_;
|
|
}
|
|
|
|
bool ImposeArt::Parse(const std::vector<uint8_t> &file, int pspLanguage) {
|
|
Reader r{ file };
|
|
const uint32_t numPalettes = r.U32(0);
|
|
const uint32_t numLanguages = r.U32(4);
|
|
const uint32_t numShared = r.U32(8);
|
|
if (!r.ok || numPalettes != NUM_PALETTES || numLanguages < NUM_LANGUAGES || numShared <= SPEAKER_SHARED_IMAGE || numShared > 256) {
|
|
return false;
|
|
}
|
|
auto tableEntry = [&](uint32_t index) { return r.U32(0x0C + 4 * index); };
|
|
|
|
uint32_t palettes[NUM_PALETTES][16];
|
|
for (uint32_t p = 0; p < NUM_PALETTES; p++) {
|
|
const uint32_t offset = tableEntry(p);
|
|
for (int i = 0; i < 16; i++) {
|
|
palettes[p][i] = ABGR1555ToRGBA(r.U16(offset + 2 * i));
|
|
}
|
|
}
|
|
|
|
// Decoded sprites, before they go into the atlas.
|
|
struct Decoded {
|
|
int w = 0, h = 0, x = 0, y = 0;
|
|
std::vector<uint32_t> pixels;
|
|
};
|
|
Decoded decoded[(int)Item::COUNT];
|
|
|
|
const uint32_t block = tableEntry(NUM_PALETTES + pspLanguage);
|
|
if (!r.ok || r.U32(block) < NUM_TEXT_IMAGES) {
|
|
return false;
|
|
}
|
|
circleIconX_ = (int)r.U32(block + 4);
|
|
circleIconY_ = (int)r.U32(block + 8);
|
|
crossIconX_ = (int)r.U32(block + 12);
|
|
crossIconY_ = (int)r.U32(block + 16);
|
|
if (circleIconX_ > PSP_W - ICON_SIZE || circleIconY_ > PSP_H - ICON_SIZE || crossIconX_ > PSP_W - ICON_SIZE || crossIconY_ > PSP_H - ICON_SIZE) {
|
|
return false;
|
|
}
|
|
|
|
for (uint32_t i = 0; i < NUM_TEXT_IMAGES; i++) {
|
|
const uint32_t rec = block + TEXT_RECORDS + 28 * i;
|
|
const uint32_t palette = r.U32(rec + 0);
|
|
const uint32_t size = r.U32(rec + 4);
|
|
const uint32_t x = r.U32(rec + 8);
|
|
const uint32_t y = r.U32(rec + 12);
|
|
const uint32_t w = r.U32(rec + 16);
|
|
const uint32_t h = r.U32(rec + 20);
|
|
const uint32_t offset = r.U32(rec + 24);
|
|
if (!r.ok || palette >= NUM_PALETTES || w == 0 || h == 0 || x + w > PSP_W || y + h > PSP_H || (w * h + 1) / 2 != size) {
|
|
return false;
|
|
}
|
|
const size_t pixels = block + TEXT_RECORDS + (size_t)offset;
|
|
if (!r.Has(pixels, size)) {
|
|
return false;
|
|
}
|
|
Decoded &d = decoded[i];
|
|
d.w = (int)w;
|
|
d.h = (int)h;
|
|
d.x = (int)x;
|
|
d.y = (int)y;
|
|
d.pixels.resize(w * h);
|
|
for (uint32_t p = 0; p < w * h; p++) {
|
|
const uint8_t byte = file[pixels + p / 2];
|
|
d.pixels[p] = palettes[palette][(p & 1) ? (byte >> 4) : (byte & 0xF)];
|
|
}
|
|
}
|
|
|
|
// Shared images, cut into the frames they hold (stacked vertically).
|
|
struct SharedImage {
|
|
int index;
|
|
int w, h; // Of one frame.
|
|
int frames;
|
|
Item first;
|
|
};
|
|
static const SharedImage sharedImages[] = {
|
|
{ BATTERY_SHARED_IMAGE, 28, 17, 4, Item::BATTERY_FULL },
|
|
{ CIRCLE_SHARED_IMAGE, ICON_SIZE, ICON_SIZE, 1, Item::CIRCLE_ICON },
|
|
{ CROSS_SHARED_IMAGE, ICON_SIZE, ICON_SIZE, 1, Item::CROSS_ICON },
|
|
{ DIGITS_SHARED_IMAGE, 16, 15, 10, Item::DIGIT_0 },
|
|
{ SEPARATORS_SHARED_IMAGE, 12, 15, 2, Item::SLASH },
|
|
{ AMPM_SHARED_IMAGE, 32, 16, 2, Item::AM },
|
|
{ LINE_SHARED_IMAGE, 328, 9, 1, Item::LINE },
|
|
{ VOLUME_SHARED_IMAGE, 8, 24, 2, Item::VOLUME_SET },
|
|
{ SPEAKER_SHARED_IMAGE, 32, 28, 3, Item::SPEAKER },
|
|
};
|
|
for (const SharedImage &img : sharedImages) {
|
|
if ((uint32_t)img.index >= numShared) {
|
|
return false;
|
|
}
|
|
const uint32_t offset = tableEntry(NUM_PALETTES + numLanguages + img.index);
|
|
const uint32_t frameBytes = (uint32_t)(img.w * img.h * 2);
|
|
// The speaker has more frames than we use (only the first).
|
|
const int frames = img.first == Item::SPEAKER ? 1 : img.frames;
|
|
if (!r.ok || r.U32(offset) != frameBytes * img.frames || !r.Has(offset + 4, frameBytes * img.frames)) {
|
|
return false;
|
|
}
|
|
for (int f = 0; f < frames; f++) {
|
|
Decoded &d = decoded[(int)img.first + f];
|
|
d.w = img.w;
|
|
d.h = img.h;
|
|
d.pixels.resize(img.w * img.h);
|
|
const uint32_t frame = offset + 4 + frameBytes * f;
|
|
for (int p = 0; p < img.w * img.h; p++) {
|
|
d.pixels[p] = ABGR1555ToRGBA(r.U16(frame + 2 * p));
|
|
}
|
|
}
|
|
}
|
|
if (!r.ok) {
|
|
return false;
|
|
}
|
|
|
|
// Stack them into one texture, a pixel apart so linear filtering doesn't bleed.
|
|
atlasW_ = 0;
|
|
atlasH_ = 0;
|
|
for (const Decoded &d : decoded) {
|
|
atlasW_ = std::max(atlasW_, d.w);
|
|
atlasH_ += d.h + 1;
|
|
}
|
|
atlas_.assign((size_t)atlasW_ * atlasH_, 0);
|
|
int y = 0;
|
|
for (int i = 0; i < (int)Item::COUNT; i++) {
|
|
const Decoded &d = decoded[i];
|
|
for (int row = 0; row < d.h; row++) {
|
|
memcpy(&atlas_[(size_t)(y + row) * atlasW_], &d.pixels[(size_t)row * d.w], d.w * 4);
|
|
}
|
|
sprites_[i] = Sprite{ y, d.w, d.h, d.x, d.y };
|
|
y += d.h + 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Draw::Texture *ImposeArt::GetTexture(Draw::DrawContext *draw) {
|
|
if (!texture_ && loaded_) {
|
|
Draw::TextureDesc desc{};
|
|
desc.type = Draw::TextureType::LINEAR2D;
|
|
desc.format = Draw::DataFormat::R8G8B8A8_UNORM;
|
|
desc.width = atlasW_;
|
|
desc.height = atlasH_;
|
|
desc.depth = 1;
|
|
desc.mipLevels = 1;
|
|
desc.generateMips = false;
|
|
desc.tag = "impose_home";
|
|
desc.initData.push_back((const uint8_t *)atlas_.data());
|
|
texture_ = draw->CreateTexture(desc);
|
|
}
|
|
return texture_;
|
|
}
|
|
|
|
Bounds ImposeArt::ItemBounds(Item item, const Bounds &screen) const {
|
|
const Sprite &s = sprites_[(int)item];
|
|
const float sx = screen.w / PSP_W;
|
|
const float sy = screen.h / PSP_H;
|
|
return Bounds(screen.x + s.x * sx, screen.y + s.y * sy, s.w * sx, s.h * sy);
|
|
}
|
|
|
|
Bounds ImposeArt::IconBounds(bool circle, const Bounds &screen) const {
|
|
const float sx = screen.w / PSP_W;
|
|
const float sy = screen.h / PSP_H;
|
|
const int x = circle ? circleIconX_ : crossIconX_;
|
|
const int y = circle ? circleIconY_ : crossIconY_;
|
|
return Bounds(screen.x + x * sx, screen.y + y * sy, ICON_SIZE * sx, ICON_SIZE * sy);
|
|
}
|
|
|
|
Bounds ImposeArt::LabelBounds(Item label, bool circle, const Bounds &screen) const {
|
|
const Sprite &s = sprites_[(int)label];
|
|
const float sx = screen.w / PSP_W;
|
|
const float sy = screen.h / PSP_H;
|
|
const int x = (circle ? circleIconX_ : crossIconX_) + ICON_SIZE;
|
|
return Bounds(screen.x + x * sx, screen.y + s.y * sy, s.w * sx, s.h * sy);
|
|
}
|
|
|
|
void ImposeArt::Draw(UIContext &dc, Item item, const Bounds &where) {
|
|
Draw::Texture *texture = GetTexture(dc.GetDrawContext());
|
|
if (!texture) {
|
|
return;
|
|
}
|
|
const Sprite &s = sprites_[(int)item];
|
|
dc.Flush();
|
|
dc.GetDrawContext()->BindTexture(0, texture);
|
|
const float u1 = 0.0f;
|
|
const float u2 = (float)s.w / atlasW_;
|
|
const float v1 = (float)s.atlasY / atlasH_;
|
|
const float v2 = (float)(s.atlasY + s.h) / atlasH_;
|
|
dc.Draw()->DrawTexRect(where, u1, v1, u2, v2, 0xFFFFFFFF);
|
|
dc.Flush();
|
|
dc.RebindTexture();
|
|
}
|
|
|
|
namespace {
|
|
|
|
// The status bar's glyphs, as impose.prx lays them out: what to draw and how far to move on.
|
|
enum class Glyph { DIGIT, SLASH, COLON, SPACE, THIN_SPACE, AM, PM };
|
|
|
|
struct StatusGlyph {
|
|
Glyph glyph;
|
|
int digit;
|
|
};
|
|
|
|
// The firmware builds the string the same way: no leading zero on the month, the day or the hour,
|
|
// a 5 pixel space after the date and at the end, a 1 pixel one before AM/PM.
|
|
std::vector<StatusGlyph> StatusBarGlyphs(int month, int day, int hour, int minute, bool dayFirst, bool twelveHour) {
|
|
std::vector<StatusGlyph> glyphs;
|
|
auto number = [&](int n, bool leadingZero) {
|
|
if (n / 10 != 0 || leadingZero) {
|
|
glyphs.push_back({ Glyph::DIGIT, (n / 10) % 10 });
|
|
}
|
|
glyphs.push_back({ Glyph::DIGIT, n % 10 });
|
|
};
|
|
number(dayFirst ? day : month, false);
|
|
glyphs.push_back({ Glyph::SLASH, 0 });
|
|
number(dayFirst ? month : day, false);
|
|
glyphs.push_back({ Glyph::SPACE, 0 });
|
|
bool pm = false;
|
|
if (twelveHour) {
|
|
pm = hour >= 12;
|
|
hour %= 12;
|
|
if (hour == 0) {
|
|
hour = 12;
|
|
}
|
|
}
|
|
number(hour, false);
|
|
glyphs.push_back({ Glyph::COLON, 0 });
|
|
number(minute, true);
|
|
if (twelveHour) {
|
|
glyphs.push_back({ Glyph::THIN_SPACE, 0 });
|
|
glyphs.push_back({ pm ? Glyph::PM : Glyph::AM, 0 });
|
|
}
|
|
glyphs.push_back({ Glyph::SPACE, 0 });
|
|
return glyphs;
|
|
}
|
|
|
|
int GlyphAdvance(const StatusGlyph &g) {
|
|
switch (g.glyph) {
|
|
case Glyph::DIGIT: return g.digit == 1 ? 10 : (g.digit == 2 ? 11 : 12);
|
|
case Glyph::SLASH: return 7;
|
|
case Glyph::COLON: return 6;
|
|
case Glyph::SPACE: return 5;
|
|
case Glyph::THIN_SPACE: return 1;
|
|
case Glyph::AM:
|
|
case Glyph::PM: return 29;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int ImposeArt::BatteryLevel(int percent) {
|
|
// impose.prx's thresholds.
|
|
if (percent >= 60)
|
|
return 0;
|
|
if (percent >= 30)
|
|
return 1;
|
|
if (percent >= 12)
|
|
return 2;
|
|
return 3;
|
|
}
|
|
|
|
std::string ImposeArt::ClockText(int month, int day, int hour, int minute, bool dayFirst, bool twelveHour) {
|
|
std::string text;
|
|
for (const StatusGlyph &g : StatusBarGlyphs(month, day, hour, minute, dayFirst, twelveHour)) {
|
|
switch (g.glyph) {
|
|
case Glyph::DIGIT: text += (char)('0' + g.digit); break;
|
|
case Glyph::SLASH: text += '/'; break;
|
|
case Glyph::COLON: text += ':'; break;
|
|
case Glyph::SPACE: text += ' '; break;
|
|
case Glyph::THIN_SPACE: break;
|
|
case Glyph::AM: text += " AM"; break;
|
|
case Glyph::PM: text += " PM"; break;
|
|
}
|
|
}
|
|
while (!text.empty() && text.back() == ' ') {
|
|
text.pop_back();
|
|
}
|
|
return text;
|
|
}
|
|
|
|
void ImposeArt::DrawStatusBar(UIContext &dc, const Bounds &screen, int month, int day, int hour, int minute, bool dayFirst, bool twelveHour, int batteryPercent) {
|
|
const float sx = screen.w / PSP_W;
|
|
const float sy = screen.h / PSP_H;
|
|
auto at = [&](Item item, int x, int y) {
|
|
const Sprite &s = sprites_[(int)item];
|
|
Draw(dc, item, Bounds(screen.x + x * sx, screen.y + y * sy, s.w * sx, s.h * sy));
|
|
};
|
|
|
|
// Everything is at y 5. The battery sits at x 450, and the clock ends where it starts, or at
|
|
// the screen's edge without one.
|
|
const int y = 5;
|
|
int right = PSP_W;
|
|
if (batteryPercent >= 0) {
|
|
right = 450;
|
|
at((Item)((int)Item::BATTERY_FULL + BatteryLevel(batteryPercent)), 450, y);
|
|
}
|
|
|
|
const std::vector<StatusGlyph> glyphs = StatusBarGlyphs(month, day, hour, minute, dayFirst, twelveHour);
|
|
int width = 0;
|
|
for (const StatusGlyph &g : glyphs) {
|
|
width += GlyphAdvance(g);
|
|
}
|
|
int x = right - width;
|
|
for (const StatusGlyph &g : glyphs) {
|
|
switch (g.glyph) {
|
|
case Glyph::DIGIT: at((Item)((int)Item::DIGIT_0 + g.digit), x, y); break;
|
|
case Glyph::SLASH: at(Item::SLASH, x, y); break;
|
|
case Glyph::COLON: at(Item::COLON, x, y); break;
|
|
case Glyph::AM: at(Item::AM, x, y); break;
|
|
case Glyph::PM: at(Item::PM, x, y); break;
|
|
default: break;
|
|
}
|
|
x += GlyphAdvance(g);
|
|
}
|
|
}
|
|
|
|
void ImposeArt::DrawFrame(UIContext &dc, const Bounds &screen, int volume) {
|
|
const float sx = screen.w / PSP_W;
|
|
const float sy = screen.h / PSP_H;
|
|
auto at = [&](Item item, int x, int y) {
|
|
const Sprite &s = sprites_[(int)item];
|
|
Draw(dc, item, Bounds(screen.x + x * sx, screen.y + y * sy, s.w * sx, s.h * sy));
|
|
};
|
|
// Where impose.prx leaves them once its opening animation (the lines growing from the
|
|
// middle) is done.
|
|
at(Item::LINE, 80, 96);
|
|
at(Item::LINE, 80, 175);
|
|
if (volume >= 0) {
|
|
at(Item::SPEAKER, 28, 223);
|
|
for (int i = 0; i < 30; i++) {
|
|
at(i < volume ? Item::VOLUME_SET : Item::VOLUME_UNSET, 62 + 12 * i, 223);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The HOME button's quit screen. On a PSP, impose draws it over a running game: the game keeps
|
|
// going but stops getting input, and Yes runs the game's exit callback (or just ends it) and goes
|
|
// back to the XMB, showing "Please wait..." meanwhile. Here it goes back to the XMB when the XMB
|
|
// launched the game, otherwise to our game browser, like Exit to menu. With a firmware dump
|
|
// installed it uses the firmware's own artwork (see ImposeArt), otherwise it's drawn the same
|
|
// way with our font.
|
|
|
|
// Whether HOME brings up the quit screen: in any game, but not in the XMB itself (where HOME does
|
|
// nothing on a PSP), the PS1 emulator (which has its own HOME menu) or a GE dump.
|
|
static bool Available() {
|
|
return !__KernelIsRunningVSH() && !PopsBootActive() && PSP_CoreParameter().fileType != IdentifiedFileType::PPSSPP_GE_DUMP;
|
|
}
|
|
|
|
static uint32_t ConfirmButton() {
|
|
// The system's confirm button, as impose reports it (see __ImposeInit).
|
|
const bool circle = PSP_CoreParameter().compat.flags().ForceCircleButtonConfirm || g_Config.iButtonPreference == PSP_SYSTEMPARAM_BUTTON_CIRCLE;
|
|
return circle ? CTRL_CIRCLE : CTRL_CROSS;
|
|
}
|
|
|
|
// Our game volume (0-100) as the PSP's volume steps (0-30), which the volume bar shows.
|
|
static int VolumeStep() {
|
|
return (std::clamp(g_Config.iGameVolume, 0, VOLUMEHI_FULL) * 30 + VOLUMEHI_FULL / 2) / VOLUMEHI_FULL;
|
|
}
|
|
|
|
void ImposeHomeScreen::Open() {
|
|
INFO_LOG(Log::System, "HOME: showing the quit screen");
|
|
state_ = State::ASKING;
|
|
yes_ = false;
|
|
__CtrlSetGameInputBlocked(true);
|
|
}
|
|
|
|
void ImposeHomeScreen::Close() {
|
|
if (state_ != State::CLOSED) {
|
|
state_ = State::CLOSED;
|
|
__CtrlSetGameInputBlocked(false);
|
|
}
|
|
}
|
|
|
|
void ImposeHomeScreen::Choose(bool quit) {
|
|
if (!quit || coreState != CORE_RUNNING_CPU) {
|
|
Close();
|
|
return;
|
|
}
|
|
// Like a PSP: the game's exit callback first, so it can clean up (and usually calls
|
|
// sceKernelExitGame itself). Without one, the game is just ended.
|
|
//
|
|
// The game still shouldn't get input meanwhile (TODO: though nothing enforces this?)
|
|
// and "Please wait..." stays up until it's gone.
|
|
state_ = State::QUITTING;
|
|
if (!__KernelIsExitCallbackPending() && __KernelInvokeRegisteredExitCallback(__KernelCanExitToVSH())) {
|
|
INFO_LOG(Log::System, "HOME: quitting, running the game's exit callback");
|
|
exitDeadline_ = time_now_d() + 4.0;
|
|
} else {
|
|
FinishQuit();
|
|
}
|
|
}
|
|
|
|
void ImposeHomeScreen::FinishQuit() {
|
|
// Back to the XMB if it launched the game, otherwise to the game browser.
|
|
if (__KernelRequestExitToVSH()) {
|
|
return;
|
|
}
|
|
INFO_LOG(Log::System, "HOME: quitting to the game browser");
|
|
Close();
|
|
pendingAction_ = Action::LEAVE_GAME;
|
|
}
|
|
|
|
ImposeHomeScreen::Action ImposeHomeScreen::Update(bool running, bool onTop) {
|
|
// A quit chosen since the last update.
|
|
if (pendingAction_ != Action::NONE) {
|
|
return TakeAction();
|
|
}
|
|
if (!running) {
|
|
// Paused in the debugger, say, or on the way out: keep showing "Please wait..." while a
|
|
// quit is under way, otherwise let go.
|
|
if (state_ == State::ASKING || !PSP_IsInited()) {
|
|
Close();
|
|
}
|
|
lastButtons_ = 0;
|
|
if (!PSP_IsInited()) {
|
|
exitDeadline_ = -1.0;
|
|
}
|
|
return Action::NONE;
|
|
}
|
|
|
|
// A game whose exit callback never gets round to exiting: quit it anyway.
|
|
if (exitDeadline_ >= 0.0 && time_now_d() > exitDeadline_) {
|
|
exitDeadline_ = -1.0;
|
|
WARN_LOG(Log::System, "HOME: the exit callback didn't end the game, quitting it");
|
|
FinishQuit();
|
|
return TakeAction();
|
|
}
|
|
|
|
const uint32_t buttons = __CtrlPeekButtons();
|
|
const uint32_t pressed = buttons & ~lastButtons_;
|
|
lastButtons_ = buttons;
|
|
|
|
if (!onTop) {
|
|
// The pause menu or such is up. Leave the quit screen as it was.
|
|
return Action::NONE;
|
|
}
|
|
|
|
switch (state_) {
|
|
case State::CLOSED:
|
|
if ((pressed & CTRL_HOME) && exitDeadline_ < 0.0 && Available()) {
|
|
Open();
|
|
}
|
|
break;
|
|
case State::ASKING:
|
|
{
|
|
const uint32_t confirm = ConfirmButton();
|
|
const uint32_t cancel = confirm == CTRL_CIRCLE ? CTRL_CROSS : CTRL_CIRCLE;
|
|
if (pressed & (CTRL_LEFT | CTRL_RIGHT)) {
|
|
yes_ = !yes_;
|
|
}
|
|
// The volume keys move the volume bar a step, which is our game volume.
|
|
if (pressed & (CTRL_VOL_UP | CTRL_VOL_DOWN)) {
|
|
const int step = std::clamp(VolumeStep() + ((pressed & CTRL_VOL_UP) ? 1 : -1), 0, 30);
|
|
g_Config.iGameVolume = (step * VOLUMEHI_FULL + 15) / 30;
|
|
}
|
|
if (pressed & confirm) {
|
|
Choose(yes_);
|
|
} else if (pressed & (cancel | CTRL_HOME)) {
|
|
Choose(false);
|
|
}
|
|
break;
|
|
}
|
|
case State::QUITTING:
|
|
break;
|
|
}
|
|
return TakeAction();
|
|
}
|
|
|
|
ImposeHomeScreen::Action ImposeHomeScreen::TakeAction() {
|
|
Action action = pendingAction_;
|
|
pendingAction_ = Action::NONE;
|
|
return action;
|
|
}
|
|
|
|
void ImposeHomeScreen::Draw(UIContext &dc, const Bounds &screen) {
|
|
using Item = ImposeArt::Item;
|
|
|
|
const float sx = screen.w / 480.0f;
|
|
const float sy = screen.h / 272.0f;
|
|
// A rectangle on the PSP screen (480x272).
|
|
auto PSPRect = [&](float x, float y, float w, float h) { return Bounds(screen.x + x * sx, screen.y + y * sy, w * sx, h * sy); };
|
|
|
|
const bool asking = state_ == State::ASKING;
|
|
const bool circleConfirm = ConfirmButton() == CTRL_CIRCLE;
|
|
|
|
// impose covers the whole screen with this, which its text bitmaps are drawn on.
|
|
dc.Flush();
|
|
dc.RebindTexture();
|
|
dc.FillRect(UI::Drawable(ImposeArt::BACKGROUND), screen);
|
|
|
|
// The status bar: the emulated PSP's date and time, and the battery - this device's, since the
|
|
// PSP's is always full. A device without one (a desktop) shows it full, like what games are told.
|
|
ScePspDateTime now{};
|
|
const bool haveTime = __RtcGetCurrentLocalTime(&now);
|
|
const bool dayFirst = g_Config.iDateFormat == PSP_SYSTEMPARAM_DATE_FORMAT_DDMMYYYY;
|
|
const bool twelveHour = g_Config.iTimeFormat == PSP_SYSTEMPARAM_TIME_FORMAT_12HR;
|
|
int batteryPercent = 100;
|
|
if (System_GetPropertyBool(SYSPROP_CAN_READ_BATTERY_PERCENTAGE)) {
|
|
const int64_t percent = System_GetPropertyInt(SYSPROP_BATTERY_PERCENTAGE);
|
|
if (percent >= 0 && percent <= 100) {
|
|
batteryPercent = (int)percent;
|
|
}
|
|
}
|
|
|
|
// The volume bar shows our game volume, in the PSP's 30 steps.
|
|
const int volume = VolumeStep();
|
|
|
|
if (!art_) {
|
|
art_ = std::make_unique<ImposeArt>();
|
|
}
|
|
if (art_->Load(GetPSPLanguage())) {
|
|
ImposeArt &art = *art_;
|
|
if (haveTime) {
|
|
art.DrawStatusBar(dc, screen, now.month, now.day, now.hour, now.minute, dayFirst, twelveHour, batteryPercent);
|
|
}
|
|
yesBounds_ = art.ItemBounds(Item::YES, screen);
|
|
noBounds_ = art.ItemBounds(Item::NO, screen);
|
|
art.DrawFrame(dc, screen, asking ? volume : -1);
|
|
if (asking) {
|
|
art.Draw(dc, Item::QUESTION, art.ItemBounds(Item::QUESTION, screen));
|
|
art.Draw(dc, yes_ ? Item::YES_SELECTED : Item::YES, yesBounds_);
|
|
art.Draw(dc, yes_ ? Item::NO : Item::NO_SELECTED, noBounds_);
|
|
// Cross on the left, circle on the right, the labels by their buttons.
|
|
art.Draw(dc, Item::CROSS_ICON, art.IconBounds(false, screen));
|
|
art.Draw(dc, Item::CIRCLE_ICON, art.IconBounds(true, screen));
|
|
art.Draw(dc, Item::ENTER, art.LabelBounds(Item::ENTER, circleConfirm, screen));
|
|
art.Draw(dc, Item::BACK, art.LabelBounds(Item::BACK, !circleConfirm, screen));
|
|
} else {
|
|
art.Draw(dc, Item::PLEASE_WAIT, art.ItemBounds(Item::PLEASE_WAIT, screen));
|
|
}
|
|
dc.Flush();
|
|
return;
|
|
}
|
|
|
|
// No firmware: the same screen with our font, at the firmware's positions (English layout).
|
|
auto di = GetI18NCategory(I18NCat::DIALOG);
|
|
const UI::Theme &theme = dc.GetTheme();
|
|
dc.SetFontStyle(theme.uiFont);
|
|
auto FitText = [&](std::string_view text, const Bounds &bounds, uint32_t color, int align) {
|
|
// Scale the font to the bitmap's height, then squeeze if the translation is longer.
|
|
float w, h;
|
|
dc.MeasureText(theme.uiFont, 1.0f, 1.0f, text, &w, &h);
|
|
float scale = h > 0.0f ? bounds.h / h : 1.0f;
|
|
if (w * scale > bounds.w && (align & ALIGN_HCENTER)) {
|
|
scale = std::max(bounds.w / w, scale * 0.5f);
|
|
}
|
|
dc.SetFontScale(scale, scale);
|
|
const float x = (align & ALIGN_HCENTER) ? bounds.centerX() : bounds.x;
|
|
dc.DrawText(text, x, bounds.centerY(), color, (align & ALIGN_HCENTER) ? ALIGN_CENTER : (ALIGN_LEFT | ALIGN_VCENTER));
|
|
};
|
|
|
|
// Status bar: the clock right-aligned at the top, the battery as its level in quarters.
|
|
float clockRight = 480.0f;
|
|
if (batteryPercent >= 0) {
|
|
clockRight = 450.0f;
|
|
const Bounds battery = PSPRect(452, 6, 24, 15);
|
|
const int level = 3 - ImposeArt::BatteryLevel(batteryPercent); // Bars, 0-3.
|
|
dc.FillRect(UI::Drawable(0xFFFFFFFF), Bounds(battery.x, battery.y, battery.w, battery.h));
|
|
dc.FillRect(UI::Drawable(ImposeArt::BACKGROUND), Bounds(battery.x + sx, battery.y + sy, battery.w - 2 * sx, battery.h - 2 * sy));
|
|
for (int i = 0; i < level; i++) {
|
|
dc.FillRect(UI::Drawable(0xFFFFFFFF), PSPRect(455 + 6.5f * i, 9, 5, 9));
|
|
}
|
|
}
|
|
if (haveTime) {
|
|
const std::string clock = ImposeArt::ClockText(now.month, now.day, now.hour, now.minute, dayFirst, twelveHour);
|
|
float w, h;
|
|
dc.MeasureText(theme.uiFont, 1.0f, 1.0f, clock, &w, &h);
|
|
const float scale = h > 0.0f ? 15.0f * sy / h : 1.0f;
|
|
dc.SetFontScale(scale, scale);
|
|
dc.DrawText(clock, screen.x + (clockRight - 6.0f) * sx, screen.y + 12.5f * sy, 0xFFFFFFFF, ALIGN_RIGHT | ALIGN_VCENTER);
|
|
}
|
|
|
|
// The lines above and below the question.
|
|
dc.FillRect(UI::Drawable(0xFF9C9C9C), PSPRect(80, 99, 328, 1.5f));
|
|
dc.FillRect(UI::Drawable(0xFF9C9C9C), PSPRect(80, 178, 328, 1.5f));
|
|
|
|
yesBounds_ = PSPRect(190, 146, 32, 18);
|
|
noBounds_ = PSPRect(258, 146, 32, 18);
|
|
if (asking) {
|
|
// Volume bar: the speaker, then a bar per step set and a dot per step not.
|
|
const Bounds speaker = PSPRect(28, 223, 32, 28);
|
|
dc.Draw()->DrawImage(ImageID(volume > 0 ? "I_SPEAKER" : "I_SPEAKER_OFF"), speaker.centerX(), speaker.y + 12.0f * sy, 22.0f * sy / 32.0f, 0xFFFFFFFF, ALIGN_CENTER);
|
|
for (int i = 0; i < 30; i++) {
|
|
if (i < volume) {
|
|
dc.FillRect(UI::Drawable(0xFFFFFFFF), PSPRect(64.5f + 12 * i, 228, 3, 14));
|
|
} else {
|
|
dc.FillRect(UI::Drawable(0xFFC5C5C5), PSPRect(65 + 12 * i, 239, 2, 2));
|
|
}
|
|
}
|
|
|
|
FitText(di->T("Do you want to quit the game?"), PSPRect(40, 112, 400, 19), 0xFFFFFFFF, ALIGN_HCENTER);
|
|
const Bounds &selected = yes_ ? yesBounds_ : noBounds_;
|
|
dc.FillRect(UI::Drawable(0xFFA4A4A4), selected);
|
|
FitText(di->T("Yes"), yesBounds_, 0xFFFFFFFF, ALIGN_HCENTER);
|
|
FitText(di->T("No"), noBounds_, 0xFFFFFFFF, ALIGN_HCENTER);
|
|
|
|
// Button guide, as the firmware has it: cross on the left, circle on the right, each
|
|
// labelled with what it does.
|
|
const float iconScale = 16.0f * sy / 32.0f; // Atlas button icons are about 32 high.
|
|
const Bounds crossIcon = PSPRect(188, 254, 16, 16);
|
|
const Bounds circleIcon = PSPRect(266, 254, 16, 16);
|
|
dc.Draw()->DrawImage(ImageID("I_CROSS"), crossIcon.centerX(), crossIcon.centerY(), iconScale, 0xFFFFFFFF, ALIGN_CENTER);
|
|
FitText(circleConfirm ? di->T("Back") : di->T("Enter"), PSPRect(204, 254, 60, 14), 0xFFFFFFFF, ALIGN_LEFT);
|
|
dc.Draw()->DrawImage(ImageID("I_CIRCLE"), circleIcon.centerX(), circleIcon.centerY(), iconScale, 0xFFFFFFFF, ALIGN_CENTER);
|
|
FitText(circleConfirm ? di->T("Enter") : di->T("Back"), PSPRect(282, 254, 60, 14), 0xFFFFFFFF, ALIGN_LEFT);
|
|
} else {
|
|
FitText(di->T("Please wait..."), PSPRect(120, 128, 240, 16), 0xFFFFFFFF, ALIGN_HCENTER);
|
|
}
|
|
|
|
dc.SetFontScale(1.0f, 1.0f);
|
|
dc.Flush();
|
|
}
|
|
|
|
ImposeHomeScreen::ImposeHomeScreen() {}
|
|
|
|
ImposeHomeScreen::~ImposeHomeScreen() {
|
|
Close();
|
|
}
|
|
|
|
void ImposeHomeScreen::DeviceLost() {
|
|
if (art_) {
|
|
art_->DeviceLost();
|
|
}
|
|
}
|