mirror of
https://github.com/cemu-project/Cemu.git
synced 2025-07-03 05:21:18 +12:00
376 lines
12 KiB
C++
376 lines
12 KiB
C++
#include "iosu_pdm.h"
|
|
#include "config/ActiveSettings.h"
|
|
#include "Common/FileStream.h"
|
|
#include "util/helpers/Semaphore.h"
|
|
|
|
#if BOOST_OS_LINUX
|
|
// using chrono::year_month_date and other features require a relatively recent stdlibc++
|
|
// to avoid upping the required version we use the STL reference implementation for now
|
|
#include "Common/unix/date.h"
|
|
namespace chrono_d = date;
|
|
#else
|
|
namespace chrono_d = std::chrono;
|
|
#endif
|
|
|
|
|
|
namespace iosu
|
|
{
|
|
namespace pdm
|
|
{
|
|
std::mutex sDiaryLock;
|
|
|
|
fs::path GetPDFile(const char* filename)
|
|
{
|
|
// todo - support for per-account tracking
|
|
return ActiveSettings::GetMlcPath(fmt::format("usr/save/system/pdm/80000001/{}", filename));
|
|
}
|
|
|
|
void MakeDirectory()
|
|
{
|
|
fs::path path = GetPDFile(".");
|
|
std::error_code ec;
|
|
fs::create_directories(path, ec);
|
|
}
|
|
|
|
chrono_d::year_month_day GetDateFromDayIndex(uint16 dayIndex)
|
|
{
|
|
chrono_d::sys_days startDateDay(chrono_d::year(2000) / chrono_d::January / chrono_d::day(1));
|
|
chrono_d::sys_days lastPlayedDay = startDateDay + chrono_d::days(dayIndex);
|
|
return chrono_d::year_month_day(lastPlayedDay);
|
|
}
|
|
|
|
uint16 GetTodaysDayIndex()
|
|
{
|
|
chrono_d::sys_days startDateDay(chrono_d::year(2000) / chrono_d::January / chrono_d::day(1));
|
|
chrono_d::sys_days currentDateDay = chrono_d::floor<chrono_d::days>(std::chrono::system_clock::now());
|
|
return (uint16)(currentDateDay - startDateDay).count();
|
|
}
|
|
|
|
struct PlayStatsEntry
|
|
{
|
|
uint32be titleIdHigh;
|
|
uint32be titleIdLow;
|
|
uint32be totalMinutesPlayed;
|
|
uint16be numTimesLaunched;
|
|
uint16be firstLaunchDayIndex; // first time this title was launched
|
|
uint16be mostRecentDayIndex; // last time this title was played
|
|
uint16be ukn12; // maybe just padding?
|
|
};
|
|
|
|
static_assert(sizeof(PlayStatsEntry) == 0x14);
|
|
|
|
struct
|
|
{
|
|
FileStream* fs{};
|
|
uint32be numEntries;
|
|
PlayStatsEntry entry[NUM_PLAY_STATS_ENTRIES];
|
|
}PlayStats;
|
|
|
|
void CreatePlaystats()
|
|
{
|
|
PlayStats.fs = FileStream::createFile2(GetPDFile("PlayStats.dat"));
|
|
if (!PlayStats.fs)
|
|
{
|
|
cemuLog_log(LogType::Force, "Unable to open or create PlayStats.dat");
|
|
return;
|
|
}
|
|
uint32be entryCount = 0;
|
|
PlayStats.fs->writeData(&entryCount, sizeof(uint32be));
|
|
PlayStats.fs->writeData(PlayStats.entry, NUM_PLAY_STATS_ENTRIES * sizeof(PlayStatsEntry));
|
|
static_assert((NUM_PLAY_STATS_ENTRIES * sizeof(PlayStatsEntry)) == 0x1400);
|
|
}
|
|
|
|
void LoadPlaystats()
|
|
{
|
|
PlayStats.numEntries = 0;
|
|
for (size_t i = 0; i < NUM_PLAY_STATS_ENTRIES; i++)
|
|
{
|
|
auto& e = PlayStats.entry[i];
|
|
memset(&e, 0, sizeof(PlayStatsEntry));
|
|
}
|
|
PlayStats.fs = FileStream::openFile2(GetPDFile("PlayStats.dat"), true);
|
|
if (!PlayStats.fs)
|
|
{
|
|
CreatePlaystats();
|
|
return;
|
|
}
|
|
if (PlayStats.fs->GetSize() != (NUM_PLAY_STATS_ENTRIES * 20 + 4))
|
|
{
|
|
delete PlayStats.fs;
|
|
PlayStats.fs = nullptr;
|
|
cemuLog_log(LogType::Force, "PlayStats.dat malformed");
|
|
// dont delete the existing file in case it could still be salvaged (todo) and instead just dont track play time
|
|
return;
|
|
}
|
|
PlayStats.fs->readData(&PlayStats.numEntries, sizeof(uint32be));
|
|
if (PlayStats.numEntries > NUM_PLAY_STATS_ENTRIES)
|
|
PlayStats.numEntries = NUM_PLAY_STATS_ENTRIES;
|
|
PlayStats.fs->readData(PlayStats.entry, NUM_PLAY_STATS_ENTRIES * 20);
|
|
}
|
|
|
|
PlayStatsEntry* PlayStats_GetEntry(uint64 titleId)
|
|
{
|
|
uint32be titleIdHigh = (uint32)(titleId>>32);
|
|
uint32be titleIdLow = (uint32)(titleId & 0xFFFFFFFF);
|
|
size_t numEntries = PlayStats.numEntries;
|
|
for (size_t i = 0; i < numEntries; i++)
|
|
{
|
|
if (PlayStats.entry[i].titleIdHigh == titleIdHigh && PlayStats.entry[i].titleIdLow == titleIdLow)
|
|
return &PlayStats.entry[i];
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void PlayStats_WriteEntry(PlayStatsEntry* entry, bool writeEntryCount = false)
|
|
{
|
|
if (!PlayStats.fs)
|
|
return;
|
|
size_t entryIndex = entry - PlayStats.entry;
|
|
cemu_assert(entryIndex < NUM_PLAY_STATS_ENTRIES);
|
|
PlayStats.fs->SetPosition(4 + entryIndex * sizeof(PlayStatsEntry));
|
|
if (PlayStats.fs->writeData(entry, sizeof(PlayStatsEntry)) != sizeof(PlayStatsEntry))
|
|
{
|
|
cemuLog_log(LogType::Force, "Failed to write to PlayStats.dat");
|
|
return;
|
|
}
|
|
if (writeEntryCount)
|
|
{
|
|
uint32be numEntries = PlayStats.numEntries;
|
|
PlayStats.fs->SetPosition(0);
|
|
PlayStats.fs->writeData(&numEntries, sizeof(uint32be));
|
|
}
|
|
}
|
|
|
|
PlayStatsEntry* PlayStats_CreateEntry(uint64 titleId)
|
|
{
|
|
bool entryCountChanged = false;
|
|
PlayStatsEntry* newEntry;
|
|
if(PlayStats.numEntries < NUM_PLAY_STATS_ENTRIES)
|
|
{
|
|
newEntry = PlayStats.entry + PlayStats.numEntries;
|
|
PlayStats.numEntries += 1;
|
|
entryCountChanged = true;
|
|
}
|
|
else
|
|
{
|
|
// list is full - find existing entry with least amount of minutes and replace it
|
|
newEntry = PlayStats.entry + 0;
|
|
for (uint32 i = 1; i < NUM_PLAY_STATS_ENTRIES; i++)
|
|
{
|
|
if(PlayStats.entry[i].totalMinutesPlayed < newEntry->totalMinutesPlayed)
|
|
newEntry = PlayStats.entry + i;
|
|
}
|
|
}
|
|
newEntry->titleIdHigh = (uint32)(titleId >> 32);
|
|
newEntry->titleIdLow = (uint32)(titleId & 0xFFFFFFFF);
|
|
newEntry->firstLaunchDayIndex = GetTodaysDayIndex();
|
|
newEntry->mostRecentDayIndex = newEntry->firstLaunchDayIndex;
|
|
newEntry->numTimesLaunched = 1;
|
|
newEntry->totalMinutesPlayed = 0;
|
|
newEntry->ukn12 = 0;
|
|
PlayStats_WriteEntry(newEntry, entryCountChanged);
|
|
return newEntry;
|
|
}
|
|
|
|
// sets last played if entry already exists
|
|
// if it does not exist it creates a new entry with first and last played set to today
|
|
PlayStatsEntry* PlayStats_BeginNewTracking(uint64 titleId)
|
|
{
|
|
PlayStatsEntry* entry = PlayStats_GetEntry(titleId);
|
|
if (entry)
|
|
{
|
|
entry->mostRecentDayIndex = GetTodaysDayIndex();
|
|
entry->numTimesLaunched += 1;
|
|
PlayStats_WriteEntry(entry);
|
|
return entry;
|
|
}
|
|
return PlayStats_CreateEntry(titleId);
|
|
}
|
|
|
|
void PlayStats_CountAdditionalMinutes(PlayStatsEntry* entry, uint32 additionalMinutes)
|
|
{
|
|
if (additionalMinutes == 0)
|
|
return;
|
|
entry->totalMinutesPlayed += additionalMinutes;
|
|
entry->mostRecentDayIndex = GetTodaysDayIndex();
|
|
PlayStats_WriteEntry(entry);
|
|
}
|
|
|
|
struct PlayDiaryHeader
|
|
{
|
|
// the play diary is a rolling log
|
|
// initially only writeIndex increases
|
|
// after the log is filled, writeIndex wraps over and readIndex will increase as well
|
|
uint32be readIndex;
|
|
uint32be writeIndex;
|
|
};
|
|
|
|
static_assert(sizeof(PlayDiaryEntry) == 0x10);
|
|
static_assert(sizeof(PlayDiaryHeader) == 0x8);
|
|
|
|
struct
|
|
{
|
|
FileStream* fs{};
|
|
PlayDiaryHeader header;
|
|
PlayDiaryEntry entry[NUM_PLAY_DIARY_ENTRIES_MAX];
|
|
}PlayDiaryData;
|
|
|
|
void CreatePlayDiary()
|
|
{
|
|
MakeDirectory();
|
|
PlayDiaryData.fs = FileStream::createFile2(GetPDFile("PlayDiary.dat"));
|
|
if (!PlayDiaryData.fs)
|
|
{
|
|
cemuLog_log(LogType::Force, "Failed to read or write PlayDiary.dat, playtime tracking will not be possible");
|
|
}
|
|
// write header
|
|
PlayDiaryData.header.readIndex = 0;
|
|
PlayDiaryData.header.writeIndex = 0;
|
|
if (PlayDiaryData.fs)
|
|
PlayDiaryData.fs->writeData(&PlayDiaryData.header, sizeof(PlayDiaryHeader));
|
|
}
|
|
|
|
void LoadPlayDiary()
|
|
{
|
|
std::unique_lock _lock(sDiaryLock);
|
|
cemu_assert_debug(!PlayDiaryData.fs);
|
|
PlayDiaryData.fs = FileStream::openFile2(GetPDFile("PlayDiary.dat"), true);
|
|
if (!PlayDiaryData.fs)
|
|
{
|
|
CreatePlayDiary();
|
|
return;
|
|
}
|
|
// read header
|
|
if (PlayDiaryData.fs->readData(&PlayDiaryData.header, sizeof(PlayDiaryHeader)) != sizeof(PlayDiaryHeader))
|
|
{
|
|
cemuLog_log(LogType::Force, "Failed to read valid PlayDiary header");
|
|
delete PlayDiaryData.fs;
|
|
PlayDiaryData.fs = nullptr;
|
|
CreatePlayDiary();
|
|
return;
|
|
}
|
|
if (PlayDiaryData.header.readIndex > NUM_PLAY_DIARY_ENTRIES_MAX || PlayDiaryData.header.writeIndex > NUM_PLAY_DIARY_ENTRIES_MAX)
|
|
{
|
|
cemuLog_log(LogType::Force, "Bad value in play diary header (read={} write={})", (uint32)PlayDiaryData.header.readIndex, (uint32)PlayDiaryData.header.writeIndex);
|
|
PlayDiaryData.header.readIndex = PlayDiaryData.header.readIndex % NUM_PLAY_DIARY_ENTRIES_MAX;
|
|
PlayDiaryData.header.writeIndex = PlayDiaryData.header.writeIndex % NUM_PLAY_DIARY_ENTRIES_MAX;
|
|
}
|
|
// read entries and set any not-yet-written entries to zero
|
|
uint32 readBytes = PlayDiaryData.fs->readData(PlayDiaryData.entry, NUM_PLAY_DIARY_ENTRIES_MAX * sizeof(PlayDiaryEntry));
|
|
uint32 readEntries = readBytes / sizeof(PlayDiaryEntry);
|
|
while (readEntries < NUM_PLAY_DIARY_ENTRIES_MAX)
|
|
{
|
|
PlayDiaryData.entry[readEntries].titleId = 0;
|
|
PlayDiaryData.entry[readEntries].ukn08 = 0;
|
|
PlayDiaryData.entry[readEntries].dayIndex = 0;
|
|
PlayDiaryData.entry[readEntries].ukn0E = 0;
|
|
readEntries++;
|
|
}
|
|
}
|
|
|
|
uint32 GetDiaryEntries(uint8 accountSlot, PlayDiaryEntry* diaryEntries, uint32 maxEntries)
|
|
{
|
|
std::unique_lock _lock(sDiaryLock);
|
|
uint32 numReadEntries = 0;
|
|
uint32 currentEntryIndex = PlayDiaryData.header.readIndex;
|
|
while (currentEntryIndex != PlayDiaryData.header.writeIndex && numReadEntries < maxEntries)
|
|
{
|
|
*diaryEntries = PlayDiaryData.entry[currentEntryIndex];
|
|
numReadEntries++;
|
|
diaryEntries++;
|
|
currentEntryIndex = (currentEntryIndex+1) % NUM_PLAY_DIARY_ENTRIES_MAX;
|
|
}
|
|
return numReadEntries;
|
|
}
|
|
|
|
bool GetStatForGamelist(uint64 titleId, GameListStat& stat)
|
|
{
|
|
stat.last_played.year = 0;
|
|
stat.last_played.month = 0;
|
|
stat.last_played.day = 0;
|
|
stat.numMinutesPlayed = 0;
|
|
std::unique_lock _lock(sDiaryLock);
|
|
// the play stats give us last time played and the total minutes
|
|
PlayStatsEntry* playStats = PlayStats_GetEntry(titleId);
|
|
if (playStats)
|
|
{
|
|
stat.numMinutesPlayed = playStats->totalMinutesPlayed;
|
|
chrono_d::year_month_day ymd = GetDateFromDayIndex(playStats->mostRecentDayIndex);
|
|
stat.last_played.year = (int)ymd.year();
|
|
stat.last_played.month = (unsigned int)ymd.month() - 1;
|
|
stat.last_played.day = (unsigned int)ymd.day();
|
|
}
|
|
_lock.unlock();
|
|
// check legacy time tracking for game entries in settings.xml
|
|
std::unique_lock _lockGC(GetConfig().game_cache_entries_mutex);
|
|
for (auto& gameEntry : GetConfig().game_cache_entries)
|
|
{
|
|
if(gameEntry.title_id != titleId)
|
|
continue;
|
|
stat.numMinutesPlayed += (gameEntry.legacy_time_played / 60);
|
|
if (gameEntry.legacy_last_played != 0)
|
|
{
|
|
time_t td = gameEntry.legacy_last_played;
|
|
tm* date = localtime(&td);
|
|
uint32 legacyYear = (uint32)date->tm_year + 1900;
|
|
uint32 legacyMonth = (uint32)date->tm_mon;
|
|
uint32 legacyDay = (uint32)date->tm_mday;
|
|
if (stat.last_played.year == 0 || std::tie(legacyYear, legacyMonth, legacyDay) > std::tie(stat.last_played.year, stat.last_played.month, stat.last_played.day))
|
|
{
|
|
stat.last_played.year = legacyYear;
|
|
stat.last_played.month = legacyMonth;
|
|
stat.last_played.day = legacyDay;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::thread sPDMTimeTrackingThread;
|
|
CounterSemaphore sPDMSem;
|
|
std::atomic_bool sPDMRequestExitThread{ false };
|
|
|
|
void TimeTrackingThread(uint64 titleId)
|
|
{
|
|
PlayStatsEntry* playStatsEntry = PlayStats_BeginNewTracking(titleId);
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
uint32 prevMinuteCounter = 0;
|
|
while (true)
|
|
{
|
|
sPDMSem.decrementWithWaitAndTimeout(15000);
|
|
if (sPDMRequestExitThread.load(std::memory_order::relaxed))
|
|
break;
|
|
auto currentTime = std::chrono::steady_clock::now();
|
|
uint32 elapsedMinutes = std::chrono::duration_cast<std::chrono::minutes>(currentTime - startTime).count();
|
|
if (elapsedMinutes > prevMinuteCounter)
|
|
{
|
|
PlayStats_CountAdditionalMinutes(playStatsEntry, (elapsedMinutes - prevMinuteCounter));
|
|
// todo - also update PlayDiary (and other files)
|
|
prevMinuteCounter = elapsedMinutes;
|
|
}
|
|
}
|
|
}
|
|
|
|
void Initialize()
|
|
{
|
|
// todo - add support for per-account handling
|
|
LoadPlaystats();
|
|
LoadPlayDiary();
|
|
}
|
|
|
|
void StartTrackingTime(uint64 titleId)
|
|
{
|
|
sPDMRequestExitThread = false;
|
|
sPDMTimeTrackingThread = std::thread(TimeTrackingThread, titleId);
|
|
}
|
|
|
|
void Stop()
|
|
{
|
|
sPDMRequestExitThread.store(true);
|
|
sPDMSem.increment();
|
|
sPDMTimeTrackingThread.join();
|
|
}
|
|
|
|
};
|
|
};
|