using System.IO.Compression; using System.Linq; using Content.Client.Message; using Content.Client.Replay; using Content.Client.UserInterface.Systems.EscapeMenu; using Robust.Client; using Robust.Client.Replays.Loading; using Robust.Client.ResourceManagement; using Robust.Client.Serialization; using Robust.Client.State; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.ContentPack; using Robust.Shared.Serialization.Markdown.Value; using Robust.Shared.Utility; using static Robust.Shared.Replays.ReplayConstants; namespace Content.Replay.Menu; /// /// Main menu screen for selecting and loading replays. /// public sealed class ReplayMainScreen : State { [Dependency] private readonly IResourceManager _resMan = default!; [Dependency] private readonly IComponentFactory _factory = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IReplayLoadManager _loadMan = default!; [Dependency] private readonly IResourceCache _resourceCache = default!; [Dependency] private readonly IGameController _controllerProxy = default!; [Dependency] private readonly IClientRobustSerializer _serializer = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; [Dependency] private readonly ContentReplayPlaybackManager _replayMan = default!; private ReplayMainMenuControl _mainMenuControl = default!; private SelectReplayWindow? _selectWindow; private ResPath _directory; private List<(string Name, ResPath Path)> _replays = new(); private ResPath? _selected; protected override void Startup() { _mainMenuControl = new(_resourceCache); _userInterfaceManager.StateRoot.AddChild(_mainMenuControl); _mainMenuControl.SelectButton.OnPressed += OnSelectPressed; _mainMenuControl.QuitButton.OnPressed += QuitButtonPressed; _mainMenuControl.OptionsButton.OnPressed += OptionsButtonPressed; _mainMenuControl.FolderButton.OnPressed += OnFolderPressed; _mainMenuControl.LoadButton.OnPressed += OnLoadPressed; _directory = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); RefreshReplays(); SelectReplay(_replays.FirstOrNull()?.Path); if (_selected == null) // force initial update UpdateSelectedInfo(); } /// /// Read replay meta-data and update the replay info box. /// private void UpdateSelectedInfo() { var info = _mainMenuControl.Info; if (_selected is not { } replay) { info.SetMarkup(Loc.GetString("replay-info-none-selected")); info.HorizontalAlignment = Control.HAlignment.Center; info.VerticalAlignment = Control.VAlignment.Center; _mainMenuControl.LoadButton.Disabled = true; return; } using var fileReader = new ReplayFileReaderZip( new ZipArchive(_resMan.UserData.OpenRead(replay)), ReplayZipFolder); if (!_resMan.UserData.Exists(replay) || _loadMan.LoadYamlMetadata(fileReader) is not { } data) { info.SetMarkup(Loc.GetString("replay-info-invalid")); info.HorizontalAlignment = Control.HAlignment.Center; info.VerticalAlignment = Control.VAlignment.Center; _mainMenuControl.LoadButton.Disabled = true; return; } var file = replay.ToRelativePath().ToString(); data.TryGet(MetaKeyTime, out var timeNode); data.TryGet(MetaFinalKeyDuration, out var durationNode); data.TryGet("roundId", out var roundIdNode); data.TryGet(MetaKeyTypeHash, out var hashNode); data.TryGet(MetaKeyComponentHash, out var compHashNode); DateTime.TryParse(timeNode?.Value, out var time); TimeSpan.TryParse(durationNode?.Value, out var duration); var forkId = string.Empty; if (data.TryGet(MetaKeyForkId, out var forkNode)) { // TODO Replay client build info. // When distributing the client we need to distribute a build.json or provide these cvars some other way? var clientFork = _cfg.GetCVar(CVars.BuildForkId); if (string.IsNullOrWhiteSpace(clientFork)) forkId = forkNode.Value; else if (forkNode.Value == clientFork) forkId = $"[color=green]{forkNode.Value}[/color]"; else forkId = $"[color=yellow]{forkNode.Value}[/color]"; } var forkVersion = string.Empty; if (data.TryGet(MetaKeyForkVersion, out var versionNode)) { forkVersion = versionNode.Value; // Why does this not have a try-convert function? I just want to check if it looks like a hash code. try { Convert.FromHexString(forkVersion); // version is a probably some git commit. Crop it to keep the info box small. forkVersion = forkVersion[..16]; } catch { // ignored } // TODO REPLAYS somehow distribute and load from build.json? var clientVer = _cfg.GetCVar(CVars.BuildVersion); if (!string.IsNullOrWhiteSpace(clientVer)) { if (versionNode.Value == clientVer) forkVersion = $"[color=green]{forkVersion}[/color]"; else forkVersion = $"[color=yellow]{forkVersion}[/color]"; } } if (hashNode == null) throw new Exception("Invalid metadata file. Missing type hash"); var typeHash = hashNode.Value; _mainMenuControl.LoadButton.Disabled = false; if (Convert.FromHexString(typeHash).SequenceEqual(_serializer.GetSerializableTypesHash())) { typeHash = $"[color=green]{typeHash[..16]}[/color]"; } else { typeHash = $"[color=red]{typeHash[..16]}[/color]"; _mainMenuControl.LoadButton.Disabled = true; } if (compHashNode == null) throw new Exception("Invalid metadata file. Missing component hash"); var compHash = compHashNode.Value; if (Convert.FromHexString(compHash).SequenceEqual(_factory.GetHash(true))) { compHash = $"[color=green]{compHash[..16]}[/color]"; _mainMenuControl.LoadButton.Disabled = false; } else { compHash = $"[color=red]{compHash[..16]}[/color]"; _mainMenuControl.LoadButton.Disabled = true; } var engineVersion = string.Empty; if (data.TryGet(MetaKeyEngineVersion, out var engineNode)) { var clientVer = _cfg.GetCVar(CVars.BuildEngineVersion); if (string.IsNullOrWhiteSpace(clientVer)) engineVersion = engineNode.Value; else if (engineNode.Value == clientVer) engineVersion = $"[color=green]{engineNode.Value}[/color]"; else engineVersion = $"[color=yellow]{engineNode.Value}[/color]"; } // Strip milliseconds. Apparently there is no general format string that suppresses milliseconds. duration = new((int)Math.Floor(duration.TotalDays), duration.Hours, duration.Minutes, duration.Seconds); data.TryGet(MetaKeyName, out var nameNode); var name = nameNode?.Value ?? string.Empty; info.HorizontalAlignment = Control.HAlignment.Left; info.VerticalAlignment = Control.VAlignment.Top; info.SetMarkup(Loc.GetString( "replay-info-info", ("file", file), ("name", name), ("time", time), ("roundId", roundIdNode?.Value ?? "???"), ("duration", duration), ("forkId", forkId), ("version", forkVersion), ("engVersion", engineVersion), ("compHash", compHash), ("hash", typeHash))); } private void OnFolderPressed(BaseButton.ButtonEventArgs obj) { _resMan.UserData.CreateDir(_directory); _resMan.UserData.OpenOsWindow(_directory); } private void OnLoadPressed(BaseButton.ButtonEventArgs obj) { if (!_selected.HasValue) return; _replayMan.LastLoad = (_selected.Value, ReplayZipFolder); var fileReader = new ReplayFileReaderZip( new ZipArchive(_resMan.UserData.OpenRead(_selected.Value)), ReplayZipFolder); _loadMan.LoadAndStartReplay(fileReader); } private void RefreshReplays() { _replays.Clear(); foreach (var entry in _resMan.UserData.DirectoryEntries(_directory)) { var file = _directory / entry; try { using var fileReader = new ReplayFileReaderZip( new ZipArchive(_resMan.UserData.OpenRead(file)), ReplayZipFolder); var data = _loadMan.LoadYamlMetadata(fileReader); if (data == null) continue; var name = data.Get(MetaKeyName).Value; _replays.Add((name, file)); } catch { // Ignore file } } _selectWindow?.Repopulate(_replays); if (_selected.HasValue && _replays.All(x => x.Path != _selected.Value)) SelectReplay(null); else _selectWindow?.UpdateSelected(_selected); } public void SelectReplay(ResPath? replay) { if (_selected == replay) return; _selected = replay; try { UpdateSelectedInfo(); } catch (Exception ex) { Logger.Error($"Failed to load replay info. Exception: {ex}"); SelectReplay(null); return; } _selectWindow?.UpdateSelected(replay); } protected override void Shutdown() { _mainMenuControl.Dispose(); _selectWindow?.Dispose(); } private void OptionsButtonPressed(BaseButton.ButtonEventArgs args) { _userInterfaceManager.GetUIController().ToggleWindow(); } private void QuitButtonPressed(BaseButton.ButtonEventArgs args) { _controllerProxy.Shutdown(); } private void OnSelectPressed(BaseButton.ButtonEventArgs args) { RefreshReplays(); _selectWindow ??= new(this); _selectWindow.Repopulate(_replays); _selectWindow.UpdateSelected(_selected); _selectWindow.OpenCentered(); } }