Files
ppsspp/Core/ELF/ParamSFO.cpp
T
Henrik RydgårdandClaude Opus 5 90a178aeb8 ParamSFO: make GenerateFakeID independent of char signedness
The fake disc ID homebrew gets when it has no PARAM.SFO is built from the sum of
the bytes of its folder name, summed through a plain char - which is signed on
x86 and unsigned on ARM. So the same homebrew folder produced one ID on Windows
and a different one on Android, quietly splitting its savestates and per-game
config between platforms. Sum through unsigned char, which is what the ARM
builds (Android, iOS, Apple Silicon) already did.

Uppercasing is now explicit and ASCII-only rather than toupper(). Passing a
negative char to toupper() is undefined and trips MSVC's debug CRT assert, so a
folder with a non-ASCII name could stop a debug build dead, and what it did with
bytes above 0x7F otherwise depended on the locale.

ASCII folder names - very nearly all of them - produce exactly the same ID as
before. Non-ASCII ones change on the signed-char platforms, to what the
unsigned-char ones were already generating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
2026-08-19 19:19:06 +02:00

476 lines
16 KiB
C++

// Copyright (c) 2012- 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 <cstdio>
#include <cstring>
#include "Common/CommonTypes.h"
#include "Common/Log.h"
#include "Common/StringUtils.h"
#include "Common/Swap.h"
#include "Common/File/Path.h"
#include "Core/ELF/ParamSFO.h"
#include "Core/System.h"
struct Header
{
u32_le magic; /* Always PSF */
u32_le version; /* Usually 1.1 */
u32_le key_table_start; /* Start position of key_table */
u32_le data_table_start; /* Start position of data_table */
u32_le index_table_entries; /* Number of entries in index_table*/
};
struct IndexTable
{
u16_le key_table_offset; /* Offset of the param_key from start of key_table */
u16_le param_fmt; /* Type of data of param_data in the data_table */
u32_le param_len; /* Used Bytes by param_data in the data_table */
u32_le param_max_len; /* Total bytes reserved for param_data in the data_table */
u32_le data_table_offset; /* Offset of the param_data from start of data_table */
};
void ParamSFOData::SetValue(std::string_view key, unsigned int value, int max_size) {
auto [it, inserted] = values.try_emplace(std::string(key)); // The string construction only happens if inserted is true.
it->second.type = VT_INT;
it->second.i_value = value;
it->second.max_size = max_size;
}
void ParamSFOData::SetValue(std::string_view key, std::string_view value, int max_size) {
auto [it, inserted] = values.try_emplace(std::string(key));
it->second.type = VT_UTF8;
it->second.s_value = value;
it->second.max_size = max_size;
}
void ParamSFOData::SetValue(std::string_view key, const u8 *value, unsigned int size, int max_size) {
auto [it, inserted] = values.try_emplace(std::string(key));
it->second.type = VT_UTF8_SPE;
it->second.SetData(value, size);
it->second.max_size = max_size;
}
int ParamSFOData::GetValueInt(std::string_view key) const {
std::map<std::string,ValueData>::const_iterator it = values.find(key);
if (it == values.end() || it->second.type != VT_INT)
return 0;
return it->second.i_value;
}
std::string ParamSFOData::GetValueString(std::string_view key) const {
std::map<std::string,ValueData>::const_iterator it = values.find(key);
if (it == values.end() || (it->second.type != VT_UTF8))
return "";
return it->second.s_value;
}
bool ParamSFOData::HasKey(std::string_view key) const {
return values.find(key) != values.end();
}
const u8 *ParamSFOData::GetValueData(std::string_view key, unsigned int *size) const {
std::map<std::string,ValueData>::const_iterator it = values.find(key);
if (it == values.end() || (it->second.type != VT_UTF8_SPE)) {
return 0;
}
if (size) {
*size = (unsigned int)it->second.u_value.size();
}
return it->second.u_value.data();
}
std::vector<std::string> ParamSFOData::GetKeys() const {
std::vector<std::string> result;
result.reserve(values.size());
for (const auto &pair : values) {
result.push_back(pair.first);
}
return result;
}
std::string ParamSFOData::GetDiscID() {
const std::string discID = GetValueString("DISC_ID");
if (discID.empty()) {
std::string fakeID = GenerateFakeID(Path());
WARN_LOG(Log::Loader, "No DiscID found - generating a fake one: '%s' (from %s)", fakeID.c_str(), PSP_CoreParameter().fileToStart.c_str());
ValueData data;
data.type = VT_UTF8;
data.s_value = fakeID;
values["DISC_ID"] = data;
return fakeID;
}
return discID;
}
// I'm so sorry Ced but this is highly endian unsafe :(
bool ParamSFOData::ReadSFO(const u8 *paramsfo, size_t size) {
if (size < sizeof(Header))
return false;
const Header *header = (const Header *)paramsfo;
if (header->magic != 0x46535000)
return false;
if (header->version != 0x00000101)
WARN_LOG(Log::Loader, "Unexpected SFO header version: %08x", header->version);
const IndexTable *indexTables = (const IndexTable *)(paramsfo + sizeof(Header));
// The index table itself must fit entirely within the buffer before we
// can dereference entries; otherwise indexTables[i] reads out of bounds.
if (sizeof(Header) + (size_t)header->index_table_entries * sizeof(IndexTable) > size) {
return false;
}
if (header->key_table_start > size || header->data_table_start > size) {
return false;
}
auto readStringCapped = [paramsfo, size](size_t offset, size_t maxLen) -> std::string {
std::string str;
while (offset < size) {
char c = (char)(paramsfo[offset]);
if (c) {
str.push_back(c);
} else {
break;
}
offset++;
if (maxLen != 0 && str.size() == maxLen)
break;
}
return str;
};
for (u32 i = 0; i < header->index_table_entries; i++)
{
size_t key_offset = header->key_table_start + indexTables[i].key_table_offset;
if (key_offset >= size) {
return false;
}
size_t data_offset = header->data_table_start + indexTables[i].data_table_offset;
if (data_offset >= size) {
return false;
}
std::string key = readStringCapped(key_offset, 0);
if (key.empty())
continue; // Likely ran into a truncated PARAMSFO.
switch (indexTables[i].param_fmt) {
case 0x0404:
{
if (data_offset + 4 > size)
continue;
// Unsigned int
const u32_le *data = (const u32_le *)(paramsfo + data_offset);
SetValue(key, *data, indexTables[i].param_max_len);
VERBOSE_LOG(Log::Loader, "%s %08x", key.c_str(), *data);
}
break;
case 0x0004:
// Special format UTF-8
{
if (data_offset + indexTables[i].param_len > size)
continue;
const u8 *utfdata = (const u8 *)(paramsfo + data_offset);
VERBOSE_LOG(Log::Loader, "%s %s", key.c_str(), utfdata);
SetValue(key, utfdata, indexTables[i].param_len, indexTables[i].param_max_len);
}
break;
case 0x0204:
// Regular UTF-8
{
// TODO: Likely should use param_len here, but there's gotta be a reason we avoided it before.
std::string str = readStringCapped(data_offset, indexTables[i].param_max_len);
VERBOSE_LOG(Log::Loader, "%s %s", key.c_str(), str.c_str());
SetValue(key, str, indexTables[i].param_max_len);
}
break;
default:
break;
}
}
return true;
}
int ParamSFOData::GetDataOffset(const u8 *paramsfo, size_t size, const char *dataName) {
if (size < sizeof(Header))
return -1;
const Header *header = (const Header *)paramsfo;
if (header->magic != 0x46535000)
return -1;
if (header->version != 0x00000101)
WARN_LOG(Log::Loader, "Unexpected SFO header version: %08x", header->version);
// Both the index table and the key/data tables must fit in the buffer.
if (sizeof(Header) + (size_t)header->index_table_entries * sizeof(IndexTable) > size)
return -1;
if (header->key_table_start > size || header->data_table_start > size)
return -1;
const IndexTable *indexTables = (const IndexTable *)(paramsfo + sizeof(Header));
const u8 *key_start = paramsfo + header->key_table_start;
int data_start = header->data_table_start;
for (u32 i = 0; i < header->index_table_entries; i++)
{
size_t key_offset = header->key_table_start + indexTables[i].key_table_offset;
if (key_offset >= size)
continue;
if (data_start + indexTables[i].data_table_offset >= (int)size)
continue;
const char *key = (const char *)(key_start + indexTables[i].key_table_offset);
// Ensure the key string is NUL-terminated within the buffer before strcmp.
if (strnlen(key, size - key_offset) == size - key_offset)
continue;
if (!strcmp(key, dataName))
{
return data_start + indexTables[i].data_table_offset;
}
}
return -1;
}
// How many bytes an entry gets in the data table. Everything written for it has to fit in here -
// the size loop below and the fill loop after it both go through this, so they can't disagree.
static u32 ReservedSize(const ParamSFOData::ValueData &value) {
return (u32)std::max(0, value.max_size);
}
void ParamSFOData::WriteSFO(u8 **paramsfo, size_t *size) const {
size_t total_size = 0;
size_t key_size = 0;
size_t data_size = 0;
Header header;
header.magic = 0x46535000;
header.version = 0x00000101;
header.index_table_entries = 0;
total_size += sizeof(Header);
// Get size info
for (const auto &[k, v] : values)
{
key_size += k.size() + 1;
data_size += ReservedSize(v);
header.index_table_entries++;
}
// Padding
while ((key_size % 4) != 0) key_size++;
header.key_table_start = sizeof(Header) + header.index_table_entries * sizeof(IndexTable);
header.data_table_start = header.key_table_start + (u32)key_size;
total_size += sizeof(IndexTable) * header.index_table_entries;
total_size += key_size;
total_size += data_size;
*size = total_size;
size_t aligned_size = (total_size + 15) & ~15;
u8* data = new u8[aligned_size];
*paramsfo = data;
memset(data, 0, aligned_size);
memcpy(data, &header, sizeof(Header));
// Now fill
IndexTable *index_ptr = (IndexTable*)(data + sizeof(Header));
u8* key_ptr = data + header.key_table_start;
u8* data_ptr = data + header.data_table_start;
for (const auto &[k, v] : values)
{
u16 offset = (u16)(key_ptr - (data+header.key_table_start));
index_ptr->key_table_offset = offset;
offset = (u16)(data_ptr - (data+header.data_table_start));
index_ptr->data_table_offset = offset;
const u32 reserved = ReservedSize(v);
index_ptr->param_max_len = reserved;
if (v.type == VT_INT)
{
index_ptr->param_fmt = 0x0404;
index_ptr->param_len = std::min(4u, reserved);
if (reserved >= 4)
*(s32_le *)data_ptr = v.i_value;
else
WARN_LOG(Log::Loader, "SFO key '%s' is an int but only reserves %d bytes, dropping", k.c_str(), (int)reserved);
}
else if (v.type == VT_UTF8_SPE)
{
index_ptr->param_fmt = 0x0004;
// Raw data, no terminator, but it still has to fit in what the entry reserved.
const u32 len = std::min((u32)v.u_value.size(), reserved);
if (len != v.u_value.size())
WARN_LOG(Log::Loader, "SFO key '%s': %d bytes of data truncated to %d", k.c_str(), (int)v.u_value.size(), (int)len);
index_ptr->param_len = len;
memset(data_ptr, 0, reserved);
memcpy(data_ptr, v.u_value.data(), len);
}
else if (v.type == VT_UTF8)
{
index_ptr->param_fmt = 0x0204;
// param_len counts the NUL terminator, so the string itself gets reserved - 1 bytes.
// Several callers pass the string's own length as max_size (see PSPLoaders.cpp), which
// used to overrun the entry by the terminator plus one more from the stray write below.
const u32 len = std::min((u32)v.s_value.size(), reserved ? reserved - 1 : 0);
if (len != v.s_value.size())
WARN_LOG(Log::Loader, "SFO key '%s': string of %d chars truncated to %d", k.c_str(), (int)v.s_value.size(), (int)len);
index_ptr->param_len = reserved ? len + 1 : 0;
memset(data_ptr, 0, reserved); // Also supplies the terminator.
memcpy(data_ptr, v.s_value.data(), len);
}
memcpy(key_ptr,k.c_str(),k.size());
key_ptr[k.size()] = 0;
data_ptr += reserved;
key_ptr += k.size() + 1;
index_ptr++;
}
}
void ParamSFOData::Clear() {
values.clear();
}
std::string ParamSFOData::GenerateFakeID(const Path &filename) const {
// Generates fake gameID for homebrew based on it's folder name.
// Should probably not be a part of ParamSFO, but it'll be called in same places.
// FileToStart here is actually a directory name, not a file, so taking GetFilename on it gets what we want.
Path path = PSP_CoreParameter().fileToStart;
if (!filename.empty())
path = filename;
std::string file = path.GetFilename();
// Deliberately byte-wise and ASCII-only. Filenames are UTF-8, and a plain char is signed on x86
// and unsigned on ARM - so summing chars directly gave Windows and Android different IDs for the
// same non-ASCII folder name, and toupper() on a negative value trips MSVC's debug CRT. ASCII
// names, which is very nearly all of them, produce exactly the same ID as before either way.
int sumOfAllLetters = 0;
for (char &c : file) {
sumOfAllLetters += (unsigned char)c;
// Get rid of some garbage characters than can arise when opening content URIs. Well, I've only seen '%', but...
if (c && strchr("%() []", c) != nullptr) {
c = 'X';
} else if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
}
if (file.size() < 4) {
file += "HOME";
}
file = file.substr(0, 4);
std::string fakeID = file + StringFromFormat("%05d", sumOfAllLetters);
return fakeID;
}
GameRegion DetectGameRegionFromID(std::string_view id_full) {
if (id_full == "MSTKUPDATE") {
return GameRegion::FIRMWARE;
}
// DISC_ID format consists of a 4-letter categorization followed by a 5-digit catalog number.
if (id_full.size() == 9 || (id_full.size() == 10 && id_full[4] == '-')) {
std::string_view id_letters = id_full.substr(0, 4);
std::string_view id_release_type = id_letters.substr(0, 2);
// Determine the type of release from the first two letters, must be one of the following:
// "UC" -> (U)MD, (C)opyrighted (first-party)
// "UL" -> (U)MD, (L)icensed (third-party)
// "NP" -> PlayStation (N)etwork, (P)roduction environment (digital download)
if (id_release_type == "UL" || id_release_type == "UC" || id_release_type == "NP") {
// Determine the region from the third letter.
// This isn't super accurate but it's all we have.
switch (id_letters[2]) {
case 'E': return GameRegion::EUROPE; break;
case 'U': return GameRegion::USA; break;
case 'J': return GameRegion::JAPAN; break;
case 'K': return GameRegion::KOREA; break;
case 'A': return GameRegion::ASIA; break;
default:
if (id_letters.substr(0, 3) == "NPH") {
return GameRegion::HONGKONG; // All games in this region are PSN.
} else if (id_letters == "NPIA") {
return GameRegion::INTERNAL;
} else {
return GameRegion::HOMEBREW;
}
}
/* The fourth letter could be used to determine the type of product. It isn't useful to us.
* UMD:
* 'S' -> full (S)oftware? (used by most games)
* 'M' -> (M)edia? (used by some Japanese and Korean games)
* 'B' -> (B)undled
* 'D' -> (D)emo
* 'P' -> (P)re-production
* 'T' -> (T)est
* 'X' -> e(X)perimental?
* Digital:
* 'A' -> first-party application
* 'B' -> third-party PSP Remasters
* 'E' -> first-party PAL PSOne
* 'F' -> third-party PAL PSOne / American PC Engine (TurboGrafx-16 Classics)
* 'G' -> first-party PSP / PlayView
* 'H' -> third-party PSP / PlayView / Neo Geo
* 'I' -> first-party NTSC PSOne
* 'J' -> third-party NTSC PSOne / Japanese PC Engine
* 'W' -> first-party tool?
* 'X' -> first-party Minis
* 'Z' -> third-party Minis
*/
// Misc patterns
} else if (id_letters == "UTST") {
return GameRegion::TEST;
} else if (id_letters == "UMDT") {
return GameRegion::DIAGNOSTIC;
}
}
return GameRegion::HOMEBREW;
}
std::string_view GameRegionToString(GameRegion region) {
switch (region) {
case GameRegion::JAPAN: return "Japan";
case GameRegion::USA: return "USA";
case GameRegion::EUROPE: return "Europe";
case GameRegion::HONGKONG: return "Hong Kong";
case GameRegion::ASIA: return "Asia";
case GameRegion::KOREA: return "Korea";
case GameRegion::HOMEBREW: return "Homebrew";
case GameRegion::INTERNAL: return "Internal";
case GameRegion::TEST: return "Test disc";
case GameRegion::DIAGNOSTIC: return "Diagnostic tool";
case GameRegion::FIRMWARE: return "Firmware update";
default: return "unknown region";
}
}