internal static bool RemoveTagFromFM(FanMission fm, string catText, string tagText) { if (tagText.IsEmpty()) { return(false); } // Parent node (category) if (catText.IsEmpty()) { // TODO: These messageboxes are annoying, but they prevent accidental deletion. // Figure out something better. bool cont = Core.View.AskToContinue(LText.TagsTab.AskRemoveCategory, LText.TagsTab.TabText, true); if (!cont) { return(false); } CatAndTags?cat = fm.Tags.FirstOrDefault(x => x.Category == tagText); if (cat != null) { fm.Tags.Remove(cat); UpdateFMTagsString(fm); // TODO: Profile the FirstOrDefaults and see if I should make them for loops GlobalCatAndTags?globalCat = GlobalTags.FirstOrDefault(x => x.Category.Name == cat.Category); if (globalCat != null && !globalCat.Category.IsPreset) { if (globalCat.Category.UsedCount > 0) { globalCat.Category.UsedCount--; } if (globalCat.Category.UsedCount == 0) { GlobalTags.Remove(globalCat); } } } } // Child node (tag) else { bool cont = Core.View.AskToContinue(LText.TagsTab.AskRemoveTag, LText.TagsTab.TabText, true); if (!cont) { return(false); } CatAndTags?cat = fm.Tags.FirstOrDefault(x => x.Category == catText); string? tag = cat?.Tags.FirstOrDefault(x => x == tagText); if (tag != null) { cat !.Tags.Remove(tag); if (cat.Tags.Count == 0) { fm.Tags.Remove(cat); } UpdateFMTagsString(fm); GlobalCatAndTags?globalCat = GlobalTags.FirstOrDefault(x => x.Category.Name == cat.Category); GlobalCatOrTag? globalTag = globalCat?.Tags.FirstOrDefault(x => x.Name == tagText); if (globalTag != null && !globalTag.IsPreset) { if (globalTag.UsedCount > 0) { globalTag.UsedCount--; } if (globalTag.UsedCount == 0) { globalCat !.Tags.Remove(globalTag); } if (globalCat !.Tags.Count == 0) { GlobalTags.Remove(globalCat); } } } } Ini.WriteFullFMDataIni(); return(true); }
internal static void AddTagToFM(FanMission fm, string catAndTag) { AddTagsToFMAndGlobalList(catAndTag, fm.Tags); UpdateFMTagsString(fm); Ini.WriteFullFMDataIni(); }
// MT: On startup only, this is run in parallel with MainForm.ctor and .InitThreadable() // So don't touch anything the other touches: anything affecting the view. // @CAN_RUN_BEFORE_VIEW_INIT internal static void Find(bool startup = false) { if (!startup) { // Make sure we don't lose anything when we re-find! // NOTE: This also writes out TagsStrings and then reads them back in and syncs them with Tags. // Critical that that gets done. Ini.WriteFullFMDataIni(); // Do this every time we modify FMsViewList in realtime, to prevent FMsDGV from redrawing from // the list when it's in an indeterminate state (which can cause a selection change (bad) and/or // a visible change of the list (not really bad but unprofessional looking)). // MT: Don't do this on startup because we're running in parallel with the form new/init in that case. Core.View.SetRowCount(0); } // Init or reinit - must be deep-copied or changes propagate back because reference types // MT: This is thread-safe, the view ctor and InitThreadable() doesn't touch it. PresetTags.DeepCopyTo(GlobalTags); #region Back up lists and read FM data file // Copy FMs to backup lists before clearing, in case we can't read the ini file. We don't want to end // up with a blank or incomplete list and then glibly save it out later. var backupList = new List <FanMission>(FMDataIniList.Count); foreach (FanMission fm in FMDataIniList) { backupList.Add(fm); } var viewBackupList = new List <FanMission>(FMsViewList.Count); foreach (FanMission fm in FMsViewList) { viewBackupList.Add(fm); } FMDataIniList.Clear(); FMsViewList.Clear(); // Mark this false because this flag is only there as a perf hack to make SetFilter() not have to // iterate the FMs list for deletion markers unless it's actually going to find something. Our FMs' // deletion markers will disappear here implicitly because the objects are destroyed and new ones // created, but we need to explicitly set our perf-hack bool to false too. Core.OneOrMoreFMsAreMarkedDeleted = false; bool fmDataIniExists = File.Exists(Paths.FMDataIni); if (fmDataIniExists) { try { Ini.ReadFMDataIni(Paths.FMDataIni, FMDataIniList); } catch (Exception ex) { Log("Exception reading FM data ini", ex); if (startup) { // Language will be loaded by this point MessageBox.Show(LText.AlertMessages.FindFMs_ExceptionReadingFMDataIni, LText.AlertMessages.Error, MessageBoxButtons.OK, MessageBoxIcon.Error); Core.EnvironmentExitDoShutdownTasks(1); } else { FMDataIniList.ClearAndAdd(backupList); FMsViewList.ClearAndAdd(viewBackupList); return; } } } #endregion #region Get installed dirs from disk // Could check inside the folder for a .mis file to confirm it's really an FM folder, but that's // horrendously expensive. Talking like eight seconds vs. < 4ms for the 1098 set. Weird. var perGameInstFMDirsList = new List <List <string> >(SupportedGameCount); var perGameInstFMDirsDatesList = new List <List <DateTime> >(SupportedGameCount); for (int gi = 0; gi < SupportedGameCount; gi++) { // NOTE! Make sure this list ends up with SupportedGameCount items in it. Just in case I change // the loop or something. perGameInstFMDirsList.Add(new List <string>()); perGameInstFMDirsDatesList.Add(new List <DateTime>()); string instPath = Config.FMInstallPaths[gi]; if (Directory.Exists(instPath)) { try { var dirs = FastIO.GetDirsTopOnly_FMs(instPath, "*", out List <DateTime> dateTimes); for (int di = 0; di < dirs.Count; di++) { string d = dirs[di]; if (!d.EqualsI(".fmsel.cache")) { perGameInstFMDirsList[gi].Add(d); perGameInstFMDirsDatesList[gi].Add(dateTimes[di]); } } } catch (Exception ex) { Log("Exception getting directories in " + instPath, ex); } } } #endregion #region Get archives from disk var fmArchives = new List <string>(); var fmArchivesDates = new List <DateTime>(); var archivePaths = GetFMArchivePaths(); bool onlyOnePath = archivePaths.Count == 1; for (int ai = 0; ai < archivePaths.Count; ai++) { try { // Returns filenames only (not full paths) var files = FastIO.GetFilesTopOnly_FMs(archivePaths[ai], "*", out List <DateTime> dateTimes); for (int fi = 0; fi < files.Count; fi++) { string f = files[fi]; // Only do .ContainsI() if we're searching multiple directories. Otherwise we're guaranteed // no duplicates and can avoid the expensive lookup. // @DIRSEP: These are filename only, no need for PathContainsI() if ((onlyOnePath || !fmArchives.ContainsI(f)) && f.ExtIsArchive() && !f.ContainsI(Paths.FMSelBak)) { fmArchives.Add(f); fmArchivesDates.Add(dateTimes[fi]); } } } catch (Exception ex) { Log("Exception getting files in " + archivePaths[ai], ex); } } #endregion #region Build FanMission objects from installed dirs var perGameFMsList = new List <List <FanMission> >(SupportedGameCount); for (int gi = 0; gi < SupportedGameCount; gi++) { // NOTE! List must have SupportedGameCount items in it perGameFMsList.Add(new List <FanMission>()); for (int di = 0; di < perGameInstFMDirsList[gi].Count; di++) { perGameFMsList[gi].Add(new FanMission { InstalledDir = perGameInstFMDirsList[gi][di], Game = GameIndexToGame((GameIndex)gi), Installed = true }); } } #endregion MergeNewArchiveFMs(fmArchives, fmArchivesDates); int instInitCount = FMDataIniList.Count; for (int i = 0; i < SupportedGameCount; i++) { var curGameInstFMsList = perGameFMsList[i]; if (curGameInstFMsList.Count > 0) { MergeNewInstalledFMs(curGameInstFMsList, perGameInstFMDirsDatesList[i], instInitCount); } } SetArchiveNames(fmArchives); SetInstalledNames(); BuildViewList(fmArchives, perGameInstFMDirsList); /* * TODO: There's an extreme corner case where duplicate FMs can appear in the list * It's so unlikely it's almost not worth worrying about, but here's the scenario: * -The FM is installed by hand and not truncated * -The FM is not in the list * -A matching archive exists for the FM * In this scenario, the FM is added twice to the list, once with the full installed folder name and * NoArchive set to true, and once with a truncated installed dir name, the correct archive name, and * NoArchive not present (false). * The code in here is so crazy-go-nuts I can't even find where this is happening. But putting this * note down for the future. */ }
internal static async Task <bool> InstallFM(FanMission fm) { #region Checks AssertR(!fm.Installed, "fm.Installed == false"); if (!GameIsKnownAndSupported(fm.Game)) { Log("FM game type is unknown or unsupported.\r\n" + "FM: " + (!fm.Archive.IsEmpty() ? fm.Archive : fm.InstalledDir) + "\r\n" + "FM game was: " + fm.Game); Dialogs.ShowError(ErrorText.FMGameTypeUnknownOrUnsupported); return(false); } GameIndex gameIndex = GameToGameIndex(fm.Game); string fmArchivePath = FMArchives.FindFirstMatch(fm.Archive); if (fmArchivePath.IsEmpty()) { Log("FM archive field was empty; this means an archive was not found for it on the last search.\r\n" + "FM: " + (!fm.Archive.IsEmpty() ? fm.Archive : fm.InstalledDir) + "\r\n" + "FM game was: " + fm.Game); Dialogs.ShowError(LText.AlertMessages.Install_ArchiveNotFound); return(false); } string gameExe = Config.GetGameExe(gameIndex); string gameName = GetLocalizedGameName(gameIndex); if (!File.Exists(gameExe)) { Log("Game executable not found.\r\n" + "Game executable: " + gameExe); Dialogs.ShowError(gameName + ":\r\n" + LText.AlertMessages.Install_ExecutableNotFound); return(false); } string instBasePath = Config.GetFMInstallPath(gameIndex); if (!Directory.Exists(instBasePath)) { Log("FM install path not found.\r\n" + "FM: " + (!fm.Archive.IsEmpty() ? fm.Archive : fm.InstalledDir) + "\r\n" + "FM game was: " + fm.Game + "\r\n" + "FM install path: " + instBasePath ); Dialogs.ShowError(gameName + ":\r\n" + LText.AlertMessages.Install_FMInstallPathNotFound); return(false); } if (GameIsRunning(gameExe)) { Dialogs.ShowAlert(gameName + ":\r\n" + LText.AlertMessages.Install_GameIsRunning, LText.AlertMessages.Alert); return(false); } #endregion string fmInstalledPath = Path.Combine(instBasePath, fm.InstalledDir); _extractCts = new CancellationTokenSource(); Core.View.ShowProgressBox(ProgressTask.InstallFM); // Framework zip extracting is much faster, so use it if possible bool canceled = !await(fmArchivePath.ExtIsZip() ? Task.Run(() => InstallFMZip(fmArchivePath, fmInstalledPath)) : Task.Run(() => InstallFMSevenZip(fmArchivePath, fmInstalledPath))); if (canceled) { Core.View.SetCancelingFMInstall(); await Task.Run(() => { try { Directory.Delete(fmInstalledPath, recursive: true); } catch (Exception ex) { // @BetterErrors(InstallFM() - install cancellation (folder deletion) failed) Log("Unable to delete FM installed directory " + fmInstalledPath, ex); } }); Core.View.HideProgressBox(); return(false); } fm.Installed = true; Ini.WriteFullFMDataIni(); try { using var sw = new StreamWriter(Path.Combine(fmInstalledPath, Paths.FMSelInf), append: false); await sw.WriteLineAsync("Name=" + fm.InstalledDir); await sw.WriteLineAsync("Archive=" + fm.Archive); } catch (Exception ex) { Log("Couldn't create " + Paths.FMSelInf + " in " + fmInstalledPath, ex); } // Only Dark engine games need audio conversion if (GameIsDark(gameIndex)) { try { Core.View.ShowProgressBox(ProgressTask.ConvertFiles); // Dark engine games can't play MP3s, so they must be converted in all cases. // This one won't be called anywhere except during install, because it always runs during // install so there's no need to make it optional elsewhere. So we don't need to have a // check bool or anything. await FMAudio.ConvertToWAVs(fm, AudioConvert.MP3ToWAV, false); if (Config.ConvertOGGsToWAVsOnInstall) { await FMAudio.ConvertToWAVs(fm, AudioConvert.OGGToWAV, false); } if (Config.ConvertWAVsTo16BitOnInstall) { await FMAudio.ConvertToWAVs(fm, AudioConvert.WAVToWAV16, false); } } catch (Exception ex) { Log("Exception in audio conversion", ex); } } // Don't be lazy about this; there can be no harm and only benefits by doing it right away GenerateMissFlagFileIfRequired(fm); // TODO: Put up a "Restoring saves and screenshots" box here to avoid the "converting files" one lasting beyond its time? try { await RestoreFM(fm); } catch (Exception ex) { Log(ex: ex); } finally { Core.View.HideProgressBox(); } // Not doing RefreshFM(rowOnly: true) because that wouldn't update the install/uninstall buttons Core.View.RefreshFM(fm); return(true); }
internal static async Task InstallIfNeededAndPlay(FanMission fm, bool askConfIfRequired = false, bool playMP = false) { if (!GameIsKnownAndSupported(fm.Game)) { Log("Game is unknown or unsupported for FM " + (!fm.Archive.IsEmpty() ? fm.Archive : fm.InstalledDir) + "\r\n" + "fm.Game was: " + fm.Game, stackTrace: true); Dialogs.ShowError(ErrorText.FMGameTypeUnknownOrUnsupported); return; } GameIndex gameIndex = GameToGameIndex(fm.Game); if (playMP && gameIndex != GameIndex.Thief2) { Log("playMP was true, but fm.Game was not Thief 2.\r\n" + "fm: " + (!fm.Archive.IsEmpty() ? fm.Archive : fm.InstalledDir) + "\r\n" + "fm.Game was: " + fm.Game, stackTrace: true); Dialogs.ShowError(ErrorText.MultiplayerForNonThief2); return; } if (askConfIfRequired && Config.ConfirmPlayOnDCOrEnter) { string message = fm.Installed ? LText.AlertMessages.Play_ConfirmMessage : LText.AlertMessages.Play_InstallAndPlayConfirmMessage; if (Core.View.GetSelectedFMOrNull() != fm) { message += "\r\n\r\n" + fm.Archive + "\r\n" + fm.Title + "\r\n" + fm.Author + "\r\n"; } (bool cancel, bool dontAskAgain) = Dialogs.AskToContinueYesNoCustomStrings( message: message, title: LText.AlertMessages.Confirm, icon: MessageBoxIcon.None, showDontAskAgain: true, yes: LText.Global.Yes, no: LText.Global.No); if (cancel) { return; } Config.ConfirmPlayOnDCOrEnter = !dontAskAgain; } if (!fm.Installed && !await InstallFM(fm)) { return; } if (playMP && gameIndex == GameIndex.Thief2 && Config.GetT2MultiplayerExe_FromDisk().IsEmpty()) { Log("Thief2MP.exe not found in Thief 2 game directory.\r\n" + "Thief 2 game directory: " + Config.GetGamePath(GameIndex.Thief2)); Dialogs.ShowError(LText.AlertMessages.Thief2_Multiplayer_ExecutableNotFound); return; } if (PlayFM(fm, playMP)) { fm.LastPlayed.DateTime = DateTime.Now; Core.View.RefreshFM(fm); Ini.WriteFullFMDataIni(); } }
// MT: On startup only, this is run in parallel with MainForm.ctor and .InitThreadable() // So don't touch anything the other touches: anything affecting the view. // @CAN_RUN_BEFORE_VIEW_INIT private static List <int> FindInternal(bool startup) { if (!startup) { // Make sure we don't lose anything when we re-find! // NOTE: This also writes out TagsStrings and then reads them back in and syncs them with Tags. // Critical that that gets done. Ini.WriteFullFMDataIni(); // Do this every time we modify FMsViewList in realtime, to prevent FMsDGV from redrawing from // the list when it's in an indeterminate state (which can cause a selection change (bad) and/or // a visible change of the list (not really bad but unprofessional looking)). // MT: Don't do this on startup because we're running in parallel with the form new/init in that case. Core.View.SetRowCount(0); } // Init or reinit - must be deep-copied or changes propagate back because reference types // MT: This is thread-safe, the view ctor and InitThreadable() doesn't touch it. PresetTags.DeepCopyTo(GlobalTags); #region Back up lists and read FM data file // Copy FMs to backup lists before clearing, in case we can't read the ini file. We don't want to end // up with a blank or incomplete list and then glibly save it out later. FanMission[] backupList = new FanMission[FMDataIniList.Count]; FMDataIniList.CopyTo(backupList); FanMission[] viewBackupList = new FanMission[FMsViewList.Count]; FMsViewList.CopyTo(viewBackupList); FMDataIniList.Clear(); FMsViewList.Clear(); bool fmDataIniExists = File.Exists(Paths.FMDataIni); if (fmDataIniExists) { try { Ini.ReadFMDataIni(Paths.FMDataIni, FMDataIniList); } catch (Exception ex) { Log("Exception reading FM data ini", ex); if (startup) { // Language will be loaded by this point Dialogs.ShowError(LText.AlertMessages.FindFMs_ExceptionReadingFMDataIni); Core.EnvironmentExitDoShutdownTasks(1); } else { FMDataIniList.ClearAndAdd(backupList); FMsViewList.ClearAndAdd(viewBackupList); return(new List <int>()); } } } #endregion #region Get installed dirs from disk // Could check inside the folder for a .mis file to confirm it's really an FM folder, but that's // horrendously expensive. Talking like eight seconds vs. < 4ms for the 1098 set. Weird. var perGameInstFMDirsList = new List <List <string> >(SupportedGameCount); var perGameInstFMDirsDatesList = new List <List <DateTime> >(SupportedGameCount); for (int gi = 0; gi < SupportedGameCount; gi++) { // NOTE! Make sure this list ends up with SupportedGameCount items in it. Just in case I change // the loop or something. perGameInstFMDirsList.Add(new List <string>()); perGameInstFMDirsDatesList.Add(new List <DateTime>()); string instPath = Config.GetFMInstallPath((GameIndex)gi); if (Directory.Exists(instPath)) { try { var dirs = FastIO.GetDirsTopOnly_FMs(instPath, "*", out List <DateTime> dateTimes); for (int di = 0; di < dirs.Count; di++) { string d = dirs[di]; if (!d.EqualsI(Paths.FMSelCache)) { perGameInstFMDirsList[gi].Add(d); perGameInstFMDirsDatesList[gi].Add(dateTimes[di]); } } } catch (Exception ex) { Log("Exception getting directories in " + instPath, ex); } } } #endregion #region Get archives from disk var fmArchivesAndDatesDict = new DictionaryI <DateTime>(); var archivePaths = FMArchives.GetFMArchivePaths(); bool onlyOnePath = archivePaths.Count == 1; for (int ai = 0; ai < archivePaths.Count; ai++) { try { // Returns filenames only (not full paths) var files = FastIO.GetFilesTopOnly_FMs(archivePaths[ai], "*", out List <DateTime> dateTimes); for (int fi = 0; fi < files.Count; fi++) { string f = files[fi]; // Do this first because it should be faster than a dictionary lookup if (!f.ExtIsArchive()) { continue; } // NOTE: We do a ContainsKey check to keep behavior the same as previously. When we use // dict[key] = value, it _replaces_ the value with the new one every time. What we want // is for it to just not touch it at all if the key is already in there. This check does // technically slow it down some, but the actual perf degradation is negligible. And we // still avoid the n-squared 1.6-million-call nightmare we get with ~1600 FMs in the list. // Nevertheless, we can avoid even this small extra cost if we only have one FM archive // path, so no harm in keeping the only-one-path check. if ((onlyOnePath || !fmArchivesAndDatesDict.ContainsKey(f)) && // @DIRSEP: These are filename only, no need for PathContainsI() !f.ContainsI(Paths.FMSelBak)) { fmArchivesAndDatesDict[f] = dateTimes[fi]; } } } catch (Exception ex) { Log("Exception getting files in " + archivePaths[ai], ex); } } int fmArchivesAndDatesDictLen = fmArchivesAndDatesDict.Count; // PERF_TODO: May want to keep these as dicts later or change other vars to dicts string[] fmArchives = new string[fmArchivesAndDatesDictLen]; DateTime[] fmArchivesDates = new DateTime[fmArchivesAndDatesDictLen]; { int i = 0; foreach (var item in fmArchivesAndDatesDict) { fmArchives[i] = item.Key; fmArchivesDates[i] = item.Value; i++; } } #endregion #region Build FanMission objects from installed dirs var perGameFMsList = new List <List <FanMission> >(SupportedGameCount); for (int gi = 0; gi < SupportedGameCount; gi++) { // NOTE! List must have SupportedGameCount items in it perGameFMsList.Add(new List <FanMission>()); for (int di = 0; di < perGameInstFMDirsList[gi].Count; di++) { perGameFMsList[gi].Add(new FanMission { InstalledDir = perGameInstFMDirsList[gi][di], Game = GameIndexToGame((GameIndex)gi), Installed = true }); } } #endregion MergeNewArchiveFMs(fmArchives, fmArchivesDates); int instInitCount = FMDataIniList.Count; for (int i = 0; i < SupportedGameCount; i++) { var curGameInstFMsList = perGameFMsList[i]; if (curGameInstFMsList.Count > 0) { MergeNewInstalledFMs(curGameInstFMsList, perGameInstFMDirsDatesList[i], instInitCount); } } SetArchiveNames(fmArchives); SetInstalledNames(); // Super quick-n-cheap hack for perf: So we don't have to iterate the whole list looking for unscanned // FMs. This will contain indexes into FMDataIniList (not FMsViewList!) var fmsViewListUnscanned = new List <int>(FMDataIniList.Count); BuildViewList(fmArchives, perGameInstFMDirsList, fmsViewListUnscanned); return(fmsViewListUnscanned); /* * TODO: There's an extreme corner case where duplicate FMs can appear in the list * It's so unlikely it's almost not worth worrying about, but here's the scenario: * -The FM is installed by hand and not truncated * -The FM is not in the list * -A matching archive exists for the FM * In this scenario, the FM is added twice to the list, once with the full installed folder name and * NoArchive set to true, and once with a truncated installed dir name, the correct archive name, and * NoArchive not present (false). * The code in here is so crazy-go-nuts I can't even find where this is happening. But putting this * note down for the future. */ }