From 8bbda5bf2b4ec2cd75706f6ded3974c11beb9e89 Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Sun, 15 Mar 2026 20:07:52 +0000 Subject: [PATCH] Add PARAM.SFO parser for PlayStation 3 game metadata --- tests/CMakeLists.txt | 3 + tests/data/ps3_param.sfo | Bin 0 -> 292 bytes tests/ps3_sfo.cpp | 177 ++++++++++++++++++++++++++++++++++++++ tool/CMakeLists.txt | 2 + tool/sfo.c | 181 +++++++++++++++++++++++++++++++++++++++ tool/sfo.h | 82 ++++++++++++++++++ 6 files changed, 445 insertions(+) create mode 100644 tests/data/ps3_param.sfo create mode 100644 tests/ps3_sfo.cpp create mode 100644 tool/sfo.c create mode 100644 tool/sfo.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e28273..b2120f9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,7 @@ set(TEST_DATA_FILES cdmode2_v1.aif cdmode2.aif BLES-00905.ird + ps3_param.sfo ) # Create data directory in build tree @@ -92,10 +93,12 @@ add_executable(tests_run ps3_crypto.cpp ps3_encryption_map.cpp ps3_ird.cpp + ps3_sfo.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ps3/aes128.c ${CMAKE_CURRENT_SOURCE_DIR}/../src/ps3/ps3_crypto.c ${CMAKE_CURRENT_SOURCE_DIR}/../src/ps3/ps3_encryption_map.c ${CMAKE_CURRENT_SOURCE_DIR}/../tool/ird.c + ${CMAKE_CURRENT_SOURCE_DIR}/../tool/sfo.c ) # Set up include directories using modern target-specific approach diff --git a/tests/data/ps3_param.sfo b/tests/data/ps3_param.sfo new file mode 100644 index 0000000000000000000000000000000000000000..99171b4ce7462fd3e9daf5c02d8c80a7130142c8 GIT binary patch literal 292 zcmZPw2zFy+WMG)Wz`*c?fq{X80R&hW1Q-~=7{p^^U}4~5U|`^Zit#hBFt9<@$}li6 zh$Hb;7#JAj8CV#Y85kH?7#J9I7#J8dkmL;*7#PeMSXe-EAZ)?Fz~F?$w_#vl2=NT@ zafOiao-PcI0Ri!0u0af8u0g?`{(cP3jv=n@{y~up0l~)c!I8lsuD)RTAlG1jpU@DH zl3@Q(KbLqn{~%w-5C(>j)Z!8a_r%;(g^ +#include +#include +#include +#include + +#include "gtest/gtest.h" + +extern "C" +{ +#include "../tool/sfo.h" +} + +class SfoFixture : public ::testing::Test +{ +public: + SfoFixture() = default; + +protected: + uint8_t *sfo_data = nullptr; + uint32_t sfo_data_len = 0; + SfoFile sfo; + + void SetUp() override + { + memset(&sfo, 0, sizeof(sfo)); + + char path[PATH_MAX]; + char cwd[PATH_MAX]; + getcwd(cwd, PATH_MAX); + snprintf(path, PATH_MAX, "%s/data/ps3_param.sfo", cwd); + + FILE *fp = fopen(path, "rb"); + ASSERT_NE(nullptr, fp) << "Cannot open " << path; + + fseek(fp, 0, SEEK_END); + sfo_data_len = (uint32_t)ftell(fp); + rewind(fp); + + sfo_data = static_cast(malloc(sfo_data_len)); + ASSERT_NE(nullptr, sfo_data); + ASSERT_EQ(sfo_data_len, fread(sfo_data, 1, sfo_data_len, fp)); + fclose(fp); + } + + void TearDown() override + { + ps3_free_sfo(&sfo); + free(sfo_data); + } + + ~SfoFixture() override = default; +}; + +/* Test: parse succeeds */ +TEST_F(SfoFixture, ParseSucceeds) +{ + int32_t ret = ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + ASSERT_EQ(0, ret); + EXPECT_EQ(8u, sfo.entry_count); +} + +/* Test: TITLE key */ +TEST_F(SfoFixture, Title) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "TITLE"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("Test Game Title", val); +} + +/* Test: TITLE_ID key */ +TEST_F(SfoFixture, TitleId) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "TITLE_ID"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("BLES99999", val); +} + +/* Test: CATEGORY key */ +TEST_F(SfoFixture, Category) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "CATEGORY"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("DG", val); +} + +/* Test: APP_VER key */ +TEST_F(SfoFixture, AppVer) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "APP_VER"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("01.00", val); +} + +/* Test: VERSION key */ +TEST_F(SfoFixture, Version) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "VERSION"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("02.50", val); +} + +/* Test: PS3_SYSTEM_VER key */ +TEST_F(SfoFixture, SystemVer) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "PS3_SYSTEM_VER"); + ASSERT_NE(nullptr, val); + EXPECT_STREQ("04.21", val); +} + +/* Test: non-existent key returns NULL */ +TEST_F(SfoFixture, NonExistentKey) +{ + ps3_parse_sfo(sfo_data, sfo_data_len, &sfo); + const char *val = ps3_sfo_get_string(&sfo, "NONEXISTENT"); + EXPECT_EQ(nullptr, val); +} + +/* Test: NULL arguments return error */ +TEST(SfoErrors, NullArgs) +{ + SfoFile sfo; + uint8_t dummy[20] = {0}; + + EXPECT_LT(ps3_parse_sfo(NULL, 10, &sfo), 0); + EXPECT_LT(ps3_parse_sfo(dummy, 10, NULL), 0); +} + +/* Test: too-small buffer returns error */ +TEST(SfoErrors, TooSmall) +{ + SfoFile sfo; + uint8_t buf[10] = {0}; + EXPECT_LT(ps3_parse_sfo(buf, 10, &sfo), 0); +} + +/* Test: wrong magic returns error */ +TEST(SfoErrors, WrongMagic) +{ + SfoFile sfo; + uint8_t buf[20]; + memset(buf, 0, 20); + buf[0] = 0xFF; /* bad magic */ + EXPECT_LT(ps3_parse_sfo(buf, 20, &sfo), 0); +} + +/* Test: free on zeroed struct is safe */ +TEST(SfoErrors, FreeZeroed) +{ + SfoFile sfo; + memset(&sfo, 0, sizeof(sfo)); + ps3_free_sfo(&sfo); /* should not crash */ + ps3_free_sfo(NULL); /* should not crash */ +} + +/* Test: get_string with NULL sfo returns NULL */ +TEST(SfoErrors, GetStringNull) +{ + EXPECT_EQ(nullptr, ps3_sfo_get_string(NULL, "TITLE")); + + SfoFile sfo; + memset(&sfo, 0, sizeof(sfo)); + EXPECT_EQ(nullptr, ps3_sfo_get_string(&sfo, NULL)); +} diff --git a/tool/CMakeLists.txt b/tool/CMakeLists.txt index 0836153..59f9fbf 100644 --- a/tool/CMakeLists.txt +++ b/tool/CMakeLists.txt @@ -30,6 +30,8 @@ add_executable(aaruformattool inject_media_tag.c ird.c ird.h + sfo.c + sfo.h termbox2.h ) diff --git a/tool/sfo.c b/tool/sfo.c new file mode 100644 index 0000000..0a939de --- /dev/null +++ b/tool/sfo.c @@ -0,0 +1,181 @@ +/* + * This file is part of the Aaru Data Preservation Suite. + * Copyright (c) 2019-2026 Natalia Portillo. + * + * 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; either version 2 + * of the License, or (at your option) any later version. + * + * 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * PARAM.SFO parser for PlayStation 3 game metadata. + * + * SFO binary format (all fields little-endian): + * + * Header (20 bytes): + * [4B] magic: 0x00505346 ("\0PSF") + * [4B] version: e.g. 0x00000101 + * [4B] key_table_offset: offset to key string table + * [4B] data_table_offset: offset to data value table + * [4B] entry_count: number of index entries + * + * Index table (entry_count × 16 bytes each): + * [2B] key_offset: offset into key table for this entry's name + * [2B] data_format: 0x0004=UTF-8, 0x0404=UTF-8(special), 0x0204=int32 + * [4B] data_len: actual data length + * [4B] data_maxlen: maximum data length (padded) + * [4B] data_offset: offset into data table for this entry's value + * + * Key table: null-terminated ASCII strings back-to-back + * Data table: values packed per index entries + */ + +#include "sfo.h" + +#include +#include + +#define SFO_MAGIC 0x46535000 /* \"\\0PSF\" as little-endian uint32 (bytes: 00 50 53 46) */ + +#define SFO_FORMAT_UTF8 0x0004 +#define SFO_FORMAT_UTF8_SPECIAL 0x0204 +#define SFO_FORMAT_INT32 0x0404 + +static inline uint16_t read_le16(const uint8_t *p) { return (uint16_t)p[0] | ((uint16_t)p[1] << 8); } + +static inline uint32_t read_le32(const uint8_t *p) +{ return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } + +int32_t ps3_parse_sfo(const uint8_t *data, uint32_t length, SfoFile *sfo) +{ + if(data == NULL || sfo == NULL) return -1; + + memset(sfo, 0, sizeof(*sfo)); + + /* Need at least the 20-byte header */ + if(length < 20) return -2; + + uint32_t magic = read_le32(data); + + if(magic != SFO_MAGIC) return -3; + + /* uint32_t version = read_le32(data + 4); — not needed */ + uint32_t key_table_offset = read_le32(data + 8); + uint32_t data_table_offset = read_le32(data + 12); + uint32_t entry_count = read_le32(data + 16); + + if(entry_count == 0) return 0; + + /* Validate that the index table fits */ + uint32_t index_end = 20 + entry_count * 16; + + if(index_end > length || key_table_offset > length || data_table_offset > length) return -2; + + sfo->entries = calloc(entry_count, sizeof(SfoEntry)); + + if(sfo->entries == NULL) return -4; + + sfo->entry_count = entry_count; + + for(uint32_t i = 0; i < entry_count; i++) + { + const uint8_t *idx = data + 20 + i * 16; + + uint16_t key_offset = read_le16(idx); + uint16_t data_format = read_le16(idx + 2); + uint32_t data_len = read_le32(idx + 4); + /* uint32_t data_maxlen = read_le32(idx + 8); — not needed */ + uint32_t data_offset = read_le32(idx + 12); + + sfo->entries[i].format = data_format; + + /* Read key from key table (null-terminated) */ + uint32_t abs_key = key_table_offset + key_offset; + + if(abs_key < length) + { + /* Find null terminator, bounded by end of buffer */ + const char *key_start = (const char *)(data + abs_key); + size_t max_len = length - abs_key; + size_t key_len = strnlen(key_start, max_len); + + sfo->entries[i].key = malloc(key_len + 1); + + if(sfo->entries[i].key != NULL) + { + memcpy(sfo->entries[i].key, key_start, key_len); + sfo->entries[i].key[key_len] = '\0'; + } + } + + /* Read value from data table */ + uint32_t abs_data = data_table_offset + data_offset; + + if(abs_data < length && data_len > 0) + { + uint32_t avail = length - abs_data; + + if(data_len > avail) data_len = avail; + + if(data_format == SFO_FORMAT_UTF8 || data_format == SFO_FORMAT_UTF8_SPECIAL) + { + /* String value: copy ensuring null termination */ + sfo->entries[i].value = malloc(data_len + 1); + + if(sfo->entries[i].value != NULL) + { + memcpy(sfo->entries[i].value, data + abs_data, data_len); + sfo->entries[i].value[data_len] = '\0'; + + /* Trim trailing nulls for clean string */ + size_t slen = strlen(sfo->entries[i].value); + + if(slen < data_len) sfo->entries[i].value[slen] = '\0'; + } + } + else if(data_format == SFO_FORMAT_INT32 && data_len >= 4) + { + sfo->entries[i].int_value = (int32_t)read_le32(data + abs_data); + } + } + } + + return 0; +} + +const char *ps3_sfo_get_string(const SfoFile *sfo, const char *key) +{ + if(sfo == NULL || key == NULL || sfo->entries == NULL) return NULL; + + for(uint32_t i = 0; i < sfo->entry_count; i++) + if(sfo->entries[i].key != NULL && strcmp(sfo->entries[i].key, key) == 0) return sfo->entries[i].value; + + return NULL; +} + +void ps3_free_sfo(SfoFile *sfo) +{ + if(sfo == NULL) return; + + if(sfo->entries != NULL) + { + for(uint32_t i = 0; i < sfo->entry_count; i++) + { + free(sfo->entries[i].key); + free(sfo->entries[i].value); + } + + free(sfo->entries); + sfo->entries = NULL; + } + + sfo->entry_count = 0; +} diff --git a/tool/sfo.h b/tool/sfo.h new file mode 100644 index 0000000..cf93489 --- /dev/null +++ b/tool/sfo.h @@ -0,0 +1,82 @@ +/* + * This file is part of the Aaru Data Preservation Suite. + * Copyright (c) 2019-2026 Natalia Portillo. + * + * 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; either version 2 + * of the License, or (at your option) any later version. + * + * 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * PARAM.SFO parser for PlayStation 3 game metadata. + */ + +#ifndef LIBAARUFORMAT_TOOL_SFO_H +#define LIBAARUFORMAT_TOOL_SFO_H + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** A single key-value entry from a PARAM.SFO file. */ + typedef struct SfoEntry + { + char *key; /**< Parameter name (malloc'd, null-terminated). */ + char *value; /**< UTF-8 string value (malloc'd, null-terminated). Only for string-type entries. */ + int32_t int_value; /**< Integer value (for integer-type entries). */ + uint16_t format; /**< Data format: 0x0004 = UTF-8 string, 0x0204 = UTF-8 string (special), 0x0404 = int32. */ + } SfoEntry; + + /** Parsed PARAM.SFO file. */ + typedef struct SfoFile + { + SfoEntry *entries; /**< Array of entries (malloc'd). */ + uint32_t entry_count; /**< Number of entries. */ + } SfoFile; + + /** + * @brief Parse a PARAM.SFO from an in-memory buffer. + * + * @param data Pointer to the SFO file data. + * @param length Length of the data buffer. + * @param sfo Output: parsed SFO structure. Must be freed with ps3_free_sfo(). + * @return 0 on success, negative on error: + * -1: NULL argument + * -2: buffer too small or invalid header + * -3: invalid magic + * -4: memory allocation failure + */ + int32_t ps3_parse_sfo(const uint8_t *data, uint32_t length, SfoFile *sfo); + + /** + * @brief Look up a string value by key in a parsed SFO. + * + * @param sfo Parsed SFO structure. + * @param key Key to search for. + * @return Pointer to the value string (owned by the SfoFile), or NULL if not found. + */ + const char *ps3_sfo_get_string(const SfoFile *sfo, const char *key); + + /** + * @brief Free all dynamically allocated fields in an SfoFile. + * + * @param sfo Pointer to SfoFile to clean up. + */ + void ps3_free_sfo(SfoFile *sfo); + +#ifdef __cplusplus +} +#endif + +#endif /* LIBAARUFORMAT_TOOL_SFO_H */