1 /*****************************************************************************
2  * Copyright (c) 2014-2020 OpenRCT2 developers
3  *
4  * For a complete list of all authors, please refer to contributors.md
5  * Interested in contributing? Visit https://github.com/OpenRCT2/OpenRCT2
6  *
7  * OpenRCT2 is licensed under the GNU General Public License version 3.
8  *****************************************************************************/
9 
10 #include "ScenarioRepository.h"
11 
12 #include "../Context.h"
13 #include "../Game.h"
14 #include "../ParkImporter.h"
15 #include "../PlatformEnvironment.h"
16 #include "../config/Config.h"
17 #include "../core/Console.hpp"
18 #include "../core/File.h"
19 #include "../core/FileIndex.hpp"
20 #include "../core/FileStream.h"
21 #include "../core/MemoryStream.h"
22 #include "../core/Numerics.hpp"
23 #include "../core/Path.hpp"
24 #include "../core/String.hpp"
25 #include "../localisation/Language.h"
26 #include "../localisation/Localisation.h"
27 #include "../localisation/LocalisationService.h"
28 #include "../platform/Platform2.h"
29 #include "../rct12/RCT12.h"
30 #include "../rct12/SawyerChunkReader.h"
31 #include "Scenario.h"
32 #include "ScenarioSources.h"
33 
34 #include <algorithm>
35 #include <memory>
36 #include <vector>
37 
38 using namespace OpenRCT2;
39 
ScenarioCategoryCompare(int32_t categoryA,int32_t categoryB)40 static int32_t ScenarioCategoryCompare(int32_t categoryA, int32_t categoryB)
41 {
42     if (categoryA == categoryB)
43         return 0;
44     if (categoryA == SCENARIO_CATEGORY_DLC)
45         return -1;
46     if (categoryB == SCENARIO_CATEGORY_DLC)
47         return 1;
48     if (categoryA == SCENARIO_CATEGORY_BUILD_YOUR_OWN)
49         return -1;
50     if (categoryB == SCENARIO_CATEGORY_BUILD_YOUR_OWN)
51         return 1;
52     if (categoryA < categoryB)
53         return -1;
54     return 1;
55 }
56 
scenario_index_entry_CompareByCategory(const scenario_index_entry & entryA,const scenario_index_entry & entryB)57 static int32_t scenario_index_entry_CompareByCategory(const scenario_index_entry& entryA, const scenario_index_entry& entryB)
58 {
59     // Order by category
60     if (entryA.category != entryB.category)
61     {
62         return ScenarioCategoryCompare(entryA.category, entryB.category);
63     }
64 
65     // Then by source game / name
66     switch (entryA.category)
67     {
68         default:
69             if (entryA.source_game != entryB.source_game)
70             {
71                 return static_cast<int32_t>(entryA.source_game) - static_cast<int32_t>(entryB.source_game);
72             }
73             return strcmp(entryA.name, entryB.name);
74         case SCENARIO_CATEGORY_REAL:
75         case SCENARIO_CATEGORY_OTHER:
76             return strcmp(entryA.name, entryB.name);
77     }
78 }
79 
scenario_index_entry_CompareByIndex(const scenario_index_entry & entryA,const scenario_index_entry & entryB)80 static int32_t scenario_index_entry_CompareByIndex(const scenario_index_entry& entryA, const scenario_index_entry& entryB)
81 {
82     // Order by source game
83     if (entryA.source_game != entryB.source_game)
84     {
85         return static_cast<int32_t>(entryA.source_game) - static_cast<int32_t>(entryB.source_game);
86     }
87 
88     // Then by index / category / name
89     ScenarioSource sourceGame = ScenarioSource{ entryA.source_game };
90     switch (sourceGame)
91     {
92         default:
93             if (entryA.source_index == -1 && entryB.source_index == -1)
94             {
95                 if (entryA.category == entryB.category)
96                 {
97                     return scenario_index_entry_CompareByCategory(entryA, entryB);
98                 }
99 
100                 return ScenarioCategoryCompare(entryA.category, entryB.category);
101             }
102             if (entryA.source_index == -1)
103             {
104                 return 1;
105             }
106             if (entryB.source_index == -1)
107             {
108                 return -1;
109             }
110             return entryA.source_index - entryB.source_index;
111 
112         case ScenarioSource::Real:
113             return scenario_index_entry_CompareByCategory(entryA, entryB);
114     }
115 }
116 
scenario_highscore_free(scenario_highscore_entry * highscore)117 static void scenario_highscore_free(scenario_highscore_entry* highscore)
118 {
119     SafeFree(highscore->fileName);
120     SafeFree(highscore->name);
121     SafeDelete(highscore);
122 }
123 
124 class ScenarioFileIndex final : public FileIndex<scenario_index_entry>
125 {
126 private:
127     static constexpr uint32_t MAGIC_NUMBER = 0x58444953; // SIDX
128     static constexpr uint16_t VERSION = 5;
129     static constexpr auto PATTERN = "*.sc4;*.sc6;*.sea";
130 
131 public:
ScenarioFileIndex(const IPlatformEnvironment & env)132     explicit ScenarioFileIndex(const IPlatformEnvironment& env)
133         : FileIndex(
134             "scenario index", MAGIC_NUMBER, VERSION, env.GetFilePath(PATHID::CACHE_SCENARIOS), std::string(PATTERN),
135             std::vector<std::string>({
136                 env.GetDirectoryPath(DIRBASE::RCT1, DIRID::SCENARIO),
137                 env.GetDirectoryPath(DIRBASE::RCT2, DIRID::SCENARIO),
138                 env.GetDirectoryPath(DIRBASE::USER, DIRID::SCENARIO),
139             }))
140     {
141     }
142 
143 protected:
Create(int32_t,const std::string & path) const144     std::tuple<bool, scenario_index_entry> Create(int32_t, const std::string& path) const override
145     {
146         scenario_index_entry entry;
147         auto timestamp = File::GetLastModified(path);
148         if (GetScenarioInfo(path, timestamp, &entry))
149         {
150             return std::make_tuple(true, entry);
151         }
152 
153         return std::make_tuple(true, scenario_index_entry());
154     }
155 
Serialise(DataSerialiser & ds,scenario_index_entry & item) const156     void Serialise(DataSerialiser& ds, scenario_index_entry& item) const override
157     {
158         ds << item.path;
159         ds << item.timestamp;
160         ds << item.category;
161         ds << item.source_game;
162         ds << item.source_index;
163         ds << item.sc_id;
164         ds << item.objective_type;
165         ds << item.objective_arg_1;
166         ds << item.objective_arg_2;
167         ds << item.objective_arg_3;
168 
169         ds << item.internal_name;
170         ds << item.name;
171         ds << item.details;
172     }
173 
174 private:
GetStreamFromRCT2Scenario(const std::string & path)175     static std::unique_ptr<IStream> GetStreamFromRCT2Scenario(const std::string& path)
176     {
177         if (String::Equals(Path::GetExtension(path), ".sea", true))
178         {
179             auto data = DecryptSea(fs::u8path(path));
180             auto ms = std::make_unique<MemoryStream>();
181             // Need to copy the data into MemoryStream as the overload will borrow instead of copy.
182             ms->Write(data.data(), data.size());
183             ms->SetPosition(0);
184             return ms;
185         }
186 
187         auto fs = std::make_unique<FileStream>(path, FILE_MODE_OPEN);
188         return fs;
189     }
190 
191     /**
192      * Reads basic information from a scenario file.
193      */
GetScenarioInfo(const std::string & path,uint64_t timestamp,scenario_index_entry * entry)194     static bool GetScenarioInfo(const std::string& path, uint64_t timestamp, scenario_index_entry* entry)
195     {
196         log_verbose("GetScenarioInfo(%s, %d, ...)", path.c_str(), timestamp);
197         try
198         {
199             std::string extension = Path::GetExtension(path);
200             if (String::Equals(extension, ".sc4", true))
201             {
202                 // RCT1 scenario
203                 bool result = false;
204                 try
205                 {
206                     auto s4Importer = ParkImporter::CreateS4();
207                     s4Importer->LoadScenario(path.c_str(), true);
208                     if (s4Importer->GetDetails(entry))
209                     {
210                         String::Set(entry->path, sizeof(entry->path), path.c_str());
211                         entry->timestamp = timestamp;
212                         result = true;
213                     }
214                 }
215                 catch (const std::exception&)
216                 {
217                 }
218                 return result;
219             }
220 
221             // RCT2 or RCTC scenario
222             auto stream = GetStreamFromRCT2Scenario(path);
223             auto chunkReader = SawyerChunkReader(stream.get());
224 
225             rct_s6_header header = chunkReader.ReadChunkAs<rct_s6_header>();
226             if (header.type == S6_TYPE_SCENARIO)
227             {
228                 rct_s6_info info = chunkReader.ReadChunkAs<rct_s6_info>();
229                 // If the name or the details contain a colour code, they might be in UTF-8 already.
230                 // This is caused by a bug that was in OpenRCT2 for 3 years.
231                 if (!IsLikelyUTF8(info.name) && !IsLikelyUTF8(info.details))
232                 {
233                     rct2_to_utf8_self(info.name, sizeof(info.name));
234                     rct2_to_utf8_self(info.details, sizeof(info.details));
235                 }
236 
237                 *entry = CreateNewScenarioEntry(path, timestamp, &info);
238                 return true;
239             }
240 
241             log_verbose("%s is not a scenario", path.c_str());
242         }
243         catch (const std::exception&)
244         {
245             Console::Error::WriteLine("Unable to read scenario: '%s'", path.c_str());
246         }
247         return false;
248     }
249 
CreateNewScenarioEntry(const std::string & path,uint64_t timestamp,rct_s6_info * s6Info)250     static scenario_index_entry CreateNewScenarioEntry(const std::string& path, uint64_t timestamp, rct_s6_info* s6Info)
251     {
252         scenario_index_entry entry = {};
253 
254         // Set new entry
255         String::Set(entry.path, sizeof(entry.path), path.c_str());
256         entry.timestamp = timestamp;
257         entry.category = s6Info->category;
258         entry.objective_type = s6Info->objective_type;
259         entry.objective_arg_1 = s6Info->objective_arg_1;
260         entry.objective_arg_2 = s6Info->objective_arg_2;
261         entry.objective_arg_3 = s6Info->objective_arg_3;
262         entry.highscore = nullptr;
263         if (String::IsNullOrEmpty(s6Info->name))
264         {
265             // If the scenario doesn't have a name, set it to the filename
266             String::Set(entry.name, sizeof(entry.name), Path::GetFileNameWithoutExtension(entry.path));
267         }
268         else
269         {
270             String::Set(entry.name, sizeof(entry.name), s6Info->name);
271             // Normalise the name to make the scenario as recognisable as possible.
272             ScenarioSources::NormaliseName(entry.name, sizeof(entry.name), entry.name);
273         }
274 
275         // entry.name will be translated later so keep the untranslated name here
276         String::Set(entry.internal_name, sizeof(entry.internal_name), entry.name);
277 
278         String::Set(entry.details, sizeof(entry.details), s6Info->details);
279 
280         // Look up and store information regarding the origins of this scenario.
281         source_desc desc;
282         if (ScenarioSources::TryGetByName(entry.name, &desc))
283         {
284             entry.sc_id = desc.id;
285             entry.source_index = desc.index;
286             entry.source_game = ScenarioSource{ desc.source };
287             entry.category = desc.category;
288         }
289         else
290         {
291             entry.sc_id = SC_UNIDENTIFIED;
292             entry.source_index = -1;
293             if (entry.category == SCENARIO_CATEGORY_REAL)
294             {
295                 entry.source_game = ScenarioSource::Real;
296             }
297             else
298             {
299                 entry.source_game = ScenarioSource::Other;
300             }
301         }
302 
303         scenario_translate(&entry);
304         return entry;
305     }
306 };
307 
308 class ScenarioRepository final : public IScenarioRepository
309 {
310 private:
311     static constexpr uint32_t HighscoreFileVersion = 2;
312 
313     std::shared_ptr<IPlatformEnvironment> const _env;
314     ScenarioFileIndex const _fileIndex;
315     std::vector<scenario_index_entry> _scenarios;
316     std::vector<scenario_highscore_entry*> _highscores;
317 
318 public:
ScenarioRepository(const std::shared_ptr<IPlatformEnvironment> & env)319     explicit ScenarioRepository(const std::shared_ptr<IPlatformEnvironment>& env)
320         : _env(env)
321         , _fileIndex(*env)
322     {
323     }
324 
~ScenarioRepository()325     virtual ~ScenarioRepository()
326     {
327         ClearHighscores();
328     }
329 
Scan(int32_t language)330     void Scan(int32_t language) override
331     {
332         ImportMegaPark();
333 
334         // Reload scenarios from index
335         _scenarios.clear();
336         auto scenarios = _fileIndex.LoadOrBuild(language);
337         for (const auto& scenario : scenarios)
338         {
339             AddScenario(scenario);
340         }
341 
342         // Sort the scenarios and load the highscores
343         Sort();
344         LoadScores();
345         LoadLegacyScores();
346         AttachHighscores();
347     }
348 
GetCount() const349     size_t GetCount() const override
350     {
351         return _scenarios.size();
352     }
353 
GetByIndex(size_t index) const354     const scenario_index_entry* GetByIndex(size_t index) const override
355     {
356         const scenario_index_entry* result = nullptr;
357         if (index < _scenarios.size())
358         {
359             result = &_scenarios[index];
360         }
361         return result;
362     }
363 
GetByFilename(const utf8 * filename) const364     const scenario_index_entry* GetByFilename(const utf8* filename) const override
365     {
366         for (const auto& scenario : _scenarios)
367         {
368             const utf8* scenarioFilename = Path::GetFileName(scenario.path);
369 
370             // Note: this is always case insensitive search for cross platform consistency
371             if (String::Equals(filename, scenarioFilename, true))
372             {
373                 return &scenario;
374             }
375         }
376         return nullptr;
377     }
378 
GetByInternalName(const utf8 * name) const379     const scenario_index_entry* GetByInternalName(const utf8* name) const override
380     {
381         for (size_t i = 0; i < _scenarios.size(); i++)
382         {
383             const scenario_index_entry* scenario = &_scenarios[i];
384 
385             if (scenario->source_game == ScenarioSource::Other && scenario->sc_id == SC_UNIDENTIFIED)
386                 continue;
387 
388             // Note: this is always case insensitive search for cross platform consistency
389             if (String::Equals(name, scenario->internal_name, true))
390             {
391                 return &_scenarios[i];
392             }
393         }
394         return nullptr;
395     }
396 
GetByPath(const utf8 * path) const397     const scenario_index_entry* GetByPath(const utf8* path) const override
398     {
399         for (const auto& scenario : _scenarios)
400         {
401             if (Path::Equals(path, scenario.path))
402             {
403                 return &scenario;
404             }
405         }
406         return nullptr;
407     }
408 
TryRecordHighscore(int32_t language,const utf8 * scenarioFileName,money64 companyValue,const utf8 * name)409     bool TryRecordHighscore(int32_t language, const utf8* scenarioFileName, money64 companyValue, const utf8* name) override
410     {
411         // Scan the scenarios so we have a fresh list to query. This is to prevent the issue of scenario completions
412         // not getting recorded, see #4951.
413         Scan(language);
414 
415         scenario_index_entry* scenario = GetByFilename(scenarioFileName);
416 
417         // Check if this is an RCTC scenario that corresponds to a known RCT1/2 scenario or vice versa, see #12626
418         if (scenario == nullptr)
419         {
420             const std::string scenarioBaseName = String::ToStd(Path::GetFileNameWithoutExtension(scenarioFileName));
421             const std::string scenarioExtension = String::ToStd(Path::GetExtension(scenarioFileName));
422 
423             if (String::Equals(scenarioExtension, ".sea", true))
424             {
425                 // Get scenario using RCT2 style name of RCTC scenario
426                 scenario = GetByFilename((scenarioBaseName + ".sc6").c_str());
427             }
428             else if (String::Equals(scenarioExtension, ".sc6", true))
429             {
430                 // Get scenario using RCTC style name of RCT2 scenario
431                 scenario = GetByFilename((scenarioBaseName + ".sea").c_str());
432             }
433         }
434 
435         if (scenario != nullptr)
436         {
437             // Check if record company value has been broken or the highscore is the same but no name is registered
438             scenario_highscore_entry* highscore = scenario->highscore;
439             if (highscore == nullptr || companyValue > highscore->company_value
440                 || (String::IsNullOrEmpty(highscore->name) && companyValue == highscore->company_value))
441             {
442                 if (highscore == nullptr)
443                 {
444                     highscore = InsertHighscore();
445                     highscore->timestamp = platform_get_datetime_now_utc();
446                     scenario->highscore = highscore;
447                 }
448                 else
449                 {
450                     if (!String::IsNullOrEmpty(highscore->name))
451                     {
452                         highscore->timestamp = platform_get_datetime_now_utc();
453                     }
454                     SafeFree(highscore->fileName);
455                     SafeFree(highscore->name);
456                 }
457                 highscore->fileName = String::Duplicate(Path::GetFileName(scenario->path));
458                 highscore->name = String::Duplicate(name);
459                 highscore->company_value = companyValue;
460                 SaveHighscores();
461                 return true;
462             }
463         }
464         return false;
465     }
466 
467 private:
GetByFilename(const utf8 * filename)468     scenario_index_entry* GetByFilename(const utf8* filename)
469     {
470         const ScenarioRepository* repo = this;
471         return const_cast<scenario_index_entry*>(repo->GetByFilename(filename));
472     }
473 
GetByPath(const utf8 * path)474     scenario_index_entry* GetByPath(const utf8* path)
475     {
476         const ScenarioRepository* repo = this;
477         return const_cast<scenario_index_entry*>(repo->GetByPath(path));
478     }
479 
480     /**
481      * Mega Park from RollerCoaster Tycoon 1 is stored in an encrypted hidden file: mp.dat.
482      * Decrypt the file and save it as sc21.sc4 in the user's scenario directory.
483      */
ImportMegaPark()484     void ImportMegaPark()
485     {
486         auto mpdatPath = _env->GetFilePath(PATHID::MP_DAT);
487         auto scenarioDirectory = _env->GetDirectoryPath(DIRBASE::USER, DIRID::SCENARIO);
488         auto expectedSc21Path = Path::Combine(scenarioDirectory, "sc21.sc4");
489         auto sc21Path = Path::ResolveCasing(expectedSc21Path);
490 
491         // If the user has a Steam installation.
492         if (!File::Exists(mpdatPath))
493         {
494             mpdatPath = Path::ResolveCasing(
495                 Path::Combine(_env->GetDirectoryPath(DIRBASE::RCT1), "RCTdeluxe_install", "Data", "mp.dat"));
496         }
497 
498         if (File::Exists(mpdatPath) && !File::Exists(sc21Path))
499         {
500             ConvertMegaPark(mpdatPath, expectedSc21Path);
501         }
502     }
503 
504     /**
505      * Converts Mega Park to normalised file location (mp.dat to sc21.sc4)
506      * @param srcPath Full path to mp.dat
507      * @param dstPath Full path to sc21.dat
508      */
ConvertMegaPark(const std::string & srcPath,const std::string & dstPath)509     void ConvertMegaPark(const std::string& srcPath, const std::string& dstPath)
510     {
511         auto directory = Path::GetDirectory(dstPath);
512         platform_ensure_directory_exists(directory.c_str());
513 
514         auto mpdat = File::ReadAllBytes(srcPath);
515 
516         // Rotate each byte of mp.dat left by 4 bits to convert
517         for (size_t i = 0; i < mpdat.size(); i++)
518         {
519             mpdat[i] = Numerics::rol8(mpdat[i], 4);
520         }
521 
522         File::WriteAllBytes(dstPath, mpdat.data(), mpdat.size());
523     }
524 
AddScenario(const scenario_index_entry & entry)525     void AddScenario(const scenario_index_entry& entry)
526     {
527         auto filename = Path::GetFileName(entry.path);
528 
529         if (!String::Equals(filename, ""))
530         {
531             auto existingEntry = GetByFilename(filename);
532             if (existingEntry != nullptr)
533             {
534                 std::string conflictPath;
535                 if (existingEntry->timestamp > entry.timestamp)
536                 {
537                     // Existing entry is more recent
538                     conflictPath = String::ToStd(existingEntry->path);
539 
540                     // Overwrite existing entry with this one
541                     *existingEntry = entry;
542                 }
543                 else
544                 {
545                     // This entry is more recent
546                     conflictPath = entry.path;
547                 }
548                 Console::WriteLine("Scenario conflict: '%s' ignored because it is newer.", conflictPath.c_str());
549             }
550             else
551             {
552                 _scenarios.push_back(entry);
553             }
554         }
555         else
556         {
557             log_error("Tried to add scenario with an empty filename!");
558         }
559     }
560 
Sort()561     void Sort()
562     {
563         if (gConfigGeneral.scenario_select_mode == SCENARIO_SELECT_MODE_ORIGIN)
564         {
565             std::sort(
566                 _scenarios.begin(), _scenarios.end(), [](const scenario_index_entry& a, const scenario_index_entry& b) -> bool {
567                     return scenario_index_entry_CompareByIndex(a, b) < 0;
568                 });
569         }
570         else
571         {
572             std::sort(
573                 _scenarios.begin(), _scenarios.end(), [](const scenario_index_entry& a, const scenario_index_entry& b) -> bool {
574                     return scenario_index_entry_CompareByCategory(a, b) < 0;
575                 });
576         }
577     }
578 
LoadScores()579     void LoadScores()
580     {
581         std::string path = _env->GetFilePath(PATHID::SCORES);
582         if (!Platform::FileExists(path))
583         {
584             return;
585         }
586 
587         try
588         {
589             auto fs = FileStream(path, FILE_MODE_OPEN);
590             uint32_t fileVersion = fs.ReadValue<uint32_t>();
591             if (fileVersion != 1 && fileVersion != 2)
592             {
593                 Console::Error::WriteLine("Invalid or incompatible highscores file.");
594                 return;
595             }
596 
597             ClearHighscores();
598 
599             uint32_t numHighscores = fs.ReadValue<uint32_t>();
600             for (uint32_t i = 0; i < numHighscores; i++)
601             {
602                 scenario_highscore_entry* highscore = InsertHighscore();
603                 highscore->fileName = fs.ReadString();
604                 highscore->name = fs.ReadString();
605                 highscore->company_value = fileVersion == 1 ? fs.ReadValue<money32>() : fs.ReadValue<money64>();
606                 highscore->timestamp = fs.ReadValue<datetime64>();
607             }
608         }
609         catch (const std::exception&)
610         {
611             Console::Error::WriteLine("Error reading highscores.");
612         }
613     }
614 
615     /**
616      * Loads the original scores.dat file and replaces any highscores that
617      * are better for matching scenarios.
618      */
LoadLegacyScores()619     void LoadLegacyScores()
620     {
621         std::string rct2Path = _env->GetFilePath(PATHID::SCORES_RCT2);
622         std::string legacyPath = _env->GetFilePath(PATHID::SCORES_LEGACY);
623         LoadLegacyScores(legacyPath);
624         LoadLegacyScores(rct2Path);
625     }
626 
LoadLegacyScores(const std::string & path)627     void LoadLegacyScores(const std::string& path)
628     {
629         if (!Platform::FileExists(path))
630         {
631             return;
632         }
633 
634         bool highscoresDirty = false;
635         try
636         {
637             auto fs = FileStream(path, FILE_MODE_OPEN);
638             if (fs.GetLength() <= 4)
639             {
640                 // Initial value of scores for RCT2, just ignore
641                 return;
642             }
643 
644             // Load header
645             auto header = fs.ReadValue<rct_scores_header>();
646             for (uint32_t i = 0; i < header.ScenarioCount; i++)
647             {
648                 // Read legacy entry
649                 auto scBasic = fs.ReadValue<rct_scores_entry>();
650 
651                 // Ignore non-completed scenarios
652                 if (scBasic.Flags & SCENARIO_FLAGS_COMPLETED)
653                 {
654                     bool notFound = true;
655                     for (auto& highscore : _highscores)
656                     {
657                         if (String::Equals(scBasic.Path, highscore->fileName, true))
658                         {
659                             notFound = false;
660 
661                             // Check if legacy highscore is better
662                             if (scBasic.CompanyValue > highscore->company_value)
663                             {
664                                 SafeFree(highscore->name);
665                                 std::string name = rct2_to_utf8(scBasic.CompletedBy, RCT2LanguageId::EnglishUK);
666                                 highscore->name = String::Duplicate(name.c_str());
667                                 highscore->company_value = scBasic.CompanyValue;
668                                 highscore->timestamp = DATETIME64_MIN;
669                                 break;
670                             }
671                         }
672                     }
673                     if (notFound)
674                     {
675                         scenario_highscore_entry* highscore = InsertHighscore();
676                         highscore->fileName = String::Duplicate(scBasic.Path);
677                         std::string name = rct2_to_utf8(scBasic.CompletedBy, RCT2LanguageId::EnglishUK);
678                         highscore->name = String::Duplicate(name.c_str());
679                         highscore->company_value = scBasic.CompanyValue;
680                         highscore->timestamp = DATETIME64_MIN;
681                     }
682                 }
683             }
684         }
685         catch (const std::exception&)
686         {
687             Console::Error::WriteLine("Error reading legacy scenario scores file: '%s'", path.c_str());
688         }
689 
690         if (highscoresDirty)
691         {
692             SaveHighscores();
693         }
694     }
695 
ClearHighscores()696     void ClearHighscores()
697     {
698         for (auto highscore : _highscores)
699         {
700             scenario_highscore_free(highscore);
701         }
702         _highscores.clear();
703     }
704 
InsertHighscore()705     scenario_highscore_entry* InsertHighscore()
706     {
707         auto highscore = new scenario_highscore_entry();
708         std::memset(highscore, 0, sizeof(scenario_highscore_entry));
709         _highscores.push_back(highscore);
710         return highscore;
711     }
712 
AttachHighscores()713     void AttachHighscores()
714     {
715         for (auto& highscore : _highscores)
716         {
717             scenario_index_entry* scenario = GetByFilename(highscore->fileName);
718             if (scenario != nullptr)
719             {
720                 scenario->highscore = highscore;
721             }
722         }
723     }
724 
SaveHighscores()725     void SaveHighscores()
726     {
727         std::string path = _env->GetFilePath(PATHID::SCORES);
728         try
729         {
730             auto fs = FileStream(path, FILE_MODE_WRITE);
731             fs.WriteValue<uint32_t>(HighscoreFileVersion);
732             fs.WriteValue<uint32_t>(static_cast<uint32_t>(_highscores.size()));
733             for (size_t i = 0; i < _highscores.size(); i++)
734             {
735                 const scenario_highscore_entry* highscore = _highscores[i];
736                 fs.WriteString(highscore->fileName);
737                 fs.WriteString(highscore->name);
738                 fs.WriteValue(highscore->company_value);
739                 fs.WriteValue(highscore->timestamp);
740             }
741         }
742         catch (const std::exception&)
743         {
744             Console::Error::WriteLine("Unable to save highscores to '%s'", path.c_str());
745         }
746     }
747 };
748 
CreateScenarioRepository(const std::shared_ptr<IPlatformEnvironment> & env)749 std::unique_ptr<IScenarioRepository> CreateScenarioRepository(const std::shared_ptr<IPlatformEnvironment>& env)
750 {
751     return std::make_unique<ScenarioRepository>(env);
752 }
753 
GetScenarioRepository()754 IScenarioRepository* GetScenarioRepository()
755 {
756     return GetContext()->GetScenarioRepository();
757 }
758 
scenario_repository_scan()759 void scenario_repository_scan()
760 {
761     IScenarioRepository* repo = GetScenarioRepository();
762     repo->Scan(LocalisationService_GetCurrentLanguage());
763 }
764 
scenario_repository_get_count()765 size_t scenario_repository_get_count()
766 {
767     IScenarioRepository* repo = GetScenarioRepository();
768     return repo->GetCount();
769 }
770 
scenario_repository_get_by_index(size_t index)771 const scenario_index_entry* scenario_repository_get_by_index(size_t index)
772 {
773     IScenarioRepository* repo = GetScenarioRepository();
774     return repo->GetByIndex(index);
775 }
776 
scenario_repository_try_record_highscore(const utf8 * scenarioFileName,money64 companyValue,const utf8 * name)777 bool scenario_repository_try_record_highscore(const utf8* scenarioFileName, money64 companyValue, const utf8* name)
778 {
779     IScenarioRepository* repo = GetScenarioRepository();
780     return repo->TryRecordHighscore(LocalisationService_GetCurrentLanguage(), scenarioFileName, companyValue, name);
781 }
782