From 4c6a248fa39f616841725521078ab2c0067cf47b Mon Sep 17 00:00:00 2001 From: initsu Date: Thu, 11 Jun 2026 17:10:19 +0200 Subject: [PATCH 1/2] Allow headerless and different header ROMs --- .../ViewModels/RomFileViewModel.cs | 50 ++++++++++++------- CrossPlatformUI/Views/RomFileView.axaml | 2 +- RandomizerCore/Hyrule.cs | 8 +-- RandomizerCore/ROM.cs | 32 ++++++++++++ 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/CrossPlatformUI/ViewModels/RomFileViewModel.cs b/CrossPlatformUI/ViewModels/RomFileViewModel.cs index 43811cf7e..dec251ba2 100644 --- a/CrossPlatformUI/ViewModels/RomFileViewModel.cs +++ b/CrossPlatformUI/ViewModels/RomFileViewModel.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using ReactiveUI; using SD.Tools.BCLExtensions.CollectionsRelated; +using Z2Randomizer.RandomizerCore; using CrossPlatformUI.Services; namespace CrossPlatformUI.ViewModels; @@ -25,6 +26,14 @@ public byte[] RomData } } + private string message { get; set; } = "Select your Zelda 2 ROM to get started!"; + [JsonIgnore] + public string Message + { + get => message; + set { message = value; this.RaisePropertyChanged(); } + } + [JsonIgnore] public IObservable RomDataObservable => this.WhenAnyValue(x => x.RomData); @@ -59,31 +68,36 @@ private async Task OpenFileInternal(CancellationToken token) if (fileprops.Size <= 1024 * 1024 * 1) { await using var readStream = await file.OpenReadAsync(); - var tmp = new byte[(uint)fileprops.Size]; - var read = await readStream.ReadAsync(tmp, token); - // TODO: Better validation - if (read == 1024 * 256 + 0x10) + byte[] fileData = new byte[(uint)fileprops.Size]; + var read = await readStream.ReadAsync(fileData, token); + try { - RomData = tmp; - if (OperatingSystem.IsBrowser()) - { - // Manually save the state - await App.PersistState(); - } - else + ROM.ValidateVanillaRom(ref fileData); + } + catch (UserFacingException e) + { + Message = e.Message; + return; + } + RomData = fileData; + if (OperatingSystem.IsBrowser()) + { + // Manually save the state + await App.PersistState(); + } + else + { + // This part crashes if run in the browser build + if ((Main.OutputFilePath ?? "") == "") { - // This part crashes if run in the browser build - if ((Main.OutputFilePath ?? "") == "") - { - Main.OutputFilePath = new Uri(file.Path, ".").LocalPath; - } + Main.OutputFilePath = new Uri(file.Path, ".").LocalPath; } - HostScreen.Router.NavigateBack.Execute(); } + HostScreen.Router.NavigateBack.Execute(); } else { - throw new Exception("File exceeded 1MB limit."); + Message = "File exceeded 1MB limit. Please provide an unmodified Zelda 2 ROM (US release) with or without header."; } } diff --git a/CrossPlatformUI/Views/RomFileView.axaml b/CrossPlatformUI/Views/RomFileView.axaml index c163659d5..98abed4bb 100644 --- a/CrossPlatformUI/Views/RomFileView.axaml +++ b/CrossPlatformUI/Views/RomFileView.axaml @@ -9,7 +9,7 @@ - + diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index cf4a04a8b..ab3e706ab 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -265,13 +265,7 @@ public async Task Randomize(byte[] vanillaRomData, RandomizerC reachableAreas = new HashSet(); //areasByLocation = new SortedDictionary>(); - byte[] correctVanillaHash = [0x76, 0x4D, 0x36, 0xFA, 0x8A, 0x24, 0x50, 0x83, 0x4D, 0xA5, 0xE8, 0x19, 0x42, 0x81, 0x03, 0x5A]; - var vanillaRomHash = MD5Hash.ComputeHash(vanillaRomData); - if (!correctVanillaHash.SequenceEqual(vanillaRomHash)) - { - throw new UserFacingException("Vanilla ROM checksum failure", "Please provide an unmodified Zelda 2 ROM (US release)."); - } - + ROM.ValidateVanillaRom(ref vanillaRomData); // Make a copy of the vanilla data to prevent seed bleed ROMData = new ROM(vanillaRomData.ToArray(), true); diff --git a/RandomizerCore/ROM.cs b/RandomizerCore/ROM.cs index ff3870003..4c345ea36 100644 --- a/RandomizerCore/ROM.cs +++ b/RandomizerCore/ROM.cs @@ -2649,4 +2649,36 @@ public void UpdateItem(Collectable item, Room room) logger.Warn($"Could not write Collectable {item} to Item room {room.GetDebuggerDisplay()} in palace {room.PalaceNumber}"); //throw new Exception("Could not write Collectable to Item room in palace " + PalaceNumber); } + + /// Validates the ROM. Tries to fix headerless or bad header ROMs. + public static void ValidateVanillaRom(ref byte[] vanillaRomData) + { + int correctVanillaLengthWithHeader = 1024 * 256 + 0x10; + byte[] iNesHeader = [0x4e, 0x45, 0x53, 0x1a, 0x08, 0x10, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // (gets overriden with ROM expansion) + byte[] correctVanillaHash = [0x76, 0x4D, 0x36, 0xFA, 0x8A, 0x24, 0x50, 0x83, 0x4D, 0xA5, 0xE8, 0x19, 0x42, 0x81, 0x03, 0x5A]; + + if (vanillaRomData.Length != correctVanillaLengthWithHeader) + { + if (vanillaRomData.Length == correctVanillaLengthWithHeader - 0x10) + { + vanillaRomData = [.. iNesHeader, .. vanillaRomData]; + } + else + { + throw new UserFacingException("Invalid ROM size", "Please provide an unmodified Zelda 2 ROM (US release) with or without header."); + } + } + else + { + for (int i = 0; i < 0x10; i++) + { + vanillaRomData[i] = iNesHeader[i]; + } + } + var vanillaRomHash = MD5Hash.ComputeHash(vanillaRomData); + if (!correctVanillaHash.SequenceEqual(vanillaRomHash)) + { + throw new UserFacingException("Vanilla ROM checksum failure", "Please provide an unmodified Zelda 2 ROM (US release)."); + } + } } From e87057c5528daca77304a8c509915e09ef0f9bf0 Mon Sep 17 00:00:00 2001 From: initsu Date: Fri, 12 Jun 2026 22:53:28 +0200 Subject: [PATCH 2/2] Ask for a new ROM output folder if ROM writing throws --- CrossPlatformUI/Services/FileDialogService.cs | 2 +- .../ViewModels/GenerateRomViewModel.cs | 25 ++++++++++++++++++- .../ViewModels/RandomizerViewModel.cs | 17 ++++++++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CrossPlatformUI/Services/FileDialogService.cs b/CrossPlatformUI/Services/FileDialogService.cs index 062173118..a0704866f 100644 --- a/CrossPlatformUI/Services/FileDialogService.cs +++ b/CrossPlatformUI/Services/FileDialogService.cs @@ -21,7 +21,7 @@ public class FileDialogService(TopLevel? target) : IFileDialogService { var files = await target!.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions() { - Title = "Open Folder", + Title = "Select ROM Output Folder", AllowMultiple = false }); diff --git a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs index b5b4fe56b..6106c28f7 100644 --- a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs +++ b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs @@ -105,7 +105,30 @@ async void GenerateSeed() { basename = filename; } - await files.SaveGeneratedBinaryFile(filename, output.romdata!, Main.OutputFilePath); + bool saved = false; + while (!saved) + { + try + { + await files.SaveGeneratedBinaryFile(filename, output.romdata!, Main.OutputFilePath); + saved = true; + } + catch (Exception e) + { + // DirectoryNotFoundException, UnauthorizedAccessException + // We could check for specific exceptions, but it's + // probably fine to do this for all errors while saving + var newFolder = await RandomizerViewModel.SelectSaveFolder(); + if (!string.IsNullOrEmpty(newFolder) && newFolder != Main.OutputFilePath) + { + Main.OutputFilePath = newFolder; + } + else // user did not pick a new save folder + { + throw new UserFacingException("Unable to save ROM to folder", e.Message); + } + } + } #if DEBUG var debugfile = basename + ".mlb"; if (!string.IsNullOrEmpty(output.debuginfo)) diff --git a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs index 17c42d728..8cf82394a 100644 --- a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs +++ b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs @@ -7,6 +7,7 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using System.Text.Json.Serialization; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Avalonia.Controls; using ReactiveUI; @@ -117,9 +118,7 @@ public RandomizerViewModel(MainViewModel main) SaveFolder = ReactiveCommand.CreateFromTask(async () => { - var fileDialog = App.Current?.Services?.GetService()!; - var folder = await fileDialog.OpenFolderAsync(); - Main.OutputFilePath = folder?.Path.LocalPath ?? ""; + Main.OutputFilePath = await SelectSaveFolder() ?? ""; }); CheckForUpdates = ReactiveCommand.CreateFromTask(async () => @@ -203,6 +202,18 @@ public RandomizerViewModel(MainViewModel main) this.WhenActivated(OnActivate); } + public static async Task SelectSaveFolder() + { + var fileDialog = App.Current?.Services?.GetService()!; + var folder = await fileDialog.OpenFolderAsync(); + Uri? path = folder?.Path; + // both LocalPath and TryGetLocalPath() throw for non-absoloute URIs + string? localPath = path?.IsAbsoluteUri == true + ? path.LocalPath + : path?.OriginalString; + return localPath; + } + private void OnActivate(CompositeDisposable disposables) { var loadedFlags = Main.Config.SerializeFlags(); // this serializes the configuration