From 5eed6b4670e03429ff65dd8cf1d7100f311dcac9 Mon Sep 17 00:00:00 2001 From: initsu Date: Thu, 18 Dec 2025 17:23:13 +0100 Subject: [PATCH 01/10] Update dependency versions --- .../CoreSourceGenerator.csproj | 2 +- CrossPlatformUI/CrossPlatformUI.csproj | 19 ++++++++----------- Directory.Build.props | 6 +++--- RandomizerCore/RandomizerCore.csproj | 2 +- Statistics/Statistics.csproj | 4 ++-- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/CoreSourceGenerator/CoreSourceGenerator.csproj b/CoreSourceGenerator/CoreSourceGenerator.csproj index 9295af99..e654d119 100644 --- a/CoreSourceGenerator/CoreSourceGenerator.csproj +++ b/CoreSourceGenerator/CoreSourceGenerator.csproj @@ -25,7 +25,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CrossPlatformUI/CrossPlatformUI.csproj b/CrossPlatformUI/CrossPlatformUI.csproj index d81fadad..5f4782a1 100644 --- a/CrossPlatformUI/CrossPlatformUI.csproj +++ b/CrossPlatformUI/CrossPlatformUI.csproj @@ -36,13 +36,13 @@ - + - + all - + @@ -51,10 +51,10 @@ - - - - + + + + @@ -84,10 +84,7 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index bffd7e2b..4d62d261 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,9 +2,9 @@ net10.0 enable - 11.3.8 - 0.9.3 - 3.13.3 + 11.3.11 + 0.10.4 + 3.13.4 2.4.1 2.88.9 true diff --git a/RandomizerCore/RandomizerCore.csproj b/RandomizerCore/RandomizerCore.csproj index 13bf98b2..9f6c24bf 100644 --- a/RandomizerCore/RandomizerCore.csproj +++ b/RandomizerCore/RandomizerCore.csproj @@ -47,7 +47,7 @@ - + diff --git a/Statistics/Statistics.csproj b/Statistics/Statistics.csproj index d4ea5711..3d109bf6 100644 --- a/Statistics/Statistics.csproj +++ b/Statistics/Statistics.csproj @@ -22,8 +22,8 @@ - - + + From fc494b11d6e1635ab0aae4587c13082d577b7b2e Mon Sep 17 00:00:00 2001 From: initsu Date: Tue, 30 Dec 2025 20:32:36 +0100 Subject: [PATCH 02/10] Separate 5.0 and 5.1 settings and improve custom preset handling - Save 5.1 settings as Settings_v5_1.json instead of Settings.json. - Save the original custom preset JSONs strings instead of always converting them to objects. This helps maintaining presets when loading up a different version of the rando where some options have been changed. (The custom presets were being deserialized and then serialized even if you never touched them, potentially removing unrecognized options.) - Make the JSON serialization even safer --- .../LocalFilePersistenceService.cs | 12 +- CrossPlatformUI/App.axaml.cs | 127 ++++++++++++------ .../ViewModels/RandomizerViewModel.cs | 9 +- .../ViewModels/SaveNewPresetViewModel.cs | 63 +++++++-- 4 files changed, 139 insertions(+), 72 deletions(-) diff --git a/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs b/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs index bda9c2d7..cbf172c8 100644 --- a/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs +++ b/CrossPlatformUI.Desktop/LocalFilePersistenceService.cs @@ -13,8 +13,6 @@ namespace CrossPlatformUI.Desktop; [RequiresUnreferencedCode("Newtonsoft.Json uses reflection")] public class LocalFilePersistenceService : ISuspendSyncService // : ISuspensionDriver { - // TODO put this in appdata - public const string SettingsFilename = "Settings.json"; public string? SettingsPath; public LocalFilePersistenceService() @@ -36,7 +34,7 @@ public LocalFilePersistenceService() public object? LoadState() { - var data = File.ReadAllText(SettingsFilename); + var data = File.ReadAllText(App.SETTINGS_FILENAME); return JsonConvert.DeserializeObject(data, serializerSettings); } @@ -46,7 +44,7 @@ public void SaveState(object state) var next = JObject.Parse(json); try { - var settings = File.ReadAllText(SettingsFilename); + var settings = File.ReadAllText(App.SETTINGS_FILENAME); var orig = JObject.Parse(settings); orig.Merge(next, new JsonMergeSettings { @@ -57,7 +55,7 @@ public void SaveState(object state) } catch (Exception e) when (e is JsonException or IOException) { } - using var file = File.CreateText(SettingsFilename); + using var file = File.CreateText(App.SETTINGS_FILENAME); using var writer = new JsonTextWriter(file); next.WriteTo(writer); } @@ -66,7 +64,7 @@ public void InvalidateState() { try { - File.Delete(SettingsFilename); + File.Delete(App.SETTINGS_FILENAME); } catch (IOException) {} } @@ -74,4 +72,4 @@ public Task> ListLocalFiles(string path) { return Task.FromResult(Directory.GetFiles(path).AsEnumerable()); } -} \ No newline at end of file +} diff --git a/CrossPlatformUI/App.axaml.cs b/CrossPlatformUI/App.axaml.cs index a85d863a..8a1f524c 100644 --- a/CrossPlatformUI/App.axaml.cs +++ b/CrossPlatformUI/App.axaml.cs @@ -13,6 +13,7 @@ using Avalonia.Markup.Xaml; using Material.Styles.Assists; using Microsoft.Extensions.DependencyInjection; +using Z2Randomizer.RandomizerCore; using CrossPlatformUI.Services; using CrossPlatformUI.ViewModels; using CrossPlatformUI.Views; @@ -57,6 +58,7 @@ public override void Initialize() public static string Version = ""; public static string Title = ""; + public const string SETTINGS_FILENAME = "Settings_v5_1.json"; public static TopLevel? TopLevel { get; private set; } @@ -73,7 +75,7 @@ public override void OnFrameworkInitializationCompleted() var files = FileSystemService!; try { - var json = files.OpenFileSync(IFileSystemService.RandomizerPath.Settings, "Settings.json"); + var json = files.OpenFileSync(IFileSystemService.RandomizerPath.Settings, SETTINGS_FILENAME); main = JsonSerializer.Deserialize(json, new SerializationContext(true).MainViewModel)!; } catch (System.IO.FileNotFoundException) { /* No settings file exists */ } @@ -153,7 +155,7 @@ private Task PersistStateInternal() { var files = Current?.Services?.GetService()!; var json = JsonSerializer.Serialize(main!, SerializationContext.Default.MainViewModel); - await files.SaveFile(IFileSystemService.RandomizerPath.Settings, "Settings.json", json); + await files.SaveFile(IFileSystemService.RandomizerPath.Settings, SETTINGS_FILENAME, json); }); } @@ -173,82 +175,119 @@ private Task PersistStateInternal() [JsonSourceGenerationOptions( WriteIndented = false, IgnoreReadOnlyProperties = true, - UseStringEnumConverter = true, PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate )] [JsonSerializable(typeof(MainViewModel))] +[JsonSerializable(typeof(RandomizerConfiguration))] public partial class SerializationContext : JsonSerializerContext { public SerializationContext(bool _) // added an argument to avoid constructors colliding - : base(CreateOptions()) + : base(InitSafeOptions()) { } - private static JsonSerializerOptions CreateOptions() + /// modifies original context - can't be called after init + private static JsonSerializerOptions InitSafeOptions() { var options = Default.GeneratedSerializerOptions!; + options.Converters.Add(new SafeStringEnumConverterFactory()); + return options; + } + + /// returns a new options copy with safe serialization + public static JsonSerializerOptions CreateSafeOptions() + { + var options = new JsonSerializerOptions + { + TypeInfoResolver = SerializationContext.Default + }; + options.Converters.Add(new SafeStringEnumConverterFactory()); + return options; + } +} - var enumNamespacePrefix = "Z2Randomizer"; +public sealed class SafeStringEnumConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsEnum; - foreach (var enumType in AppDomain.CurrentDomain.GetAssemblies() - .Where(a => !a.IsDynamic) - .SelectMany(a => - { -#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code - try { return a.GetTypes(); } -#pragma warning restore IL2026 - catch { return Enumerable.Empty(); } - }) - .Where(t => - t.IsEnum && - t.IsPublic && - !t.IsGenericTypeDefinition && - t.Namespace != null && t.Namespace.StartsWith(enumNamespacePrefix) - )) + [RequiresUnreferencedCode("Uses reflection to construct generic enum converters at runtime.")] + [SuppressMessage("Trimming", "IL2046:'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = " Justification = \"Enum converters are created dynamically; enum metadata is preserved.\")]")] + public override JsonConverter CreateConverter( + Type typeToConvert, + JsonSerializerOptions options) + { + var converterType = typeof(SafeStringEnumConverter<>) + .MakeGenericType(typeToConvert); + + return (JsonConverter)Activator.CreateInstance(converterType)!; + } +} + +/// Custom StringEnumConverter that returns default instead of failing for unknown values +public sealed class SafeStringEnumConverter : JsonConverter where T : struct, Enum +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + try { - try + if (reader.TokenType == JsonTokenType.String) { -#pragma warning disable IL2076 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. - var converterType = typeof(SafeStringEnumConverter<>).MakeGenericType(enumType); -#pragma warning restore IL2076 - var converter = (JsonConverter)Activator.CreateInstance(converterType)!; - options.Converters.Add(converter); + var value = reader.GetString(); + + if (!string.IsNullOrEmpty(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } } - catch + else if (reader.TokenType == JsonTokenType.Number) { - // we try our best to add the custom parsing, but proceed regardless + if (reader.TryGetInt32(out var raw) && Enum.IsDefined(typeof(T), raw)) + { + return (T)Enum.ToObject(typeof(T), raw); + } } } + catch // we prefer returning the default over failing + { + } - return options; + return default; } -} -/// Custom StringEnumConverter that returns default instead of failing for unknown values -public class SafeStringEnumConverter : JsonConverter where T : struct, Enum -{ - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + + public override T ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) { - if (reader.TokenType == JsonTokenType.String) + try { var value = reader.GetString(); - if (Enum.TryParse(value, ignoreCase: true, out var result)) + + if (!string.IsNullOrEmpty(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) { - return result; + return parsed; } } - else if (reader.TokenType == JsonTokenType.Number && - reader.TryGetInt32(out var intValue) && - Enum.IsDefined(typeof(T), intValue)) + catch // we prefer returning the default over failing { - return (T)Enum.ToObject(typeof(T), intValue); } return default; } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options) { - writer.WriteStringValue(value.ToString()); + writer.WritePropertyName(value.ToString()); } } diff --git a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs index 5b6bd281..fb2cbd0c 100644 --- a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs +++ b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs @@ -166,14 +166,7 @@ public RandomizerViewModel(MainViewModel main) }); SaveAsPreset = ReactiveCommand.Create((string name) => { - var updatedPreset = new CustomPreset - { - Preset = name, - Config = - { - Flags = Main.Config.Flags - } - }; + var updatedPreset = new CustomPreset(name, new RandomizerConfiguration { Flags = Main.Config.Flags }); var collection = Main.SaveNewPresetViewModel.SavedPresets; // makeshift FindIndex since ObservableCollection doesn't have one int presetIndex = -1; diff --git a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs index dd0183af..9e3a8f2f 100644 --- a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs +++ b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs @@ -4,6 +4,7 @@ using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Text.Json; using System.Text.Json.Serialization; using DynamicData.Binding; using ReactiveUI; @@ -12,14 +13,57 @@ namespace CrossPlatformUI.ViewModels; - - public class CustomPreset : ReactiveObject { + /// name of preset private string preset = ""; public string Preset { get => preset; set => this.RaiseAndSetIfChanged(ref preset, value); } - private RandomizerConfiguration config = new (); - public RandomizerConfiguration Config { get => config; set => this.RaiseAndSetIfChanged(ref config, value); } + + private RandomizerConfiguration? config; + + // presets might be created in different versions with options that + // are not known to this version. keep the original raw preset JSON + // to avoid changing presets unexpectedly. + [JsonPropertyName("Config")] + public JsonElement? RawConfig { get; set; } + [JsonIgnore] + public RandomizerConfiguration Config + { + get + { + if (config == null) + { + if (RawConfig.HasValue) + { + // Deserialize with safe options to handle modified Enums affecting presets + var parsed = JsonSerializer.Deserialize(RawConfig.Value, SerializationContext.CreateSafeOptions()); + if (parsed != null) + { + config = parsed; + return config; + } + } + config = new RandomizerConfiguration(); + } + return config; + } + set + { + RawConfig = JsonSerializer.SerializeToElement(value, SerializationContext.Default.RandomizerConfiguration); + this.RaiseAndSetIfChanged(ref config, value); + } + } + + /// empty constructor for serialization only + public CustomPreset() + { + } + + public CustomPreset(string preset, RandomizerConfiguration config) + { + Preset = preset; + Config = config; + } } [RequiresUnreferencedCode("ReactiveUI uses reflection")] @@ -40,14 +84,7 @@ public SaveNewPresetViewModel(MainViewModel main) SavePreset = ReactiveCommand.Create(() => { Main.SaveNewPresetDialogOpen = false; // Setting the preset config through the flags creates a deep clone instead of a reference - var preset = new CustomPreset - { - Preset = PresetName, - Config = - { - Flags = Main.Config.Flags - } - }; + var preset = new CustomPreset(PresetName, new RandomizerConfiguration { Flags = Main.Config.Flags }); SavedPresets.Add(preset); }); CancelPreset = ReactiveCommand.Create(() => @@ -91,4 +128,4 @@ public SaveNewPresetViewModel(MainViewModel main) public IScreen HostScreen { get; } [JsonIgnore] public ViewModelActivator Activator { get; } -} \ No newline at end of file +} From efb2eaa56544911175947d1aaac706f769f5f285 Mon Sep 17 00:00:00 2001 From: initsu Date: Sat, 15 Nov 2025 04:58:52 +0100 Subject: [PATCH 03/10] Create dropdown option for boss HP randomization with 4 values --- CommandLine/Sample.json | 1 + CrossPlatformUI/Lang/Resources.resx | 5 +++ CrossPlatformUI/Presets/FullShufflePreset.cs | 1 + CrossPlatformUI/Presets/HardmodePreset.cs | 1 + CrossPlatformUI/Presets/MaxRandoPreset.cs | 1 + CrossPlatformUI/Presets/NormalPreset.cs | 1 + .../Presets/RandomPercentPreset.cs | 1 + CrossPlatformUI/Presets/StandardPreset.cs | 1 + .../Presets/StandardSwissPreset.cs | 1 + .../Presets/UpstartsTournamentPreset.cs | 1 + CrossPlatformUI/Views/Tabs/EnemiesView.axaml | 12 ++++++- RandomizerCore/EnumTypes.cs | 13 ++++++++ RandomizerCore/Hyrule.cs | 2 +- RandomizerCore/RandomizerConfiguration.cs | 4 +++ RandomizerCore/RandomizerProperties.cs | 1 + RandomizerCore/StatRandomizer.cs | 31 +++++++++++++++---- 16 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CommandLine/Sample.json b/CommandLine/Sample.json index 0887e5cb..d6327e65 100644 --- a/CommandLine/Sample.json +++ b/CommandLine/Sample.json @@ -103,6 +103,7 @@ "ShuffleDripperEnemy": false, "ShuffleEncounters": false, "ShuffleEnemyHP": false, +"ShuffleBossHP": "VANILLA", "ShuffleGP": false, "ShuffleItemDropFrequency": true, "ShuffleLifeExperience": false, diff --git a/CrossPlatformUI/Lang/Resources.resx b/CrossPlatformUI/Lang/Resources.resx index 2da72535..7b6c94ac 100644 --- a/CrossPlatformUI/Lang/Resources.resx +++ b/CrossPlatformUI/Lang/Resources.resx @@ -217,6 +217,11 @@ taking damage. Play at your own risk. If selected, each enemy type will have between 50% and 150% of its vanilla HP value. + + +Choose how boss HP is randomized. HP will always be capped at 255. + +Great Palace bosses are not included. Randomly reassigns which enemies steal EXP. The number of enemies that steal EXP in each diff --git a/CrossPlatformUI/Presets/FullShufflePreset.cs b/CrossPlatformUI/Presets/FullShufflePreset.cs index c6eb9b6d..4a3e4bf8 100644 --- a/CrossPlatformUI/Presets/FullShufflePreset.cs +++ b/CrossPlatformUI/Presets/FullShufflePreset.cs @@ -95,6 +95,7 @@ public static class FullShufflePreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/HardmodePreset.cs b/CrossPlatformUI/Presets/HardmodePreset.cs index 349d1d09..b7474894 100644 --- a/CrossPlatformUI/Presets/HardmodePreset.cs +++ b/CrossPlatformUI/Presets/HardmodePreset.cs @@ -95,6 +95,7 @@ public static class HardmodePreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.VANILLA, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/MaxRandoPreset.cs b/CrossPlatformUI/Presets/MaxRandoPreset.cs index 6e4212ad..a2488c60 100644 --- a/CrossPlatformUI/Presets/MaxRandoPreset.cs +++ b/CrossPlatformUI/Presets/MaxRandoPreset.cs @@ -95,6 +95,7 @@ public static class MaxRandoPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/NormalPreset.cs b/CrossPlatformUI/Presets/NormalPreset.cs index a0d09c92..e0f74f61 100644 --- a/CrossPlatformUI/Presets/NormalPreset.cs +++ b/CrossPlatformUI/Presets/NormalPreset.cs @@ -95,6 +95,7 @@ public static class NormalPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/RandomPercentPreset.cs b/CrossPlatformUI/Presets/RandomPercentPreset.cs index 9a1844a0..900976a7 100644 --- a/CrossPlatformUI/Presets/RandomPercentPreset.cs +++ b/CrossPlatformUI/Presets/RandomPercentPreset.cs @@ -95,6 +95,7 @@ public static class RandomPercentPreset GeneratorsAlwaysMatch = false, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/StandardPreset.cs b/CrossPlatformUI/Presets/StandardPreset.cs index 593c7338..08a44554 100644 --- a/CrossPlatformUI/Presets/StandardPreset.cs +++ b/CrossPlatformUI/Presets/StandardPreset.cs @@ -95,6 +95,7 @@ public static class StandardPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/StandardSwissPreset.cs b/CrossPlatformUI/Presets/StandardSwissPreset.cs index f3db3815..b9318a5b 100644 --- a/CrossPlatformUI/Presets/StandardSwissPreset.cs +++ b/CrossPlatformUI/Presets/StandardSwissPreset.cs @@ -95,6 +95,7 @@ public static class StandardSwissPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs b/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs index 48ce8b8d..676da277 100644 --- a/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs +++ b/CrossPlatformUI/Presets/UpstartsTournamentPreset.cs @@ -89,6 +89,7 @@ public static class UpstartsTournamentPreset GeneratorsAlwaysMatch = true, ShuffleEnemyHP = true, + ShuffleBossHP = EnemyLifeOption.MEDIUM, ShuffleXPStealers = true, ShuffleXPStolenAmount = true, ShuffleSwordImmunity = true, diff --git a/CrossPlatformUI/Views/Tabs/EnemiesView.axaml b/CrossPlatformUI/Views/Tabs/EnemiesView.axaml index c6f5e390..d817432e 100644 --- a/CrossPlatformUI/Views/Tabs/EnemiesView.axaml +++ b/CrossPlatformUI/Views/Tabs/EnemiesView.axaml @@ -74,10 +74,20 @@ + + + + + LifeEffectivenessList { get; } = ToDescriptions(); public static IEnumerable XPEffectivenessList { get; } = ToDescriptions(); public static IEnumerable DripperEnemyOptionList { get; } = ToDescriptions(); + public static IEnumerable EnemyLifeOptionList { get; } = ToDescriptions(); public static IEnumerable FireOptionList { get; } = ToDescriptions(); public static IEnumerable BossRoomMinDistanceOptions { get; } = ToDescriptions(); public static IEnumerable PalaceItemRoomCountOptions { get; } = ToDescriptions(); diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 8eebb733..2e4043ef 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -3964,7 +3964,7 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando rom.FixBossKillPaletteGlitch(engine); StatTracking(engine); - if (props.ShuffleEnemyHP) + if (props.ShuffleBossHP != EnemyLifeOption.VANILLA) { rom.SetBossHpBarDivisors(engine, randomizedStats); } diff --git a/RandomizerCore/RandomizerConfiguration.cs b/RandomizerCore/RandomizerConfiguration.cs index c2db1555..bac11961 100644 --- a/RandomizerCore/RandomizerConfiguration.cs +++ b/RandomizerCore/RandomizerConfiguration.cs @@ -363,6 +363,9 @@ public sealed partial class RandomizerConfiguration : INotifyPropertyChanged [Reactive] private bool shuffleEnemyHP; + [Reactive] + private EnemyLifeOption shuffleBossHP; + [Reactive] private bool shuffleXPStealers; @@ -1200,6 +1203,7 @@ public RandomizerProperties Export(Random r) //Enemies properties.ShuffleEnemyHP = shuffleEnemyHP; + properties.ShuffleBossHP = shuffleBossHP; properties.ShuffleEnemyStealExp = shuffleXPStealers; properties.ShuffleStealExpAmt = shuffleXPStolenAmount; properties.ShuffleSwordImmunity = shuffleSwordImmunity; diff --git a/RandomizerCore/RandomizerProperties.cs b/RandomizerCore/RandomizerProperties.cs index 4b065a1f..0f87dc24 100644 --- a/RandomizerCore/RandomizerProperties.cs +++ b/RandomizerCore/RandomizerProperties.cs @@ -128,6 +128,7 @@ public class RandomizerProperties //Enemies public bool ShuffleEnemyHP { get; set; } + public EnemyLifeOption ShuffleBossHP { get; set; } public bool ShuffleEnemyStealExp { get; set; } public bool ShuffleStealExpAmt { get; set; } public bool ShuffleSwordImmunity { get; set; } diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index 5d2ced92..35065e27 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -43,6 +43,15 @@ public void Randomize(Random r) { RandomizeHp(r); } + switch (props.ShuffleBossHP) + { + case EnemyLifeOption.RANDOM: + for (int i = 0; i < BossHpTable.Length; i++) { RollHpValue(r, BossHpTable, i); } + break; + case EnemyLifeOption.RANDOM_HIGH: + for (int i = 0; i < BossHpTable.Length; i++) { RollHpValue(r, BossHpTable, i, 1.0, 2.0); } + break; + } } public void Write(ROM rom) @@ -95,14 +104,24 @@ protected void RandomizeHp(Random r) for (i = (int)EnemiesGreatPalace.RED_DEELER; i < (int)EnemiesGreatPalace.ELEVATOR; i++) { RollHpValue(r, GpEnemyHpTable, i); } for (i = (int)EnemiesGreatPalace.SLOW_BUBBLE; i < 0x1b; i++) { RollHpValue(r, GpEnemyHpTable, i); } for (i = (int)EnemiesGreatPalace.FOKKERU; i < (int)EnemiesGreatPalace.KING_BOT; i++) { RollHpValue(r, GpEnemyHpTable, i); } - - for (i = 0; i < BossHpTable.Length; i++) { RollHpValue(r, BossHpTable, i); } } - protected void RollHpValue(Random r, byte[] array, int index) + protected void RollHpValue(Random r, byte[] array, int index, double lower=0.5, double upper=1.5) { - var vanillaHp = array[index]; - var newHp = (byte)Math.Min(r.Next((int)(vanillaHp * 0.5), (int)(vanillaHp * 1.5)), 255); - array[index] = newHp; + switch (props.ShuffleBossHP) + { + case EnemyLifeOption.VANILLA: + break; + case EnemyLifeOption.MEDIUM: + for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 0.5, 1.5); } + break; + case EnemyLifeOption.HIGH: + for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 1.0, 2.0); } + break; + case EnemyLifeOption.FULL_RANGE: + for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 0.5, 2.0); } + break; + default: + throw new NotImplementedException("Invalid ShuffleBossHP value"); } } From 6aca1b7c9313b71a18302890c1218d1dae475973 Mon Sep 17 00:00:00 2001 From: initsu Date: Fri, 9 Jan 2026 16:42:41 +0100 Subject: [PATCH 04/10] Refactor: Move stat effectiveness logic to StatRandomizer --- RandomizerCore/Hyrule.cs | 234 -------------------- RandomizerCore/ROM.cs | 25 +-- RandomizerCore/RomMap.cs | 4 + RandomizerCore/StatRandomizer.cs | 362 +++++++++++++++++++++++++++---- 4 files changed, 331 insertions(+), 294 deletions(-) diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 2e4043ef..3c26a024 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -576,90 +576,6 @@ Spell 93 25 Thunder 96 36 */ - - private void RandomizeAttackEffectiveness(ROM rom, AttackEffectiveness attackEffectiveness) - { - byte[] attackValues = rom.GetBytes(0x1E67D, 8); - byte[] newAttackValueBytes = RandomizeAttackEffectiveness(r, attackValues, attackEffectiveness); - rom.Put(0x1E67D, newAttackValueBytes); - } - - private static byte[] RandomizeAttackEffectiveness(Random RNG, byte[] attackValues, AttackEffectiveness attackEffectiveness) - { - if (attackEffectiveness == AttackEffectiveness.VANILLA) - { - return attackValues; - } - if (attackEffectiveness == AttackEffectiveness.OHKO) - { - // handled in RandomizeEnemyStats() - return attackValues; - } - - int RandomInRange(double minVal, double maxVal) - { - int nextVal = (int)Math.Round(RNG.NextDouble() * (maxVal - minVal) + minVal); - nextVal = (int)Math.Min(nextVal, maxVal); - nextVal = (int)Math.Max(nextVal, minVal); - return nextVal; - } - - byte[] newAttackValues = new byte[8]; - for (int i = 0; i < 8; i++) - { - int nextVal; - byte vanilla = attackValues[i]; - switch (attackEffectiveness) - { - case AttackEffectiveness.LOW: - //the naieve approach here gives a curve of 1,2,2,4,5,6 which is weird, or a different - //irregular curve in digshake's old approach. Just use a linear increase for the first 6 levels on low - if(i < 6) - { - nextVal = i + 1; - } - else - { - nextVal = (int)Math.Round(attackValues[i] * .5, MidpointRounding.ToPositiveInfinity); - } - break; - case AttackEffectiveness.AVERAGE_LOW: - nextVal = RandomInRange(vanilla * .5, vanilla); - if (i == 1) - { - nextVal = Math.Max(nextVal, 2); // set minimum 2 damage at level 2 - } - break; - case AttackEffectiveness.AVERAGE: - nextVal = RandomInRange(vanilla * .667, vanilla * 1.5); - if (i == 0) - { - nextVal = Math.Max(nextVal, 2); // set minimum 2 damage at start - } - break; - case AttackEffectiveness.AVERAGE_HIGH: - nextVal = RandomInRange(vanilla, vanilla * 1.5); - break; - case AttackEffectiveness.HIGH: - nextVal = (int)(attackValues[i] * 1.5); - break; - default: - throw new Exception("Invalid Attack Effectiveness"); - } - if (i > 0) - { - byte lastValue = newAttackValues[i - 1]; - if (nextVal < lastValue) - { - nextVal = lastValue; // levelling up should never be worse - } - } - - newAttackValues[i] = (byte)nextVal; - } - return newAttackValues; - } - private void ShuffleItems() { List shufflableItems = [ @@ -1580,149 +1496,6 @@ private int UpdateItemGets() return gottenItems.Count; } - - - //This used to be one method for handling both, but it was rapidly approaching unreadable so I split it back out - private void RandomizeLifeEffectiveness(ROM rom) - { - int numBanks = 7; - int start = 0x1E2BF; - //There are 7 different damage categories for which damage taken scales with life level - //Each of those 7 categories has 8 values coresponding to each life level - //Damage values that do not scale with life levels are currently not randomized. - byte[] life = rom.GetBytes(start, numBanks * 8); - byte[] newLifeBytes = RandomizeLifeEffectiveness(r, life, props.LifeEffectiveness); - rom.Put(start, newLifeBytes); - } - - private static byte[] RandomizeLifeEffectiveness(Random RNG, byte[] life, LifeEffectiveness statEffectiveness) - { - if (statEffectiveness == LifeEffectiveness.VANILLA) - { - return life; - } - - int numBanks = 7; - byte[] newLife = new byte[numBanks * 8]; - - if (statEffectiveness == LifeEffectiveness.OHKO) - { - Array.Fill(newLife, 0xFF); - return newLife; - } - if (statEffectiveness == LifeEffectiveness.INVINCIBLE) - { - Array.Fill(newLife, 0x00); - return newLife; - } - - for (int j = 0; j < 8; j++) - { - for (int i = 0; i < numBanks; i++) - { - byte nextVal; - byte vanilla = (byte)(life[i * 8 + j] >> 1); - int min = (int)(vanilla * .75); - int max = Math.Min((int)(vanilla * 1.5), 120); - switch (statEffectiveness) - { - case LifeEffectiveness.AVERAGE_LOW: - nextVal = (byte)RNG.Next(vanilla, max); - break; - case LifeEffectiveness.AVERAGE: - nextVal = (byte)RNG.Next(min, max); - break; - case LifeEffectiveness.AVERAGE_HIGH: - nextVal = (byte)RNG.Next(min, vanilla); - break; - case LifeEffectiveness.HIGH: - nextVal = (byte)(vanilla * .5); - break; - default: - throw new Exception("Invalid Life Effectiveness"); - } - if (j > 0) - { - byte lastVal = (byte)(newLife[i * 8 + j - 1] >> 1); - if (nextVal > lastVal) - { - nextVal = lastVal; // levelling up should never be worse - } - } - newLife[i * 8 + j] = (byte)(nextVal << 1); - } - } - return newLife; - } - - private void RandomizeMagicEffectiveness(ROM rom) - { - //8 spells by 8 magic levels - int numBanks = 8; - int start = 0xD8B; - byte[] magicCosts = rom.GetBytes(start, numBanks * 8); - byte[] newMagicCostBytes = RandomizeMagicEffectiveness(r, magicCosts, props.MagicEffectiveness); - rom.Put(start, newMagicCostBytes); - } - - private byte[] RandomizeMagicEffectiveness(Random RNG, byte[] magicCosts, MagicEffectiveness statEffectiveness) - { - if (statEffectiveness == MagicEffectiveness.VANILLA) - { - return magicCosts; - } - - int numBanks = 8; - byte[] newMagicCosts = new byte[numBanks * 8]; - - if (statEffectiveness == MagicEffectiveness.FREE) - { - Array.Fill(newMagicCosts, 0); - return newMagicCosts; - } - - for (int level = 0; level < 8; level++) - { - for (int spellIndex = 0; spellIndex < numBanks; spellIndex++) - { - byte nextVal; - byte vanilla = (byte)(magicCosts[spellIndex * 8 + level] >> 1); - int min = (int)(vanilla * .5); - int max = Math.Min((int)(vanilla * 1.5), 120); - switch (statEffectiveness) - { - case MagicEffectiveness.HIGH_COST: - nextVal = (byte)max; - break; - case MagicEffectiveness.AVERAGE_HIGH_COST: - nextVal = (byte)RNG.Next(vanilla, max); - break; - case MagicEffectiveness.AVERAGE: - nextVal = (byte)RNG.Next(min, max); - break; - case MagicEffectiveness.AVERAGE_LOW_COST: - nextVal = (byte)RNG.Next(min, vanilla); - break; - case MagicEffectiveness.LOW_COST: - nextVal = (byte)min; - break; - default: - throw new Exception("Invalid Magic Effectiveness"); - } - if (level > 0) - { - byte lastVal = (byte)(newMagicCosts[spellIndex * 8 + level - 1] >> 1); - if (nextVal > lastVal) - { - nextVal = lastVal; // levelling up should never be worse - } - } - newMagicCosts[spellIndex * 8 + level] = (byte)(nextVal << 1); - } - } - return newMagicCosts; - } - public List GetRequireables() { List requireables = new(); @@ -2776,12 +2549,6 @@ private void RandomizeStartingValues(Assembler a, ROM rom) rom.ChangeLevelUpCancelling(a); - RandomizeAttackEffectiveness(rom, props.AttackEffectiveness); - - RandomizeLifeEffectiveness(rom); - - RandomizeMagicEffectiveness(rom); - rom.Put(0x17B10, (byte)props.StartGems); @@ -4052,7 +3819,6 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando FixSoftLock(engine); RandomizeStartingValues(engine, rom); - rom.FixRebonackHorseKillBug(); rom.FixStaleSaveSlotData(engine); rom.UseExtendedBanksForPalaceRooms(engine); rom.ExtendMapSize(engine); diff --git a/RandomizerCore/ROM.cs b/RandomizerCore/ROM.cs index 6a9d1a42..af539efe 100644 --- a/RandomizerCore/ROM.cs +++ b/RandomizerCore/ROM.cs @@ -1763,14 +1763,14 @@ bcs @loop } } - public void SetBossHpBarDivisors(Assembler asm, StatRandomizer enemyStats) + public void SetBossHpBarDivisors(Assembler asm, StatRandomizer randomizedStats) { var a = asm.Module(); for (int idx = 0; idx < RomMap.bossHpAddresses.Count; idx++) { var bossHpAddr = RomMap.bossHpAddresses[idx]; - var newVal = enemyStats.BossHpTable[idx]; + var newVal = randomizedStats.BossHpTable[idx]; var (_, bossHpBarAddr) = RomMap.bossMap[idx]; var originalDivisor = GetByte(bossHpBarAddr); var p = new NesPointer(bossHpBarAddr); @@ -2136,27 +2136,6 @@ public void AdjustGpProjectileDamage() //Put(0x15428, 0x00); // already at 0 } - /// When Rebonack's HP is set to exactly 2 * your damage, it will - /// trigger a bug where you kill Rebo's horse while de-horsing him. - /// This causes an additional key to drop, as well as softlocking - /// the player if they die before killing Rebo. It seems to also - /// trigger if you have exactly damage == Rebo HP (very high damage). - /// - /// This has to be called after RandomizeEnemyStats and - /// RandomizeAttackEffectiveness. - /// - /// (In Vanilla Zelda 2, your sword damage is never this high.) - public void FixRebonackHorseKillBug() - { - byte[] attackValues = GetBytes(0x1E67D, 8); - byte reboHp = GetByte(0x12951); - while (attackValues.Any(v => v * 2 == reboHp || v == reboHp)) - { - reboHp++; - Put(0x12951, reboHp); - } - } - /// Rewrite the graphic tiles for walkthrough walls to be something else public void RevealWalkthroughWalls() { diff --git a/RandomizerCore/RomMap.cs b/RandomizerCore/RomMap.cs index b845c1cc..69b3152b 100644 --- a/RandomizerCore/RomMap.cs +++ b/RandomizerCore/RomMap.cs @@ -142,6 +142,10 @@ class RomMap public const int GP_ENEMY_HP_TABLE = 0x15431; public const int DRIPPER_ID = 0x11927; + public const int ATTACK_EFFECTIVENESS_TABLE = 0x1E67D; + public const int LIFE_EFFECTIVENESS_TABLE = 0x1E2BF; + public const int MAGIC_EFFECTIVENESS_TABLE = 0xD8B; + /** * The function in bank 4 $9C45 (file offset 0x11c55) and bank 5 $A4E9 (file offset 0x164f9) * are divide functions that are used to display the HP bar for bosses and split it into 8 segments. diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index 35065e27..e719cd10 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -5,8 +5,6 @@ namespace Z2Randomizer.RandomizerCore; -// TODO: move stat effectiveness here. - /// /// Responsible for randomizing stats of all kinds. Each array should /// be randomized at most once. If you have to re-roll for some reason, @@ -14,6 +12,12 @@ namespace Z2Randomizer.RandomizerCore; /// public class StatRandomizer { + public const int LIFE_EFFECTIVENESS_ROWS = 7; + public const int MAGIC_EFFECTIVENESS_ROWS = 8; + public byte[] AttackEffectivenessTable { get; private set; } = null!; + public byte[] LifeEffectivenessTable { get; private set; } = null!; + public byte[] MagicEffectivenessTable { get; private set; } = null!; + public byte[] WestEnemyHpTable { get; private set; } = null!; public byte[] EastEnemyHpTable { get; private set; } = null!; public byte[] Palace125EnemyHpTable { get; private set; } = null!; @@ -23,43 +27,98 @@ public class StatRandomizer protected RandomizerProperties props { get; } #if DEBUG - private bool AlreadyRandomized = false; + private bool hasRandomized = false; + private bool hasWritten = false; #endif public StatRandomizer(ROM rom, RandomizerProperties props) { this.props = props; - ReadHp(rom); + ReadAttackEffectiveness(rom); + ReadLifeEffectiveness(rom); + ReadMagicEffectiveness(rom); + ReadEnemyHp(rom); } public void Randomize(Random r) { #if DEBUG - Debug.Assert(!AlreadyRandomized); - AlreadyRandomized = true; + Debug.Assert(!hasRandomized); + hasRandomized = true; #endif - if (props.ShuffleEnemyHP) - { - RandomizeHp(r); - } - switch (props.ShuffleBossHP) - { - case EnemyLifeOption.RANDOM: - for (int i = 0; i < BossHpTable.Length; i++) { RollHpValue(r, BossHpTable, i); } - break; - case EnemyLifeOption.RANDOM_HIGH: - for (int i = 0; i < BossHpTable.Length; i++) { RollHpValue(r, BossHpTable, i, 1.0, 2.0); } - break; - } + RandomizeAttackEffectiveness(r, props.AttackEffectiveness); + RandomizeLifeEffectiveness(r, props.LifeEffectiveness); + RandomizeMagicEffectiveness(r, props.MagicEffectiveness); + + RandomizeRegularEnemyHp(r); + RandomizeBossHp(r); + FixRebonackHorseKillBug(); } public void Write(ROM rom) { - WriteHp(rom); +#if DEBUG + Debug.Assert(!hasWritten); + hasWritten = true; +#endif + + WriteAttackEffectiveness(rom); + WriteLifeEffectiveness(rom); + WriteMagicEffectiveness(rom); + WriteEnemyHp(rom); + } + + [Conditional("DEBUG")] + public void AssertHasRandomized(bool value = true) + { +#if DEBUG + Debug.Assert(hasRandomized == value); +#endif + } + + [Conditional("DEBUG")] + public void AssertHasWritten(bool value = true) { +#if DEBUG + Debug.Assert(hasWritten == value); +#endif + } + + protected void ReadAttackEffectiveness(ROM rom) + { + AttackEffectivenessTable = rom.GetBytes(RomMap.ATTACK_EFFECTIVENESS_TABLE, 8); + } + + protected void WriteAttackEffectiveness(ROM rom) + { + rom.Put(RomMap.ATTACK_EFFECTIVENESS_TABLE, AttackEffectivenessTable); + } + + protected void ReadLifeEffectiveness(ROM rom) + { + // There are 7 different damage codes for which damage taken scales with Life-level + // Each of those 7 codes have 8 values corresponding to each Life-level. + // (This is followed by an 8th row that is OHKO regardless of Life-level.) + LifeEffectivenessTable = rom.GetBytes(RomMap.LIFE_EFFECTIVENESS_TABLE, LIFE_EFFECTIVENESS_ROWS * 8); + } + + protected void WriteLifeEffectiveness(ROM rom) + { + rom.Put(RomMap.LIFE_EFFECTIVENESS_TABLE, LifeEffectivenessTable); } - protected void ReadHp(ROM rom) + protected void ReadMagicEffectiveness(ROM rom) + { + // 8 Spell costs by 8 Magic levels + MagicEffectivenessTable = rom.GetBytes(RomMap.MAGIC_EFFECTIVENESS_TABLE, MAGIC_EFFECTIVENESS_ROWS * 8); + } + + protected void WriteMagicEffectiveness(ROM rom) + { + rom.Put(RomMap.MAGIC_EFFECTIVENESS_TABLE, MagicEffectivenessTable); + } + + protected void ReadEnemyHp(ROM rom) { WestEnemyHpTable = rom.GetBytes(RomMap.WEST_ENEMY_HP_TABLE, 0x24); EastEnemyHpTable = rom.GetBytes(RomMap.EAST_ENEMY_HP_TABLE, 0x24); @@ -69,7 +128,7 @@ protected void ReadHp(ROM rom) BossHpTable = [.. RomMap.bossHpAddresses.Select(rom.GetByte)]; } - protected void WriteHp(ROM rom) + protected void WriteEnemyHp(ROM rom) { rom.Put(RomMap.WEST_ENEMY_HP_TABLE, WestEnemyHpTable); rom.Put(RomMap.EAST_ENEMY_HP_TABLE, EastEnemyHpTable); @@ -82,31 +141,221 @@ protected void WriteHp(ROM rom) } } - protected void RandomizeHp(Random r) + protected void RandomizeAttackEffectiveness(Random r, AttackEffectiveness attackEffectiveness) { + if (attackEffectiveness == AttackEffectiveness.VANILLA) + { + return; + } + if (attackEffectiveness == AttackEffectiveness.OHKO) + { + // handled in RandomizeEnemyStats() + return; + } + + byte[] newTable = new byte[8]; + for (int i = 0; i < 8; i++) + { + int nextVal; + byte vanilla = AttackEffectivenessTable[i]; + switch (attackEffectiveness) + { + case AttackEffectiveness.LOW: + //the naieve approach here gives a curve of 1,2,2,4,5,6 which is weird, or a different + //irregular curve in digshake's old approach. Just use a linear increase for the first 6 levels on low + if (i < 6) + { + nextVal = i + 1; + } + else + { + nextVal = (int)Math.Round(vanilla * .5, MidpointRounding.ToPositiveInfinity); + } + break; + case AttackEffectiveness.AVERAGE_LOW: + nextVal = RandomInRange(r, vanilla * .5, vanilla); + if (i == 1) + { + nextVal = Math.Max(nextVal, 2); // set minimum 2 damage at level 2 + } + break; + case AttackEffectiveness.AVERAGE: + nextVal = RandomInRange(r, vanilla * .667, vanilla * 1.5); + if (i == 0) + { + nextVal = Math.Max(nextVal, 2); // set minimum 2 damage at start + } + break; + case AttackEffectiveness.AVERAGE_HIGH: + nextVal = RandomInRange(r, vanilla, vanilla * 1.5); + break; + case AttackEffectiveness.HIGH: + nextVal = (int)Math.Round(vanilla * 1.5); + break; + default: + throw new NotImplementedException("Invalid Attack Effectiveness"); + } + if (i > 0) + { + byte lastValue = newTable[i - 1]; + if (nextVal < lastValue) + { + nextVal = lastValue; // levelling up should never be worse + } + } + + newTable[i] = (byte)nextVal; + } + AttackEffectivenessTable = newTable; + } + + protected void RandomizeLifeEffectiveness(Random r, LifeEffectiveness statEffectiveness) + { + if (statEffectiveness == LifeEffectiveness.VANILLA) + { + return; + } + + if (statEffectiveness == LifeEffectiveness.OHKO) + { + Array.Fill(LifeEffectivenessTable, 0xFF); + return; + } + if (statEffectiveness == LifeEffectiveness.INVINCIBLE) + { + Array.Fill(LifeEffectivenessTable, 0x00); + return; + } + + byte[] newTable = new byte[LIFE_EFFECTIVENESS_ROWS * 8]; + + for (int level = 0; level < 8; level++) + { + for (int damageCode = 0; damageCode < LIFE_EFFECTIVENESS_ROWS; damageCode++) + { + int index = damageCode * 8 + level; + byte nextVal; + byte vanilla = (byte)(LifeEffectivenessTable[index] >> 1); + int min = (int)(vanilla * .75); + int max = Math.Min((int)(vanilla * 1.5), 120); + switch (statEffectiveness) + { + case LifeEffectiveness.AVERAGE_LOW: + nextVal = (byte)r.Next(vanilla, max); + break; + case LifeEffectiveness.AVERAGE: + nextVal = (byte)r.Next(min, max); + break; + case LifeEffectiveness.AVERAGE_HIGH: + nextVal = (byte)r.Next(min, vanilla); + break; + case LifeEffectiveness.HIGH: + nextVal = (byte)(vanilla * .5); + break; + default: + throw new NotImplementedException("Invalid Life Effectiveness"); + } + if (level > 0) + { + byte lastVal = (byte)(newTable[index - 1] >> 1); + if (nextVal > lastVal) + { + nextVal = lastVal; // levelling up should never be worse + } + } + newTable[index] = (byte)(nextVal << 1); + } + } + LifeEffectivenessTable = newTable; + } + + protected void RandomizeMagicEffectiveness(Random r, MagicEffectiveness statEffectiveness) + { + if (statEffectiveness == MagicEffectiveness.VANILLA) + { + return; + } + + if (statEffectiveness == MagicEffectiveness.FREE) + { + Array.Fill(MagicEffectivenessTable, 0); + return; + } + + byte[] newTable = new byte[MAGIC_EFFECTIVENESS_ROWS * 8]; + + for (int level = 0; level < 8; level++) + { + for (int spellIndex = 0; spellIndex < MAGIC_EFFECTIVENESS_ROWS; spellIndex++) + { + int index = spellIndex * 8 + level; + byte nextVal; + byte vanilla = (byte)(MagicEffectivenessTable[index] >> 1); + int min = (int)(vanilla * .5); + int max = Math.Min((int)(vanilla * 1.5), 120); + switch (statEffectiveness) + { + case MagicEffectiveness.HIGH_COST: + nextVal = (byte)max; + break; + case MagicEffectiveness.AVERAGE_HIGH_COST: + nextVal = (byte)r.Next(vanilla, max); + break; + case MagicEffectiveness.AVERAGE: + nextVal = (byte)r.Next(min, max); + break; + case MagicEffectiveness.AVERAGE_LOW_COST: + nextVal = (byte)r.Next(min, vanilla); + break; + case MagicEffectiveness.LOW_COST: + nextVal = (byte)min; + break; + default: + throw new Exception("Invalid Magic Effectiveness"); + } + if (level > 0) + { + byte lastVal = (byte)(newTable[index - 1] >> 1); + if (nextVal > lastVal) + { + nextVal = lastVal; // levelling up should never be worse + } + } + newTable[index] = (byte)(nextVal << 1); + } + } + MagicEffectivenessTable = newTable; + } + + protected void RandomizeRegularEnemyHp(Random r) + { + if (!props.ShuffleEnemyHP) { + return; + } + int i; - for (i = (int)EnemiesWest.MYU; i < 0x23; i++) { RollHpValue(r, WestEnemyHpTable, i); } + for (i = (int)EnemiesWest.MYU; i < 0x23; i++) { RandomizeInsideArray(r, WestEnemyHpTable, i); } - for (i = (int)EnemiesEast.MYU; i < 0x1e; i++) { RollHpValue(r, EastEnemyHpTable, i); } + for (i = (int)EnemiesEast.MYU; i < 0x1e; i++) { RandomizeInsideArray(r, EastEnemyHpTable, i); } // keeping old behavior where some non-enemies are not randomized (strike for jar, unused enemies etc.) - for (i = (int)EnemiesPalace125.MYU; i < (int)EnemiesPalace125.STRIKE_FOR_RED_JAR; i++) { RollHpValue(r, Palace125EnemyHpTable, i); } - for (i = (int)EnemiesPalace125.SLOW_BUBBLE; i < (int)EnemiesPalace125.HORSEHEAD; i++) { RollHpValue(r, Palace125EnemyHpTable, i); } - for (i = (int)EnemiesPalace125.BLUE_STALFOS; i < 0x24; i++) { RollHpValue(r, Palace125EnemyHpTable, i); } + for (i = (int)EnemiesPalace125.MYU; i < (int)EnemiesPalace125.STRIKE_FOR_RED_JAR; i++) { RandomizeInsideArray(r, Palace125EnemyHpTable, i); } + for (i = (int)EnemiesPalace125.SLOW_BUBBLE; i < (int)EnemiesPalace125.HORSEHEAD; i++) { RandomizeInsideArray(r, Palace125EnemyHpTable, i); } + for (i = (int)EnemiesPalace125.BLUE_STALFOS; i < 0x24; i++) { RandomizeInsideArray(r, Palace125EnemyHpTable, i); } - for (i = (int)EnemiesPalace346.MYU; i < (int)EnemiesPalace346.STRIKE_FOR_RED_JAR_OR_IRON_KNUCKLE; i++) { RollHpValue(r, Palace346EnemyHpTable, i); } - for (i = (int)EnemiesPalace346.SLOW_BUBBLE; i < (int)EnemiesPalace346.REBONAK; i++) { RollHpValue(r, Palace346EnemyHpTable, i); } - for (i = (int)EnemiesPalace346.BLUE_STALFOS; i < 0x24; i++) { RollHpValue(r, Palace346EnemyHpTable, i); } + for (i = (int)EnemiesPalace346.MYU; i < (int)EnemiesPalace346.STRIKE_FOR_RED_JAR_OR_IRON_KNUCKLE; i++) { RandomizeInsideArray(r, Palace346EnemyHpTable, i); } + for (i = (int)EnemiesPalace346.SLOW_BUBBLE; i < (int)EnemiesPalace346.REBONAK; i++) { RandomizeInsideArray(r, Palace346EnemyHpTable, i); } + for (i = (int)EnemiesPalace346.BLUE_STALFOS; i < 0x24; i++) { RandomizeInsideArray(r, Palace346EnemyHpTable, i); } - for (i = (int)EnemiesGreatPalace.MYU; i < (int)EnemiesGreatPalace.STRIKE_FOR_RED_JAR_OR_FOKKA; i++) { RollHpValue(r, GpEnemyHpTable, i); } - for (i = (int)EnemiesGreatPalace.ORANGE_MOA; i < (int)EnemiesGreatPalace.BUBBLE_GENERATOR; i++) { RollHpValue(r, GpEnemyHpTable, i); } - for (i = (int)EnemiesGreatPalace.RED_DEELER; i < (int)EnemiesGreatPalace.ELEVATOR; i++) { RollHpValue(r, GpEnemyHpTable, i); } - for (i = (int)EnemiesGreatPalace.SLOW_BUBBLE; i < 0x1b; i++) { RollHpValue(r, GpEnemyHpTable, i); } - for (i = (int)EnemiesGreatPalace.FOKKERU; i < (int)EnemiesGreatPalace.KING_BOT; i++) { RollHpValue(r, GpEnemyHpTable, i); } + for (i = (int)EnemiesGreatPalace.MYU; i < (int)EnemiesGreatPalace.STRIKE_FOR_RED_JAR_OR_FOKKA; i++) { RandomizeInsideArray(r, GpEnemyHpTable, i); } + for (i = (int)EnemiesGreatPalace.ORANGE_MOA; i < (int)EnemiesGreatPalace.BUBBLE_GENERATOR; i++) { RandomizeInsideArray(r, GpEnemyHpTable, i); } + for (i = (int)EnemiesGreatPalace.RED_DEELER; i < (int)EnemiesGreatPalace.ELEVATOR; i++) { RandomizeInsideArray(r, GpEnemyHpTable, i); } + for (i = (int)EnemiesGreatPalace.SLOW_BUBBLE; i < 0x1b; i++) { RandomizeInsideArray(r, GpEnemyHpTable, i); } + for (i = (int)EnemiesGreatPalace.FOKKERU; i < (int)EnemiesGreatPalace.KING_BOT; i++) { RandomizeInsideArray(r, GpEnemyHpTable, i); } } - protected void RollHpValue(Random r, byte[] array, int index, double lower=0.5, double upper=1.5) + protected void RandomizeBossHp(Random r) { switch (props.ShuffleBossHP) { @@ -125,3 +374,42 @@ protected void RollHpValue(Random r, byte[] array, int index, double lower=0.5, throw new NotImplementedException("Invalid ShuffleBossHP value"); } } + + protected int RandomInRange(Random r, double minVal, double maxVal) + { + int nextVal = (int)Math.Round(r.NextDouble() * (maxVal - minVal) + minVal); + nextVal = (int)Math.Min(nextVal, maxVal); + nextVal = (int)Math.Max(nextVal, minVal); + return nextVal; + } + + protected void RandomizeInsideArray(Random r, byte[] array, int index, double lower=0.5, double upper=1.5) + { + var vanillaVal = array[index]; + int minVal = (int)(vanillaVal * lower); + int maxVal = (int)(vanillaVal * upper); + var newVal = (byte)Math.Min(r.Next(minVal, maxVal), 255); + array[index] = newVal; + } + + /// When Rebonack's HP is set to exactly 2 * your damage, it will + /// trigger a bug where you kill Rebo's horse while de-horsing him. + /// This causes an additional key to drop, as well as softlocking + /// the player if they die before killing Rebo. It seems to also + /// trigger if you have exactly damage == Rebo HP (very high damage). + /// + /// This has to be called after RandomizeEnemyStats and + /// RandomizeAttackEffectiveness. + /// + /// (In Vanilla Zelda 2, your sword damage is never this high.) + public void FixRebonackHorseKillBug() + { + byte[] attackValues = AttackEffectivenessTable; + byte reboHp = BossHpTable[2]; + while (attackValues.Any(v => v * 2 == reboHp || v == reboHp)) + { + reboHp++; + BossHpTable[2] = reboHp; + } + } +} From 5a84e90c2b64b370d167924d4a5b8792b5964483 Mon Sep 17 00:00:00 2001 From: initsu Date: Tue, 2 Dec 2025 11:19:48 +0100 Subject: [PATCH 05/10] Refactor: Move level-up experience rando logic to StatRandomizer --- RandomizerCore/Hyrule.cs | 156 ------------------------------- RandomizerCore/ROM.cs | 29 ++++++ RandomizerCore/RomMap.cs | 3 + RandomizerCore/StatRandomizer.cs | 135 ++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 156 deletions(-) diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 3c26a024..4b830e57 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -2158,133 +2158,6 @@ private void SwapUpAndDownstab() westHyrule.midoChurch.Collectables = [Collectable.UPSTAB]; } - private void RandomizeExperience(ROM rom) - { - bool[] shuffleStat = [props.ShuffleAtkExp, props.ShuffleMagicExp, props.ShuffleLifeExp]; - int[] levelCap = [props.AttackCap, props.MagicCap, props.LifeCap]; - - var startAddr = 0x1669; - var startAddrText = 0x1e42; - - int[] vanillaExp = new int[24]; - - for (int i = 0; i < vanillaExp.Length; i++) - { - vanillaExp[i] = rom.GetShort(startAddr + i, startAddr + 24 + i); - } - - int[] randomizedExp = RandomizeExperience(r, vanillaExp, shuffleStat, levelCap, props.ScaleLevels); - - for (int i = 0; i < randomizedExp.Length; i++) - { - rom.PutShort(startAddr + i, startAddr + 24 + i, randomizedExp[i]); - } - - for (int i = 0; i < randomizedExp.Length; i++) - { - var n = randomizedExp[i]; - var digit1 = IntToText(n / 1000); - n %= 1000; - var digit2 = IntToText(n / 100); - n %= 100; - var digit3 = IntToText(n / 10); - rom.Put(startAddrText + 48 + i, digit1); - rom.Put(startAddrText + 24 + i, digit2); - rom.Put(startAddrText + 0 + i, digit3); - } - } - - private static int[] RandomizeExperience(Random RNG, int[] vanillaExp, bool[] shuffleStat, int[] levelCap, bool scaleLevels) - { - int[] randomized = new int[24]; - Span randomizedSpan = randomized; - ReadOnlySpan vanillaSpan = vanillaExp; - - for (int stat = 0; stat < 3; stat++) - { - var statStartIndex = stat * 8; - if (!shuffleStat[stat]) { - vanillaSpan.Slice(statStartIndex, 8).CopyTo(randomizedSpan.Slice(statStartIndex, 8)); - continue; - } - for (int i = 0; i < 8; i++) - { - var vanilla = vanillaExp[statStartIndex + i]; - int nextMin = (int)(vanilla - vanilla * 0.25); - int nextMax = (int)(vanilla + vanilla * 0.25); - if (i == 0) - { - randomized[statStartIndex + i] = RNG.Next(Math.Max(10, nextMin), nextMax); - } - else - { - randomized[statStartIndex + i] = RNG.Next(Math.Max(randomized[statStartIndex + i - 1], nextMin), Math.Min(nextMax, 9990)); - } - } - } - - for (int i = 0; i < randomized.Length; i++) - { - randomized[i] = randomized[i] / 10 * 10; //wtf is this line of code? -digshake, 2020 - } - - if (scaleLevels) - { - int[] cappedExp = new int[24]; - - for (int stat = 0; stat < 3; stat++) - { - var statStartIndex = stat * 8; - var cap = levelCap[stat]; - - for (int i = 0; i < 8; i++) - { - if (i >= cap) - { - cappedExp[statStartIndex + i] = randomized[statStartIndex + i]; //shouldn't matter, just wanna put something here - } - else if (i == cap - 1) - { - cappedExp[statStartIndex + i] = randomized[7]; //exp to get a 1up - } - else - { - cappedExp[statStartIndex + i] = randomized[(int)(6 * ((i + 1.0) / (cap - 1)))]; //cap = 3, level 4, 8, - } - } - } - - randomized = cappedExp; - } - - // Make sure all 1-up levels are higher cost than regular levels - int highestRegularLevelExp = 0; - for (int stat = 0; stat < 3; stat++) - { - var cap = levelCap[stat]; - if (cap < 2) { continue; } - var statStartIndex = stat * 8; - int statMaxLevelIndex = statStartIndex + cap - 2; - var maxExp = randomized[statMaxLevelIndex]; - if (highestRegularLevelExp < maxExp) { highestRegularLevelExp = maxExp; } - } - - int min1upExp = Math.Min(highestRegularLevelExp + 10, 9990); - for (int stat = 0; stat < 3; stat++) - { - var cap = levelCap[stat]; - Debug.Assert(cap >= 1); - var statStartIndex = stat * 8; - int stat1upLevelIndex = statStartIndex + cap - 1; - var exp1up = randomized[stat1upLevelIndex]; - if (exp1up < min1upExp) { - randomized[stat1upLevelIndex] = RNG.Next(min1upExp, 9990) / 10 * 10; - } - } - - return randomized; - } - /// /// For a given set of bytes, set a masked portion of the value of each byte on or off (all 1's or all 0's) at a rate /// equal to the proportion of values at the addresses that have that masked portion set to a nonzero value. @@ -2543,8 +2416,6 @@ private void RandomizeStartingValues(Assembler a, ROM rom) rom.Put(0xF53A, (byte)0); } - RandomizeExperience(rom); - rom.SetLevelCap(a, props.AttackCap, props.MagicCap, props.LifeCap); rom.ChangeLevelUpCancelling(a); @@ -2643,33 +2514,6 @@ private void RandomizeEnemyAttributes(ROM rom, int baseAddr, T[] groundEnemie for (int i = 0; i < addrsByte2.Count; i++) { rom.Put(addrsByte2[i], enemyBytes2[i]); } } - private byte IntToText(int x) - { - switch (x) - { - case 0: - return (byte)0xD0; - case 1: - return (byte)0xD1; - case 2: - return (byte)0xD2; - case 3: - return (byte)0xD3; - case 4: - return (byte)0xD4; - case 5: - return (byte)0xD5; - case 6: - return (byte)0xD6; - case 7: - return (byte)0xD7; - case 8: - return (byte)0xD8; - default: - return (byte)0xD9; - } - } - private void UpdateRom() { foreach (World world in worlds) diff --git a/RandomizerCore/ROM.cs b/RandomizerCore/ROM.cs index af539efe..36b3d291 100644 --- a/RandomizerCore/ROM.cs +++ b/RandomizerCore/ROM.cs @@ -2146,6 +2146,35 @@ public void RevealWalkthroughWalls() Put(0x141b3, [0x02, 0x02]); // GP } + public static byte DigitToZ2TextByte(int digit) + { + switch (digit) + { + case 0: + return 0xD0; + case 1: + return 0xD1; + case 2: + return 0xD2; + case 3: + return 0xD3; + case 4: + return 0xD4; + case 5: + return 0xD5; + case 6: + return 0xD6; + case 7: + return 0xD7; + case 8: + return 0xD8; + case 9: + return 0xD9; + default: + throw new ArgumentException("Value out of range (0-9)"); + } + } + public static string Z2BytesToString(byte[] data) { return new string(data.Select(letter => { diff --git a/RandomizerCore/RomMap.cs b/RandomizerCore/RomMap.cs index 69b3152b..591b0011 100644 --- a/RandomizerCore/RomMap.cs +++ b/RandomizerCore/RomMap.cs @@ -146,6 +146,9 @@ class RomMap public const int LIFE_EFFECTIVENESS_TABLE = 0x1E2BF; public const int MAGIC_EFFECTIVENESS_TABLE = 0xD8B; + public const int EXPERIENCE_TO_LEVEL_TABLE = 0x1669; + public const int EXPERIENCE_TO_LEVEL_TEXT_TABLE = 0x1e42; + /** * The function in bank 4 $9C45 (file offset 0x11c55) and bank 5 $A4E9 (file offset 0x164f9) * are divide functions that are used to display the HP bar for bosses and split it into 8 segments. diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index e719cd10..d8fe56fd 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -18,6 +18,8 @@ public class StatRandomizer public byte[] LifeEffectivenessTable { get; private set; } = null!; public byte[] MagicEffectivenessTable { get; private set; } = null!; + public int[] ExperienceToLevelTable { get; private set; } = null!; + public byte[] WestEnemyHpTable { get; private set; } = null!; public byte[] EastEnemyHpTable { get; private set; } = null!; public byte[] Palace125EnemyHpTable { get; private set; } = null!; @@ -34,9 +36,12 @@ public class StatRandomizer public StatRandomizer(ROM rom, RandomizerProperties props) { this.props = props; + ReadExperienceToLevel(rom); + ReadAttackEffectiveness(rom); ReadLifeEffectiveness(rom); ReadMagicEffectiveness(rom); + ReadEnemyHp(rom); } @@ -46,6 +51,8 @@ public void Randomize(Random r) Debug.Assert(!hasRandomized); hasRandomized = true; #endif + RandomizeExperienceToLevel(r, [props.ShuffleAtkExp, props.ShuffleMagicExp, props.ShuffleLifeExp], + [props.AttackCap, props.MagicCap, props.LifeCap], props.ScaleLevels); RandomizeAttackEffectiveness(r, props.AttackEffectiveness); RandomizeLifeEffectiveness(r, props.LifeEffectiveness); @@ -63,9 +70,12 @@ public void Write(ROM rom) hasWritten = true; #endif + WriteExperienceToLevel(rom); + WriteAttackEffectiveness(rom); WriteLifeEffectiveness(rom); WriteMagicEffectiveness(rom); + WriteEnemyHp(rom); } @@ -84,6 +94,38 @@ public void AssertHasWritten(bool value = true) { #endif } + protected void ReadExperienceToLevel(ROM rom) + { + const int startAddr = RomMap.EXPERIENCE_TO_LEVEL_TABLE; + ExperienceToLevelTable = new int[24]; + for (int i = 0; i < 24; i++) + { + ExperienceToLevelTable[i] = rom.GetShort(startAddr + i, startAddr + 24 + i); + } + } + + protected void WriteExperienceToLevel(ROM rom) + { + const int startAddr = RomMap.EXPERIENCE_TO_LEVEL_TABLE; + for (int i = 0; i < ExperienceToLevelTable.Length; i++) + { + rom.PutShort(startAddr + i, startAddr + 24 + i, ExperienceToLevelTable[i]); + } + + for (int i = 0; i < ExperienceToLevelTable.Length; i++) + { + int n = ExperienceToLevelTable[i]; + var digit1 = ROM.DigitToZ2TextByte(n / 1000); + n %= 1000; + var digit2 = ROM.DigitToZ2TextByte(n / 100); + n %= 100; + var digit3 = ROM.DigitToZ2TextByte(n / 10); + rom.Put(RomMap.EXPERIENCE_TO_LEVEL_TEXT_TABLE + 48 + i, digit1); + rom.Put(RomMap.EXPERIENCE_TO_LEVEL_TEXT_TABLE + 24 + i, digit2); + rom.Put(RomMap.EXPERIENCE_TO_LEVEL_TEXT_TABLE + 0 + i, digit3); + } + } + protected void ReadAttackEffectiveness(ROM rom) { AttackEffectivenessTable = rom.GetBytes(RomMap.ATTACK_EFFECTIVENESS_TABLE, 8); @@ -141,6 +183,99 @@ protected void WriteEnemyHp(ROM rom) } } + protected void RandomizeExperienceToLevel(Random r, bool[] shuffleStat, int[] levelCap, bool scaleLevels) + { + int[] newTable = new int[24]; + Span randomizedSpan = newTable; + ReadOnlySpan vanillaSpan = ExperienceToLevelTable; + + for (int stat = 0; stat < 3; stat++) + { + var statStartIndex = stat * 8; + if (!shuffleStat[stat]) + { + vanillaSpan.Slice(statStartIndex, 8).CopyTo(randomizedSpan.Slice(statStartIndex, 8)); + continue; + } + for (int i = 0; i < 8; i++) + { + var vanilla = ExperienceToLevelTable[statStartIndex + i]; + int nextMin = (int)(vanilla - vanilla * 0.25); + int nextMax = (int)(vanilla + vanilla * 0.25); + if (i == 0) + { + newTable[statStartIndex + i] = r.Next(Math.Max(10, nextMin), nextMax); + } + else + { + newTable[statStartIndex + i] = r.Next(Math.Max(newTable[statStartIndex + i - 1], nextMin), Math.Min(nextMax, 9990)); + } + } + } + + for (int i = 0; i < newTable.Length; i++) + { + newTable[i] = newTable[i] / 10 * 10; //wtf is this line of code? -digshake, 2020 + } + + if (scaleLevels) + { + int[] cappedExp = new int[24]; + + for (int stat = 0; stat < 3; stat++) + { + var statStartIndex = stat * 8; + var cap = levelCap[stat]; + + for (int i = 0; i < 8; i++) + { + if (i >= cap) + { + cappedExp[statStartIndex + i] = newTable[statStartIndex + i]; //shouldn't matter, just wanna put something here + } + else if (i == cap - 1) + { + cappedExp[statStartIndex + i] = newTable[7]; //exp to get a 1up + } + else + { + cappedExp[statStartIndex + i] = newTable[(int)(6 * ((i + 1.0) / (cap - 1)))]; //cap = 3, level 4, 8, + } + } + } + + newTable = cappedExp; + } + + // Make sure all 1-up levels are higher cost than regular levels + int highestRegularLevelExp = 0; + for (int stat = 0; stat < 3; stat++) + { + var cap = levelCap[stat]; + if (cap < 2) { continue; } + var statStartIndex = stat * 8; + int statMaxLevelIndex = statStartIndex + cap - 2; + var maxExp = newTable[statMaxLevelIndex]; + if (highestRegularLevelExp < maxExp) { highestRegularLevelExp = maxExp; } + } + + int min1upExp = Math.Min(highestRegularLevelExp + 10, 9990); + for (int stat = 0; stat < 3; stat++) + { + var cap = levelCap[stat]; + Debug.Assert(cap >= 1); + var statStartIndex = stat * 8; + int stat1upLevelIndex = statStartIndex + cap - 1; + var exp1up = newTable[stat1upLevelIndex]; + if (exp1up < min1upExp) + { + newTable[stat1upLevelIndex] = r.Next(min1upExp, 9990) / 10 * 10; + } + } + + ExperienceToLevelTable = newTable; + } + protected void RandomizeAttackEffectiveness(Random r, AttackEffectiveness attackEffectiveness) { if (attackEffectiveness == AttackEffectiveness.VANILLA) From f023a4d9dc4118908fb407b5463ec09ba32c987d Mon Sep 17 00:00:00 2001 From: initsu Date: Tue, 2 Dec 2025 16:23:18 +0100 Subject: [PATCH 06/10] Refactor: Move enemy experience and stats logic to StatRandomizer --- RandomizerCore/Hyrule.cs | 135 ------------------------ RandomizerCore/ROM.cs | 11 +- RandomizerCore/RomMap.cs | 38 ++++--- RandomizerCore/StatRandomizer.cs | 170 ++++++++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 155 deletions(-) diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 4b830e57..ac95c922 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -2158,65 +2158,6 @@ private void SwapUpAndDownstab() westHyrule.midoChurch.Collectables = [Collectable.UPSTAB]; } - /// - /// For a given set of bytes, set a masked portion of the value of each byte on or off (all 1's or all 0's) at a rate - /// equal to the proportion of values at the addresses that have that masked portion set to a nonzero value. - /// In effect, turn some values in a range on or off randomly in the proportion of the number of such values that are on in vanilla. - /// - /// Bytes to randomize. - /// What part of the byte value at each address contains the configuration bit(s) we care about. - private static void RandomizeBits(Random RNG, byte[] bytes, int mask) - { - if (bytes.Length == 0) { return; } - - int notMask = mask ^ 0xFF; - double vanillaBitSetCount = bytes.Where(b => (b & mask) != 0).Count(); - - //proportion of the bytes that have nonzero values in the masked portion - double fraction = vanillaBitSetCount / bytes.Length; - - for (int i = 0; i < bytes.Length; i++) - { - int v = bytes[i] & notMask; - if (RNG.NextDouble() <= fraction) - { - v |= mask; - } - bytes[i] = (byte)v; - } - } - - private static void RandomizeEnemyExp(Random RNG, byte[] bytes, XPEffectiveness effectiveness) - { - for (int i = 0; i < bytes.Length; i++) - { - int b = bytes[i]; - int low = b & 0x0f; - - if (effectiveness == XPEffectiveness.RANDOM_HIGH) - { - low++; - } - else if (effectiveness == XPEffectiveness.RANDOM_LOW) - { - low--; - } - else if (effectiveness == XPEffectiveness.NONE) - { - low = 0; - } - - if (effectiveness.IsRandom()) - { - low = RNG.Next(low - 2, low + 3); - } - - low = Math.Min(Math.Max(low, 0), 15); - - bytes[i] = (byte)((b & 0xf0) | low); - } - } - //Updated to use fisher-yates. Eventually i'll catch all of these. N is small enough here it REALLY makes a difference private void ShuffleEncounters(ROM rom, List addr) { @@ -2337,28 +2278,6 @@ private void RandomizeStartingValues(Assembler a, ROM rom) rom.Put(0x1E314, (byte)big); } - RandomizeEnemyAttributes(rom, 0x54e5, Enemies.WestGroundEnemies, Enemies.WestFlyingEnemies, Enemies.WestGenerators); - RandomizeEnemyAttributes(rom, 0x94e5, Enemies.EastGroundEnemies, Enemies.EastFlyingEnemies, Enemies.EastGenerators); - RandomizeEnemyAttributes(rom, 0x114e5, Enemies.Palace125GroundEnemies, Enemies.Palace125FlyingEnemies, Enemies.Palace125Generators); - RandomizeEnemyAttributes(rom, 0x129e5, Enemies.Palace346GroundEnemies, Enemies.Palace346FlyingEnemies, Enemies.Palace346Generators); - RandomizeEnemyAttributes(rom, 0x154e5, Enemies.GPGroundEnemies, Enemies.GPFlyingEnemies, Enemies.GPGenerators); - - if (props.EnemyXPDrops != XPEffectiveness.VANILLA) - { - List addrs = new List(); - addrs.Add(0x11505); // Horsehead - addrs.Add(0x13C88); // Helmethead - addrs.Add(0x13C89); // Gooma - addrs.Add(0x129EF); // Rebonak unhorsed - addrs.Add(0x12A05); // Rebonak - addrs.Add(0x12A06); // Barba - addrs.Add(0x12A07); // Carock - addrs.Add(0x15507); // Thunderbird - byte[] enemyBytes = addrs.Select(a => rom.GetByte(a)).ToArray(); - RandomizeEnemyExp(r, enemyBytes, props.EnemyXPDrops); - for (int i = 0; i < addrs.Count; i++) { rom.Put(addrs[i], enemyBytes[i]); }; - } - List addr = new List(); if (props.ShuffleEncounters) { @@ -2460,60 +2379,6 @@ private void RandomizeStartingValues(Assembler a, ROM rom) } - private void RandomizeEnemyAttributes(ROM rom, int baseAddr, T[] groundEnemies, T[] flyingEnemies, T[] generators) where T : Enum - { - List allEnemies = [.. groundEnemies, .. flyingEnemies, .. generators]; - var addrsByte1 = allEnemies.Select(n => baseAddr + (int)(object)n).ToList(); - var addrsByte2 = allEnemies.Select(n => baseAddr + 0x24 + (int)(object)n).ToList(); - var vanillaEnemyBytes1 = addrsByte1.Select(a => rom.GetByte(a)).ToArray(); - var vanillaEnemyBytes2 = addrsByte2.Select(a => rom.GetByte(a)).ToArray(); - - byte[] enemyBytes1 = vanillaEnemyBytes1.ToArray(); - byte[] enemyBytes2 = vanillaEnemyBytes2.ToArray(); - - // enemy attributes byte1 - // ..x. .... sword immune - // ...x .... steals exp - // .... xxxx exp - const int SWORD_IMMUNE_BIT = 0b00100000; - const int XP_STEAL_BIT = 0b00010000; - - if (props.ShuffleSwordImmunity) - { - RandomizeBits(r, enemyBytes1, SWORD_IMMUNE_BIT); - } - if (props.ShuffleEnemyStealExp) - { - RandomizeBits(r, enemyBytes1, XP_STEAL_BIT); - } - if (props.EnemyXPDrops != XPEffectiveness.VANILLA) - { - RandomizeEnemyExp(r, enemyBytes1, props.EnemyXPDrops); - } - - // enemy attributes byte2 - // ..x. .... immune to projectiles - const int PROJECTILE_IMMUNE_BIT = 0b00100000; - - for (int i = 0; i < allEnemies.Count; i++) { - if ((enemyBytes1[i] & SWORD_IMMUNE_BIT) != 0) - { - // if an enemy is becoming sword immune, make it not fire immune - if ((vanillaEnemyBytes1[i] & SWORD_IMMUNE_BIT) == 0) - { - enemyBytes2[i] &= PROJECTILE_IMMUNE_BIT ^ 0xFF; - } - } - } - - // byte4 could be used to randomize thunder immunity - // (then we must probably exclude generators so thunder doesn't destroy them) - // x... .... immune to thunder - - for (int i = 0; i < addrsByte1.Count; i++) { rom.Put(addrsByte1[i], enemyBytes1[i]); } - for (int i = 0; i < addrsByte2.Count; i++) { rom.Put(addrsByte2[i], enemyBytes2[i]); } - } - private void UpdateRom() { foreach (World world in worlds) diff --git a/RandomizerCore/ROM.cs b/RandomizerCore/ROM.cs index 36b3d291..d43d1a55 100644 --- a/RandomizerCore/ROM.cs +++ b/RandomizerCore/ROM.cs @@ -1679,6 +1679,13 @@ jmp ActualLavaDeath """); } + /** + * The function in bank 4 $9C45 (file offset 0x11c55) and bank 5 $A4E9 (file offset 0x164f9) + * are divide functions that are used to display the HP bar for bosses and split it into 8 segments. + * Inputs - A = divisor; X = enemy slot + * + * This function updates all the call sites to these two functions to match the HP for the boss. + */ private void UpdateAllBossHpDivisor(AsmModule a) { a.Code(/* lang=s */$""" @@ -1756,7 +1763,7 @@ bcs @loop jmp $BADA """); - foreach (var (hpaddr, divisoraddr) in RomMap.bossMap) + foreach (var (hpaddr, divisoraddr) in RomMap.bossHpDivisorMap) { int hp = GetByte(hpaddr); Put(divisoraddr, (byte)(hp / 8)); @@ -1771,7 +1778,7 @@ public void SetBossHpBarDivisors(Assembler asm, StatRandomizer randomizedStats) { var bossHpAddr = RomMap.bossHpAddresses[idx]; var newVal = randomizedStats.BossHpTable[idx]; - var (_, bossHpBarAddr) = RomMap.bossMap[idx]; + var (_, bossHpBarAddr) = RomMap.bossHpDivisorMap[idx]; var originalDivisor = GetByte(bossHpBarAddr); var p = new NesPointer(bossHpBarAddr); a.Segment($"PRG{p.Bank}"); diff --git a/RandomizerCore/RomMap.cs b/RandomizerCore/RomMap.cs index 591b0011..f7496658 100644 --- a/RandomizerCore/RomMap.cs +++ b/RandomizerCore/RomMap.cs @@ -141,6 +141,11 @@ class RomMap public const int PALACE346_ENEMY_HP_TABLE = 0x12931; public const int GP_ENEMY_HP_TABLE = 0x15431; public const int DRIPPER_ID = 0x11927; + public const int WEST_ENEMY_STATS_TABLE = 0x54e5; + public const int EAST_ENEMY_STATS_TABLE = 0x94e5; + public const int PALACE125_ENEMY_STATS_TABLE = 0x114e5; + public const int PALACE346_ENEMY_STATS_TABLE = 0x129e5; + public const int GP_ENEMY_STATS_TABLE = 0x154e5; public const int ATTACK_EFFECTIVENESS_TABLE = 0x1E67D; public const int LIFE_EFFECTIVENESS_TABLE = 0x1E2BF; @@ -149,27 +154,30 @@ class RomMap public const int EXPERIENCE_TO_LEVEL_TABLE = 0x1669; public const int EXPERIENCE_TO_LEVEL_TEXT_TABLE = 0x1e42; - /** - * The function in bank 4 $9C45 (file offset 0x11c55) and bank 5 $A4E9 (file offset 0x164f9) - * are divide functions that are used to display the HP bar for bosses and split it into 8 segments. - * Inputs - A = divisor; X = enemy slot - * - * This function updates all the call sites to these two functions to match the HP for the boss. - */ public static readonly List bossHpAddresses = [ - 0x11451, // Horsehead - 0x13C86, // Helmethead - 0x12951, // Rebonack - 0x13041, // Unhorsed Rebonack - 0x12953, // Carock - 0x13C87, // Gooma - 0x12952, // Barba + 0x11451, // Horsehead (regular Palace 125 enemy table) + 0x13C86, // Helmethead ("bank4_Table_for_Helmethead_Gooma") + 0x12951, // Rebonack (regular Palace 346 enemy table) + 0x13041, // Unhorsed Rebonack (hardcoded, not in a table) + 0x12953, // Carock (regular Palace 346 enemy table) + 0x13C87, // Gooma ("bank4_Table_for_Helmethead_Gooma") + 0x12952, // Barba (regular Palace 346 enemy table) // These are bank 5 enemies so we should make a separate table for them // but we can deal with these when we start randomizing their hp // 0x15453, // Thunderbird // 0x15454, // Dark Link ]; - public static readonly List<(int, int)> bossMap = [ + public static readonly List bossExpAddresses = [ + 0x11505, // Horsehead (regular Palace 125 enemy table) + 0x13C88, // Helmethead ("bank4_Table_for_Helmethead_Gooma") + 0x12A05, // Rebonack (regular Palace 346 enemy table) + 0x129EF, // Unhorsed Rebonack (regular Palace 346 enemy table) + 0x12A07, // Carock (regular Palace 346 enemy table) + 0x13C89, // Gooma ("bank4_Table_for_Helmethead_Gooma") + 0x12A06, // Barba (regular Palace 346 enemy table) + 0x15507, // Thunderbird (regular GP enemy table) + ]; + public static readonly List<(int, int)> bossHpDivisorMap = [ (bossHpAddresses[0], 0x13b80), // Horsehead (bossHpAddresses[1], 0x13ae2), // Helmethead (bossHpAddresses[2], 0x12fd2), // Rebonack diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index d8fe56fd..e3246ad2 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Z2Randomizer.RandomizerCore.Enemy; @@ -26,6 +27,13 @@ public class StatRandomizer public byte[] Palace346EnemyHpTable { get; private set; } = null!; public byte[] GpEnemyHpTable { get; private set; } = null!; public byte[] BossHpTable { get; private set; } = null!; + public byte[] WestEnemyStatsTable { get; private set; } = null!; + public byte[] EastEnemyStatsTable { get; private set; } = null!; + public byte[] Palace125EnemyStatsTable { get; private set; } = null!; + public byte[] Palace346EnemyStatsTable { get; private set; } = null!; + public byte[] GpEnemyStatsTable { get; private set; } = null!; + public byte[] BossExpTable { get; private set; } = null!; + protected RandomizerProperties props { get; } #if DEBUG @@ -43,6 +51,7 @@ public StatRandomizer(ROM rom, RandomizerProperties props) ReadMagicEffectiveness(rom); ReadEnemyHp(rom); + ReadEnemyStats(rom); } public void Randomize(Random r) @@ -61,6 +70,7 @@ public void Randomize(Random r) RandomizeRegularEnemyHp(r); RandomizeBossHp(r); FixRebonackHorseKillBug(); + RandomizeEnemyStats(r); } public void Write(ROM rom) @@ -77,6 +87,7 @@ public void Write(ROM rom) WriteMagicEffectiveness(rom); WriteEnemyHp(rom); + WriteEnemyStats(rom); } [Conditional("DEBUG")] @@ -176,13 +187,35 @@ protected void WriteEnemyHp(ROM rom) rom.Put(RomMap.EAST_ENEMY_HP_TABLE, EastEnemyHpTable); rom.Put(RomMap.PALACE125_ENEMY_HP_TABLE, Palace125EnemyHpTable); rom.Put(RomMap.PALACE346_ENEMY_HP_TABLE, Palace346EnemyHpTable); - rom.Put(RomMap.WEST_ENEMY_HP_TABLE, WestEnemyHpTable); rom.Put(RomMap.GP_ENEMY_HP_TABLE, GpEnemyHpTable); for (int i = 0; i < RomMap.bossHpAddresses.Count; i++) { rom.Put(RomMap.bossHpAddresses[i], BossHpTable[i]); } } + protected void ReadEnemyStats(ROM rom) + { + WestEnemyStatsTable = rom.GetBytes(RomMap.WEST_ENEMY_STATS_TABLE, 0x48); + EastEnemyStatsTable = rom.GetBytes(RomMap.EAST_ENEMY_STATS_TABLE, 0x48); + Palace125EnemyStatsTable = rom.GetBytes(RomMap.PALACE125_ENEMY_STATS_TABLE, 0x48); + Palace346EnemyStatsTable = rom.GetBytes(RomMap.PALACE346_ENEMY_STATS_TABLE, 0x48); + GpEnemyStatsTable = rom.GetBytes(RomMap.GP_ENEMY_STATS_TABLE, 0x48); + BossExpTable = [.. RomMap.bossExpAddresses.Select(rom.GetByte)]; // we only have exp byte here + } + + protected void WriteEnemyStats(ROM rom) + { + rom.Put(RomMap.WEST_ENEMY_STATS_TABLE, WestEnemyStatsTable); + rom.Put(RomMap.EAST_ENEMY_STATS_TABLE, EastEnemyStatsTable); + rom.Put(RomMap.PALACE125_ENEMY_STATS_TABLE, Palace125EnemyStatsTable); + rom.Put(RomMap.PALACE346_ENEMY_STATS_TABLE, Palace346EnemyStatsTable); + rom.Put(RomMap.GP_ENEMY_STATS_TABLE, GpEnemyStatsTable); + for (int i = 0; i < RomMap.bossExpAddresses.Count; i++) + { + rom.Put(RomMap.bossExpAddresses[i], BossExpTable[i]); + } + } + protected void RandomizeExperienceToLevel(Random r, bool[] shuffleStat, int[] levelCap, bool scaleLevels) { int[] newTable = new int[24]; @@ -510,7 +543,110 @@ protected void RandomizeBossHp(Random r) } } - protected int RandomInRange(Random r, double minVal, double maxVal) + protected void RandomizeEnemyStats(Random r) + { + RandomizeEnemyAttributes(r, WestEnemyStatsTable, Enemies.WestGroundEnemies, Enemies.WestFlyingEnemies, Enemies.WestGenerators); + RandomizeEnemyAttributes(r, EastEnemyStatsTable, Enemies.EastGroundEnemies, Enemies.EastFlyingEnemies, Enemies.EastGenerators); + RandomizeEnemyAttributes(r, Palace125EnemyStatsTable, Enemies.Palace125GroundEnemies, Enemies.Palace125FlyingEnemies, Enemies.Palace125Generators); + RandomizeEnemyAttributes(r, Palace346EnemyStatsTable, Enemies.Palace346GroundEnemies, Enemies.Palace346FlyingEnemies, Enemies.Palace346Generators); + RandomizeEnemyAttributes(r, GpEnemyStatsTable, Enemies.GPGroundEnemies, Enemies.GPFlyingEnemies, Enemies.GPGenerators); + if (props.EnemyXPDrops != XPEffectiveness.VANILLA) + { + RandomizeEnemyExp(r, BossExpTable, props.EnemyXPDrops); + } + } + + protected void RandomizeEnemyAttributes(Random r, byte[] bytes, T[] groundEnemies, T[] flyingEnemies, T[] generators) where T : Enum + { + List allEnemies = [.. groundEnemies, .. flyingEnemies, .. generators]; + var vanillaEnemyBytes1 = allEnemies.Select(n => bytes[(int)(object)n]).ToArray(); + var vanillaEnemyBytes2 = allEnemies.Select(n => bytes[(int)(object)n + 0x24]).ToArray(); + + byte[] enemyBytes1 = vanillaEnemyBytes1.ToArray(); + byte[] enemyBytes2 = vanillaEnemyBytes2.ToArray(); + + // enemy attributes byte1 + // ..x. .... sword immune + // ...x .... steals exp + // .... xxxx exp + const int SWORD_IMMUNE_BIT = 0b00100000; + const int XP_STEAL_BIT = 0b00010000; + + if (props.ShuffleSwordImmunity) + { + RandomizeBits(r, enemyBytes1, SWORD_IMMUNE_BIT); + } + if (props.ShuffleEnemyStealExp) + { + RandomizeBits(r, enemyBytes1, XP_STEAL_BIT); + } + if (props.EnemyXPDrops != XPEffectiveness.VANILLA) + { + RandomizeEnemyExp(r, enemyBytes1, props.EnemyXPDrops); + } + + // enemy attributes byte2 + // ..x. .... immune to projectiles + const int PROJECTILE_IMMUNE_BIT = 0b00100000; + + for (int i = 0; i < allEnemies.Count; i++) + { + if ((enemyBytes1[i] & SWORD_IMMUNE_BIT) != 0) + { + // if an enemy is becoming sword immune, make it not fire immune + if ((vanillaEnemyBytes1[i] & SWORD_IMMUNE_BIT) == 0) + { + enemyBytes2[i] &= PROJECTILE_IMMUNE_BIT ^ 0xFF; + } + } + } + + // For future reference: + // byte4 could be used to randomize thunder immunity + // (then we must probably exclude generators so thunder doesn't destroy them) + // x... .... immune to thunder + + + for (int i = 0; i < allEnemies.Count; i++) + { + int index = (int)(object)allEnemies[i]; + bytes[index] = enemyBytes1[i]; + bytes[index + 0x24] = enemyBytes2[i]; + } + } + + protected static void RandomizeEnemyExp(Random r, byte[] bytes, XPEffectiveness effectiveness) + { + for (int i = 0; i < bytes.Length; i++) + { + int b = bytes[i]; + int low = b & 0x0f; + + if (effectiveness == XPEffectiveness.RANDOM_HIGH) + { + low++; + } + else if (effectiveness == XPEffectiveness.RANDOM_LOW) + { + low--; + } + else if (effectiveness == XPEffectiveness.NONE) + { + low = 0; + } + + if (effectiveness.IsRandom()) + { + low = r.Next(low - 2, low + 3); + } + + low = Math.Min(Math.Max(low, 0), 15); + + bytes[i] = (byte)((b & 0xf0) | low); + } + } + + protected static int RandomInRange(Random r, double minVal, double maxVal) { int nextVal = (int)Math.Round(r.NextDouble() * (maxVal - minVal) + minVal); nextVal = (int)Math.Min(nextVal, maxVal); @@ -518,7 +654,7 @@ protected int RandomInRange(Random r, double minVal, double maxVal) return nextVal; } - protected void RandomizeInsideArray(Random r, byte[] array, int index, double lower=0.5, double upper=1.5) + protected static void RandomizeInsideArray(Random r, byte[] array, int index, double lower=0.5, double upper=1.5) { var vanillaVal = array[index]; int minVal = (int)(vanillaVal * lower); @@ -527,6 +663,34 @@ protected void RandomizeInsideArray(Random r, byte[] array, int index, double lo array[index] = newVal; } + /// + /// For a given set of bytes, set a masked portion of the value of each byte on or off (all 1's or all 0's) at a rate + /// equal to the proportion of values at the addresses that have that masked portion set to a nonzero value. + /// In effect, turn some values in a range on or off randomly in the proportion of the number of such values that are on in vanilla. + /// + /// Bytes to randomize. + /// What part of the byte value at each address contains the configuration bit(s) we care about. + public static void RandomizeBits(Random r, byte[] bytes, int mask) + { + if (bytes.Length == 0) { return; } + + int notMask = mask ^ 0xFF; + double vanillaBitSetCount = bytes.Where(b => (b & mask) != 0).Count(); + + //proportion of the bytes that have nonzero values in the masked portion + double fraction = vanillaBitSetCount / bytes.Length; + + for (int i = 0; i < bytes.Length; i++) + { + int v = bytes[i] & notMask; + if (r.NextDouble() <= fraction) + { + v |= mask; + } + bytes[i] = (byte)v; + } + } + /// When Rebonack's HP is set to exactly 2 * your damage, it will /// trigger a bug where you kill Rebo's horse while de-horsing him. /// This causes an additional key to drop, as well as softlocking From 4e5a2291c55690d88ef061e614fe46443db59f45 Mon Sep 17 00:00:00 2001 From: initsu Date: Wed, 3 Dec 2025 11:57:45 +0100 Subject: [PATCH 07/10] Set all 1-up experience values to be the same This should fix an inconsistency when cancelling out of a level up, and the next level up value shown. --- RandomizerCore/StatRandomizer.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index e3246ad2..9425f36a 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -280,7 +280,8 @@ protected void RandomizeExperienceToLevel(Random r, bool[] shuffleStat, int[] le newTable = cappedExp; } - // Make sure all 1-up levels are higher cost than regular levels + // Make sure all 1-up levels are the same value, + // one that is higher than any regular level int highestRegularLevelExp = 0; for (int stat = 0; stat < 3; stat++) { @@ -292,17 +293,16 @@ protected void RandomizeExperienceToLevel(Random r, bool[] shuffleStat, int[] le if (highestRegularLevelExp < maxExp) { highestRegularLevelExp = maxExp; } } - int min1upExp = Math.Min(highestRegularLevelExp + 10, 9990); + int exp1upMin = Math.Min(highestRegularLevelExp + 10, 9990); + int exp1up = r.Next(exp1upMin, 9999) / 10 * 10; for (int stat = 0; stat < 3; stat++) { var cap = levelCap[stat]; - Debug.Assert(cap >= 1); + Debug.Assert(cap >= 1 && cap < 9); var statStartIndex = stat * 8; - int stat1upLevelIndex = statStartIndex + cap - 1; - var exp1up = newTable[stat1upLevelIndex]; - if (exp1up < min1upExp) + for (int i = cap - 1; i < 8; i++) { - newTable[stat1upLevelIndex] = r.Next(min1upExp, 9990) / 10 * 10; + newTable[statStartIndex + i] = exp1up; } } From 536b87942cda5467c9967d7873c6004fb284e486 Mon Sep 17 00:00:00 2001 From: initsu Date: Mon, 5 Jan 2026 11:58:36 +0100 Subject: [PATCH 08/10] Add more options for enemy XP randomization - Streamline enum IsRandom() functionality more - Switch to setting the randomized ranges for some enums via attributes --- CrossPlatformUI/Lang/Resources.resx | 16 +++-- RandomizerCore/EnumTypes.cs | 106 ++++++++++++++++++++-------- RandomizerCore/StatRandomizer.cs | 55 ++++----------- 3 files changed, 101 insertions(+), 76 deletions(-) diff --git a/CrossPlatformUI/Lang/Resources.resx b/CrossPlatformUI/Lang/Resources.resx index 7b6c94ac..39fbc4d9 100644 --- a/CrossPlatformUI/Lang/Resources.resx +++ b/CrossPlatformUI/Lang/Resources.resx @@ -240,13 +240,19 @@ immune in each group is the same as the number in that group that are immune in in the dark to change. -Controls how much experience enemies drop. The possible experience values for an enemy are +Controls how much experience enemies give. + +The possible experience values for an enemy are: + 0, 2, 3, 5, 10, 15, 20, 30, 50, 70, 100, 150, 200, 300, 500, 700, 1000. - • Vanilla: Same as the base game - • Low: Each enemy gives a random XP level between -3 and +1 levels of vanilla. - • Average: Each enemy gives a random XP level between -2 and +2 levels of vanilla. - • High: Each enemy gives a random XP level between -1 and +3 levels of vanilla. +When XP is randomized, the enemy's XP value shifts left or right in this list by the rolled number of steps. + +For example, a Blue Stalfos gives 50 XP in the vanilla game. If the XP is rolled -2 to +2 levels, +the minimum XP it could give is 20 and the maximum is 100. + +Also note that XP values are randomized per enemy table and can differ in a single seed. For example +Red Iron Knuckle XP in P1/P2/P5 could roll down while Red Iron Knuckle XP in P3/P4/P6 rolls up. When enabled, helpful hints are scattered throughout the world. These hints are provided diff --git a/RandomizerCore/EnumTypes.cs b/RandomizerCore/EnumTypes.cs index b7b4d662..19ceb361 100644 --- a/RandomizerCore/EnumTypes.cs +++ b/RandomizerCore/EnumTypes.cs @@ -132,48 +132,34 @@ public enum LifeEffectiveness INVINCIBLE } -//I removed no XP drops because literally nobody played it and it saved a bit in the flag string -//If anyone complains I can put it back but... nobody will. [DefaultValue(VANILLA)] public enum XPEffectiveness { - [Description("Vanilla")] + [Description("Vanilla (No Randomization)"), RandomRangeInt(Low = 0, High = 0)] VANILLA, - [Description("Low")] + [Description("Low [-3 to +1]"), RandomRangeInt(Low = -3, High = 1), IsRandom] RANDOM_LOW, - [Description("Average")] + [Description("Average [-2 to +2]"), RandomRangeInt(Low = -2, High = 2), IsRandom] RANDOM, - [Description("High")] + [Description("Average (Low Variance) [-1 to +1]"), RandomRangeInt(Low = -1, High = 1), IsRandom] + LESS_VARIANCE, + [Description("Above Average (Low Variance) [0 to +1]"), RandomRangeInt(Low = 0, High = 1), IsRandom] + SLIGHTLY_HIGH, + [Description("High [-1 to +3]"), RandomRangeInt(Low = -1, High = 3), IsRandom] RANDOM_HIGH, - [Description("None")] + [Description("None"), RandomRangeInt(Low = -15, High = -15)] NONE } -public static class XPEffectivenessExtensions -{ - public static bool IsRandom(this XPEffectiveness effectiveness) - { - return effectiveness switch - { - XPEffectiveness.RANDOM_LOW => true, - XPEffectiveness.RANDOM_HIGH => true, - XPEffectiveness.RANDOM => true, - XPEffectiveness.NONE => false, - XPEffectiveness.VANILLA => false, - _ => throw new Exception("Unrecognized XPEffectiveness") - }; - } -} - public enum EnemyLifeOption { [Description("Vanilla")] VANILLA, - [Description("Medium [-50% to +50%]")] + [Description("Medium [-50% to +50%]"), RandomRangeDouble(Low = 0.5, High = 1.5)] MEDIUM, - [Description("High [-0% to +100%]")] + [Description("High [-0% to +100%]"), RandomRangeDouble(Low = 1.0, High = 2.0)] HIGH, - [Description("Full Range [-50% to +100%]")] + [Description("Full Range [-50% to +100%]"), RandomRangeDouble(Low = 0.5, High = 2.0)] FULL_RANGE, } @@ -326,11 +312,11 @@ public enum Biome VOLCANO, [Description("Caldera")] CALDERA, - [Description("Random (No Vanilla or Shuffle)")] + [Description("Random (No Vanilla or Shuffle)"), IsRandom] RANDOM_NO_VANILLA_OR_SHUFFLE, - [Description("Random (No Vanilla)")] + [Description("Random (No Vanilla)"), IsRandom] RANDOM_NO_VANILLA, - [Description("Random")] + [Description("Random"), IsRandom] RANDOM } @@ -759,6 +745,36 @@ public class StringValueAttribute(string v) : Attribute } } +public class IsRandomAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Field)] +public class RandomRangeDoubleAttribute : Attribute +{ + /// + /// Inclusive lower end of randomized value + /// + public double Low { get; init; } + /// + /// Inclusive upper end of randomized value + /// + public double High { get; init; } +} + +[AttributeUsage(AttributeTargets.Field)] +public class RandomRangeIntAttribute : Attribute +{ + /// + /// Inclusive lower end of randomized value + /// + public int Low { get; init; } + /// + /// Inclusive upper end of randomized value + /// + public int High { get; init; } +} + public record EnumDescription { public object? Value { get; init; } @@ -831,7 +847,7 @@ public static object ToDefault(this Type enumType) } return Enum.ToObject(enumType, (attributes[0] as DefaultValueAttribute)?.Value!); } - + public static EnumDescription ToDescription(this Enum value) { string description; @@ -857,6 +873,36 @@ public static EnumDescription ToDescription(this Enum value) return new EnumDescription() { Value = value, Description = description, Help = help }; } + public static bool IsRandom(this Enum self) + { + Type type = self.GetType(); + string? name = Enum.GetName(type, self); + if (name == null) { return false; } + FieldInfo? fieldInfo = type.GetField(name); + if (fieldInfo == null) { return false; } + return fieldInfo.IsDefined(typeof(IsRandomAttribute), inherit: false); + } + + public static RandomRangeDoubleAttribute? GetRandomRangeDouble(this Enum self) + { + Type type = self.GetType(); + string? name = Enum.GetName(type, self); + if (name == null) { return null; } + FieldInfo? fieldInfo = type.GetField(name); + if (fieldInfo == null) { return null; } + return fieldInfo.GetCustomAttribute(inherit: false); + } + + public static RandomRangeIntAttribute? GetRandomRangeInt(this Enum self) + { + Type type = self.GetType(); + string? name = Enum.GetName(type, self); + if (name == null) { return null; } + FieldInfo? fieldInfo = type.GetField(name); + if (fieldInfo == null) { return null; } + return fieldInfo.GetCustomAttribute(inherit: false); + } + public static string? GetStringValue(Enum value) { string? output = null; diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index 9425f36a..58ba245b 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -233,8 +233,8 @@ protected void RandomizeExperienceToLevel(Random r, bool[] shuffleStat, int[] le for (int i = 0; i < 8; i++) { var vanilla = ExperienceToLevelTable[statStartIndex + i]; - int nextMin = (int)(vanilla - vanilla * 0.25); - int nextMax = (int)(vanilla + vanilla * 0.25); + int nextMin = (int)(vanilla - vanilla * 0.25); // hardcoded -25% + int nextMax = (int)(vanilla + vanilla * 0.25); // hardcoded +25% if (i == 0) { newTable[statStartIndex + i] = r.Next(Math.Max(10, nextMin), nextMax); @@ -383,7 +383,6 @@ protected void RandomizeLifeEffectiveness(Random r, LifeEffectiveness statEffect { return; } - if (statEffectiveness == LifeEffectiveness.OHKO) { Array.Fill(LifeEffectivenessTable, 0xFF); @@ -397,6 +396,7 @@ protected void RandomizeLifeEffectiveness(Random r, LifeEffectiveness statEffect byte[] newTable = new byte[LIFE_EFFECTIVENESS_ROWS * 8]; + // The values we are randomizing are actually *enemy damage* values for (int level = 0; level < 8; level++) { for (int damageCode = 0; damageCode < LIFE_EFFECTIVENESS_ROWS; damageCode++) @@ -525,23 +525,11 @@ protected void RandomizeRegularEnemyHp(Random r) protected void RandomizeBossHp(Random r) { - switch (props.ShuffleBossHP) - { - case EnemyLifeOption.VANILLA: - break; - case EnemyLifeOption.MEDIUM: - for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 0.5, 1.5); } - break; - case EnemyLifeOption.HIGH: - for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 1.0, 2.0); } - break; - case EnemyLifeOption.FULL_RANGE: - for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, 0.5, 2.0); } - break; - default: - throw new NotImplementedException("Invalid ShuffleBossHP value"); + if (props.ShuffleBossHP == EnemyLifeOption.VANILLA) { return; } + + var rr = props.ShuffleBossHP.GetRandomRangeDouble()!; + for (int i = 0; i < BossHpTable.Length; i++) { RandomizeInsideArray(r, BossHpTable, i, rr.Low, rr.High); } } -} protected void RandomizeEnemyStats(Random r) { @@ -617,32 +605,17 @@ protected void RandomizeEnemyAttributes(Random r, byte[] bytes, T[] groundEne protected static void RandomizeEnemyExp(Random r, byte[] bytes, XPEffectiveness effectiveness) { + var rr = effectiveness.GetRandomRangeInt()!; + for (int i = 0; i < bytes.Length; i++) { - int b = bytes[i]; - int low = b & 0x0f; - - if (effectiveness == XPEffectiveness.RANDOM_HIGH) - { - low++; - } - else if (effectiveness == XPEffectiveness.RANDOM_LOW) - { - low--; - } - else if (effectiveness == XPEffectiveness.NONE) - { - low = 0; - } - - if (effectiveness.IsRandom()) - { - low = r.Next(low - 2, low + 3); - } + int byt = bytes[i]; + int nibble = byt & 0x0f; - low = Math.Min(Math.Max(low, 0), 15); + nibble = r.Next(nibble + rr.Low, nibble + rr.High + 1); + nibble = Math.Min(Math.Max(nibble, 0), 15); - bytes[i] = (byte)((b & 0xf0) | low); + bytes[i] = (byte)((byt & 0xf0) | nibble); } } From 43cb5305c4b5f1edfa01e40fea7590f5597e6171 Mon Sep 17 00:00:00 2001 From: initsu Date: Thu, 8 Jan 2026 18:03:09 +0100 Subject: [PATCH 09/10] Better ROM hash calculation - Fix issues with FillPalaceRooms/ApplyAsmPatches using/writing Hyrule.r and Hyrule.ROMData fields instead of the objects passed as arguments. - Actually hash the ROM instead of only hashing shallow values. This still has to be done before any customization is applied, so it wont be the final ROM. There is plenty of room to improve the separation regarding that. - Separate some Hyrule assembly functions so they make more sense. --- RandomizerCore/Hyrule.cs | 175 +++++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 81 deletions(-) diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index ac95c922..78a8d9fa 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -308,24 +308,10 @@ public Hyrule(NewAssemblerFn createAsm, PalaceRooms rooms) assembler.Add(sideviewModule); } - //Allows casting magic without requeueing a spell - if (props.FastCast) - { - ROMData.WriteFastCastMagic(); - } - - bool randomizeMusic = false; - if (props.DisableMusic) - { - ROMData.DisableMusic(); - } - else - randomizeMusic = props.RandomizeMusic; - ROMData.WriteKasutoJarAmount(kasutoJars); ROMData.DoHackyFixes(); ROMData.AdjustGpProjectileDamage(); - + shuffler.ShuffleDrops(ROMData, r); shuffler.ShufflePbagAmounts(ROMData, r); @@ -345,9 +331,6 @@ public Hyrule(NewAssemblerFn createAsm, PalaceRooms rooms) ShortenWizards(); - // startRandomizeStartingValuesTimestamp = DateTime.Now; - // RandomizeEnemyStats(); - firstProcessOverworldTimestamp = DateTime.Now; await ProcessOverworld(progress, ct); if (ct.IsCancellationRequested) { return null; } @@ -412,6 +395,27 @@ public Hyrule(NewAssemblerFn createAsm, PalaceRooms rooms) StatRandomizer randomizedStats = new(ROMData, props); randomizedStats.Randomize(r); randomizedStats.Write(ROMData); + + // ideally this should be calculated later, but custom music changes asm patches + byte[] randoRomHash = MD5Hash.ComputeHash(ROMData.rawdata); + + // ROM changes after this will vary with customize tab options + //Allows casting magic without requeueing a spell + if (props.FastCast) + { + ROMData.WriteFastCastMagic(); + } + + bool randomizeMusic = false; + if (props.DisableMusic) + { + ROMData.DisableMusic(); + } + else + { + randomizeMusic = props.RandomizeMusic; + } + ApplyAsmPatches(props, assembler, r, texts, ROMData, randomizedStats); var rom = await ROMData.ApplyAsm(assembler); @@ -475,14 +479,10 @@ public Hyrule(NewAssemblerFn createAsm, PalaceRooms rooms) byte[] finalRNGState = new byte[32]; r.NextBytes(finalRNGState); - var version = (Assembly.GetEntryAssembly()?.GetName()?.Version) - ?? throw new Exception("Invalid entry assembly version information"); - var versionstr = $"{version.Major}.{version.Minor}.{version.Build}"; byte[] hash = MD5Hash.ComputeHash(Encoding.UTF8.GetBytes( Flags + SeedHash + - versionstr + - // TODO get room file hash + randoRomHash + // ideally this should be all that's required // Util.ReadAllTextFromFile(config.GetRoomsFile()) + Util.ByteArrayToHexString(finalRNGState) )); @@ -1172,10 +1172,11 @@ private async Task FillPalaceRooms(AsmModule sideviewModule) try { ROM testRom = new(ROMData); + Random testRng = new Random(); //This continues to get worse, the text is based on the palaces and asm patched, so it needs to //be tested here, but we don't actually know what they will be until later, for now i'm just //testing with the vanilla text, but this could be an issue down the line. - ApplyAsmPatches(props, validationEngine, r, ROMData.GetGameText(), testRom, new StatRandomizer(testRom, props)); + ApplyAsmPatches(props, validationEngine, testRng, ROMData.GetGameText(), testRom, new StatRandomizer(testRom, props)); validationEngine.Add(sideviewModule); await testRom.ApplyAsm(validationEngine); //.Wait(ct); } @@ -2159,7 +2160,7 @@ private void SwapUpAndDownstab() } //Updated to use fisher-yates. Eventually i'll catch all of these. N is small enough here it REALLY makes a difference - private void ShuffleEncounters(ROM rom, List addr) + private static void ShuffleEncounters(ROM rom, Random r, List addr) { for (int i = addr.Count - 1; i > 0; --i) { @@ -2170,18 +2171,14 @@ private void ShuffleEncounters(ROM rom, List addr) rom.Put(addr[swap], temp); } } - private void RandomizeStartingValues(Assembler a, ROM rom) + + private void RandomizeStartingValues(RandomizerProperties props, Assembler a, Random r, ROM rom) { rom.Put(0x17AF3, (byte)props.StartAtk); rom.Put(0x17AF4, (byte)props.StartMag); rom.Put(0x17AF5, (byte)props.StartLifeLvl); - if (props.RemoveFlashing) - { - rom.DisableFlashing(); - } - if (props.SpellEnemy) { //3, 4, 6, 7, 14, 16, 17, 18, 24, 25, 26 @@ -2247,20 +2244,6 @@ private void RandomizeStartingValues(Assembler a, ROM rom) rom.Put(0x28ba, new byte[] { 0xA5, 0x26, 0xD0, 0x0D, 0xEE, 0xE0, 0x06, 0xA9, 0x01, 0x2D, 0xE0, 0x06, 0xD0, 0x03, 0x4C, 0x98, 0x82, 0x4C, 0x93, 0x82 }); } - //CMP #$20 ; 0x1d4e4 $D4D4 C9 20 - rom.Put(0x1d4e5, props.BeepThreshold); - if (props.BeepFrequency == 0) - { - //C9 20 - EA 38 - //CMP 20 -> NOP SEC - rom.Put(0x1D4E4, (byte)0xEA); - rom.Put(0x1D4E5, (byte)0x38); - } - else - { - //LDA #$30 ; 0x193c1 $93B1 A9 30 - rom.Put(0x193c2, props.BeepFrequency); - } if (props.ShuffleLifeRefill) { @@ -2296,7 +2279,7 @@ private void RandomizeStartingValues(Assembler a, ROM rom) addr.Add(0x4423); } - ShuffleEncounters(rom, addr); + ShuffleEncounters(rom, r, addr); addr = new List(); addr.Add(0x841B); // 0x62: East grass @@ -2317,7 +2300,7 @@ private void RandomizeStartingValues(Assembler a, ROM rom) addr.Add(0x8424); } - ShuffleEncounters(rom, addr); + ShuffleEncounters(rom, r, addr); } if (props.JumpAlwaysOn) @@ -2341,16 +2324,12 @@ private void RandomizeStartingValues(Assembler a, ROM rom) rom.Put(0x17B10, (byte)props.StartGems); - startHearts = props.StartHearts; rom.Put(0x17B00, (byte)startHearts); - maxHearts = props.MaxHearts; - heartContainersInItemPool = maxHearts - startHearts; - rom.Put(0x1C369, (byte)props.StartLives); rom.Put(0x17B12, (byte)((props.StartWithUpstab ? 0x04 : 0) + (props.StartWithDownstab ? 0x10 : 0))); @@ -2376,7 +2355,24 @@ private void RandomizeStartingValues(Assembler a, ROM rom) int drop = r.Next(5) + 4; rom.Put(0x1E8B0, (byte)drop); } + } + private static void ApplyBeepSettings(RandomizerProperties props, ROM rom) + { + //CMP #$20 ; 0x1d4e4 $D4D4 C9 20 + rom.Put(0x1d4e5, props.BeepThreshold); + if (props.BeepFrequency == 0) + { + //C9 20 - EA 38 + //CMP 20 -> NOP SEC + rom.Put(0x1D4E4, (byte)0xEA); + rom.Put(0x1D4E5, (byte)0x38); + } + else + { + //LDA #$30 ; 0x193c1 $93B1 A9 30 + rom.Put(0x193c2, props.BeepFrequency); + } } private void UpdateRom() @@ -2886,7 +2882,7 @@ private byte[] PrgToCpuAddr(int addr) return new byte[] { (byte)(val & 0xff), (byte)(val >> 8) }; } - private void AddCropGuideBoxesToFileSelect(Assembler a) + private static void AddCropGuideBoxesToFileSelect(Assembler a) { a.Module().Code(""" .segment "PRG5" @@ -3043,7 +3039,7 @@ jsr HelmetHeadGoomaFix """, "helmethead_gooma_fix.s"); } - private void RestartWithPalaceUpA(Assembler a) { + private static void RestartWithPalaceUpA(Assembler a) { a.Module().Code(""" .include "z2r.inc" @@ -3109,7 +3105,7 @@ sty temp_room_flag /// /// I assume this fixes the XP on screen transition softlock, but who knows with all these magic bytes. /// - private void FixSoftLock(Assembler a) + private static void FixSoftLock(Assembler a) { a.Module().Code(""" .segment "PRG7" @@ -3128,12 +3124,12 @@ jsr FixSoftlock """, "fix_softlock.s"); } - public void ExpandedPauseMenu(Assembler a) + public static void ExpandedPauseMenu(Assembler a) { a.Module().Code(Util.ReadResource("Z2Randomizer.RandomizerCore.Asm.ExpandedPauseMenu.s"), "expand_pause.s"); } - public void StandardizeDrops(Assembler a) + public static void StandardizeDrops(Assembler a) { a.Module().Code(""" .segment "PRG7" @@ -3152,7 +3148,7 @@ jsr StandardizeDrops """, "standardize_drops.s"); } - public void PreventSideviewOutOfBounds(Assembler a) + public static void PreventSideviewOutOfBounds(Assembler a) { a.Module().Code(""" ; In vanilla, sideview loading routinely reads a few extra bytes past the end of sideview data, @@ -3178,7 +3174,7 @@ beq @EndOfData """, "prevent_sideview_oob.s"); } - public void FixContinentTransitions(Assembler asm) + public void SetPalacePalettes(Assembler asm) { var a = asm.Module(); a.Assign("P1Palette", (byte)palPalettes[westHyrule?.locationAtPalace1.PalaceNumber ?? 0]); @@ -3189,7 +3185,24 @@ public void FixContinentTransitions(Assembler asm) a.Assign("P6Palette", (byte)palPalettes[eastHyrule?.locationAtPalace6.PalaceNumber ?? 5]); a.Assign("PGreatPalette", (byte)palPalettes[eastHyrule?.locationAtGP.PalaceNumber ?? 6]); a.Code(""" +.include "z2r.inc" + +.segment "PRG7" + +.org $ce32 + lda PalacePaletteOffset,y +.reloc +PalacePaletteOffset: + .byte P1Palette, P2Palette, P3Palette, $20, $30, $30, $30, $30, P5Palette, P6Palette, PGreatPalette, $60, P4Palette +""", "set_palace_palettes.s"); + } + + + public static void FixContinentTransitions(Assembler asm) + { + var a = asm.Module(); + a.Code(""" .include "z2r.inc" .import SwapPRG @@ -3220,16 +3233,10 @@ asl a asl a tay -.org $ce32 - lda PalacePaletteOffset,y - .reloc ExpandedRegionBankTable: .byte $01, $01, $02, $02 .reloc -PalacePaletteOffset: - .byte P1Palette, P2Palette, P3Palette, $20, $30, $30, $30, $30, P5Palette, P6Palette, PGreatPalette, $60, P4Palette -.reloc RaftWorldMappingTable: .byte $00, $02, $00, $02 .export RaftWorldMappingTable @@ -3323,7 +3330,7 @@ bpl SetVariablesForRaftTravel """, "fix_continent_transitions.s"); } - private void UpdateTexts(Assembler asm, List hints) + private static void UpdateTexts(Assembler asm, List hints) { var a = asm.Module(); // Clear out the ROM for the existing tables @@ -3369,7 +3376,7 @@ private void AssignRealPalaceLocations(AsmModule a) a.Assign("RealPalaceAtLocationGP", (eastHyrule?.locationAtGP.PalaceNumber ?? 7) - 1); } - public void StatTracking(Assembler asm) + public void StatTracking(RandomizerProperties props, Assembler asm) { var a = asm.Module(); a.Segment("PRG1"); @@ -3420,11 +3427,11 @@ private void ChangeMapperToMMC5(Assembler asm, bool preventFlash, bool enableZ2F a.Code(Util.ReadResource("Z2Randomizer.RandomizerCore.Asm.MMC5.s"), "mmc5_conversion.s"); } - private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Random RNG, List texts, ROM rom, StatRandomizer randomizedStats) + private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Random r, List texts, ROM rom, StatRandomizer randomizedStats) { bool randomizeMusic = !props.DisableMusic && props.RandomizeMusic; - ChangeMapperToMMC5(engine, props.DisableHUDLag, randomizeMusic); + ChangeMapperToMMC5(engine, props.DisableHUDLag, randomizeMusic); // will make output vary with customize tab options rom.AddRandomizerToTitle(engine); AddCropGuideBoxesToFileSelect(engine); FixHelmetheadBossRoom(engine); @@ -3438,7 +3445,7 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando rom.FixItemPickup(engine); rom.FixMinibossGlitchyAppearance(engine); rom.FixBossKillPaletteGlitch(engine); - StatTracking(engine); + StatTracking(props, engine); if (props.ShuffleBossHP != EnemyLifeOption.VANILLA) { @@ -3467,7 +3474,7 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando throw new ImpossibleException(); } byte dripperId = (byte)dripperEnemies[r.Next(dripperEnemies.Length)]; - ROMData.Put(RomMap.DRIPPER_ID, dripperId); + rom.Put(RomMap.DRIPPER_ID, dripperId); if (props.DripperEnemyOption == DripperEnemyOption.EASIER_GROUND_ENEMIES_FULL_HP) { @@ -3493,7 +3500,7 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando if (props.RandomizeKnockback) { - rom.RandomizeKnockback(engine, RNG); + rom.RandomizeKnockback(engine, r); } if (props.HardBosses) @@ -3506,11 +3513,6 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando rom.DashSpell(engine); } - if (props.UpAC1) - { - rom.UpAController1(engine); - } - if (props.UpARestartsAtPalaces) { RestartWithPalaceUpA(engine); @@ -3527,17 +3529,29 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando } FixSoftLock(engine); - RandomizeStartingValues(engine, rom); + RandomizeStartingValues(props, engine, r, rom); rom.FixStaleSaveSlotData(engine); rom.UseExtendedBanksForPalaceRooms(engine); rom.ExtendMapSize(engine); ExpandedPauseMenu(engine); + SetPalacePalettes(engine); FixContinentTransitions(engine); PreventSideviewOutOfBounds(engine); + // things that depend on customize tab options below + ApplyBeepSettings(props, rom); + if (props.RemoveFlashing) + { + rom.DisableFlashing(); + } + if (props.UpAC1) + { + rom.UpAController1(engine); + } + if (!props.DisableMusic && randomizeMusic) { - ROMData.ApplyIps( + rom.ApplyIps( Util.ReadBinaryResource("Z2Randomizer.RandomizerCore.Asm.z2rndft.ips")); // really hacky workaround but i don't want to recompile z2ft // z2ft is compiled using an old address for NmiBankShadow8 and NmiBankShadowA so rather than @@ -3549,9 +3563,9 @@ void PatchAddress(byte opcode, int before, int after) { var idx = 0; var needle = new ReadOnlySpan([opcode, (byte)before, (byte)(before >> 8)]); - while (ROMData.rawdata.AsSpan(idx).IndexOf(needle) is var di and >= 0) + while (rom.rawdata.AsSpan(idx).IndexOf(needle) is var di and >= 0) { - ROMData.Put(idx + di, opcode, (byte)after, (byte)(after >> 8)); + rom.Put(idx + di, opcode, (byte)after, (byte)(after >> 8)); idx += di + 1; } } @@ -3566,7 +3580,6 @@ void PatchAddress(byte opcode, int before, int after) } UpdateTexts(engine, texts); - } //This entire town location shuffle structure is awful if this method needs to exist. From fd474cd4d6a63a4497b92bf599dc7eea77868d95 Mon Sep 17 00:00:00 2001 From: initsu Date: Fri, 9 Jan 2026 20:03:15 +0100 Subject: [PATCH 10/10] Refactor UI flags logic and make flags serialization explicit On the UI side, it uses more reactive programming now. A lot of unnecessary extra flags (de)serializations were removed. --- CommandLine/Program.cs | 2 +- .../FlagsSerializeGenerator.cs | 2 +- CrossPlatformUI/App.axaml.cs | 3 +- .../ViewModels/GenerateRomViewModel.cs | 10 +- CrossPlatformUI/ViewModels/MainViewModel.cs | 36 +++++-- .../ViewModels/RandomizerViewModel.cs | 100 ++++++++---------- .../ViewModels/RomFileViewModel.cs | 3 + .../ViewModels/SaveNewPresetViewModel.cs | 2 +- CrossPlatformUI/Views/RandomizerView.axaml | 4 +- RandomizerCore/Hyrule.cs | 2 +- RandomizerCore/RandomizerConfiguration.cs | 13 +-- 11 files changed, 93 insertions(+), 84 deletions(-) diff --git a/CommandLine/Program.cs b/CommandLine/Program.cs index 79fe26f8..4ef2b7cd 100644 --- a/CommandLine/Program.cs +++ b/CommandLine/Program.cs @@ -105,7 +105,7 @@ public int OnExecute() if (Flags == null) { - Flags = configuration.Flags; + Flags = configuration.SerializeFlags(); } logger.Info($"Flags: {Flags}"); logger.Info($"Rom: {Rom}"); diff --git a/CoreSourceGenerator/FlagsSerializeGenerator.cs b/CoreSourceGenerator/FlagsSerializeGenerator.cs index 1b64b1e2..2ce01281 100644 --- a/CoreSourceGenerator/FlagsSerializeGenerator.cs +++ b/CoreSourceGenerator/FlagsSerializeGenerator.cs @@ -292,7 +292,7 @@ private static void GenerateReactiveProperty(StringBuilder sb, ReactiveFieldInfo sb.AppendLine($"{indent} {{"); sb.AppendLine($"{indent} {field.FieldName} = value;"); sb.AppendLine($"{indent} OnPropertyChanged(nameof({field.PropertyName}));"); - sb.AppendLine($"{indent} OnPropertyChanged(nameof(Flags));"); + sb.AppendLine($"{indent} OnPropertyChanged(\"Flags\");"); sb.AppendLine($"{indent} }}"); sb.AppendLine($"{indent} }}"); sb.AppendLine($"{indent} }}{defaultValue}"); diff --git a/CrossPlatformUI/App.axaml.cs b/CrossPlatformUI/App.axaml.cs index 8a1f524c..57a62965 100644 --- a/CrossPlatformUI/App.axaml.cs +++ b/CrossPlatformUI/App.axaml.cs @@ -62,8 +62,6 @@ public override void Initialize() public static TopLevel? TopLevel { get; private set; } - // public static MainViewModel? Main { get; set; } - private MainViewModel? main; public override void OnFrameworkInitializationCompleted() @@ -175,6 +173,7 @@ private Task PersistStateInternal() [JsonSourceGenerationOptions( WriteIndented = false, IgnoreReadOnlyProperties = true, + UseStringEnumConverter = true, PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate )] [JsonSerializable(typeof(MainViewModel))] diff --git a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs index 2838cc72..d4c72aa5 100644 --- a/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs +++ b/CrossPlatformUI/ViewModels/GenerateRomViewModel.cs @@ -46,9 +46,10 @@ public GenerateRomViewModel(MainViewModel main) var config = host.Config; var version = Assembly.GetEntryAssembly()!.GetName().Version!; var versionstr = $"{version.Major}.{version.Minor}.{version.Build}"; + var flags = config.SerializeFlags(); await clipboard.SetTextAsync($""" Version: {versionstr} -Flags: {config.Flags} +Flags: {flags} Seed: {config.Seed} ``` {lastError} @@ -94,13 +95,14 @@ async void GenerateSeed() var output = await Task.Run(async () => await randomizer.Randomize(romdata, config, UpdateProgress, tokenSource.Token)); if(!tokenSource.IsCancellationRequested) { - var filename = $"Z2_{config.Seed}_{config.Flags}.nes"; + var flags = config.SerializeFlags(); + var filename = $"Z2_{config.Seed}_{flags}.nes"; await files.SaveGeneratedBinaryFile(filename, output!, Main.OutputFilePath); if (config.GenerateSpoiler) { - var spoilerFilename = $"Z2_{config.Seed}_{config.Flags}_spoiler.txt"; + var spoilerFilename = $"Z2_{config.Seed}_{flags}_spoiler.txt"; await files.SaveSpoilerFile(spoilerFilename, randomizer.GenerateSpoiler(), Main.OutputFilePath); - var spoilerMapFilename = $"Z2_{config.Seed}_{config.Flags}_spoiler.png"; + var spoilerMapFilename = $"Z2_{config.Seed}_{flags}_spoiler.png"; await files.SaveGeneratedBinaryFile(spoilerMapFilename, new Spoiler(randomizer.ROMData).CreateSpoilerImage(randomizer.worlds), Main.OutputFilePath); } ProgressHeading = "Generation Complete"; diff --git a/CrossPlatformUI/ViewModels/MainViewModel.cs b/CrossPlatformUI/ViewModels/MainViewModel.cs index 1736e025..5d612bdf 100644 --- a/CrossPlatformUI/ViewModels/MainViewModel.cs +++ b/CrossPlatformUI/ViewModels/MainViewModel.cs @@ -1,9 +1,10 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reactive; using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Text.Json.Serialization; -using System.Diagnostics.CodeAnalysis; -using System.Reactive.Disposables.Fluent; using ReactiveUI; using ReactiveUI.Validation.Helpers; using Z2Randomizer.RandomizerCore; @@ -15,6 +16,11 @@ public class MainViewModel : ReactiveValidationObject, IScreen, IActivatableView { public string? OutputFilePath { get; set; } private RandomizerConfiguration config = new(); + /// Useful inexpensive shared observable for views to attach onto + /// for chaining change detection logic + public IObservable FlagsChanged { get; } + + public IObservable FlagsObservable { get; } [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public RandomizerConfiguration Config { get => config; set => this.RaiseAndSetIfChanged(ref config, value); } @@ -34,6 +40,20 @@ public class MainViewModel : ReactiveValidationObject, IScreen, IActivatableView public MainViewModel() { + FlagsChanged = Observable.FromEventPattern( + h => Config.PropertyChanged += h, + h => Config.PropertyChanged -= h) + .Where(e => e.EventArgs.PropertyName == "Flags") + .Select(_ => Unit.Default) + .Replay(1) + .RefCount(); + + FlagsObservable = FlagsChanged + .Select(_ => this.Config.SerializeFlags()) + .DistinctUntilChanged() + .Replay(1) + .RefCount(); + RomFileViewModel = new(this); GenerateRomViewModel = new(this); SaveNewPresetViewModel = new(this); @@ -43,20 +63,16 @@ public MainViewModel() GenerateRom = ReactiveCommand.CreateFromObservable( () => Router.Navigate.Execute() ); - - this.WhenActivated(ShowRomFileViewIfNoRom); - return; - void ShowRomFileViewIfNoRom(CompositeDisposable disposables) + + this.WhenActivated((CompositeDisposable disposables) => { if (!RomFileViewModel.HasRomData) { Router.Navigate.Execute(RomFileViewModel); } - Disposable.Create(() => { }) - .DisposeWith(disposables); - } + }); } - + // Window/Desktop specific data private const int DefaultWidth = 900; diff --git a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs index fb2cbd0c..b95b342f 100644 --- a/CrossPlatformUI/ViewModels/RandomizerViewModel.cs +++ b/CrossPlatformUI/ViewModels/RandomizerViewModel.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Reactive; using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Avalonia.Controls; @@ -11,16 +13,22 @@ using ReactiveUI; using ReactiveUI.Validation.Extensions; using ReactiveUI.Validation.Helpers; +using Z2Randomizer.RandomizerCore; using CrossPlatformUI.Presets; using CrossPlatformUI.Services; using CrossPlatformUI.ViewModels.Tabs; -using Z2Randomizer.RandomizerCore; namespace CrossPlatformUI.ViewModels; [RequiresUnreferencedCode("ReactiveUI uses reflection")] public class RandomizerViewModel : ReactiveValidationObject, IRoutableViewModel, IActivatableViewModel { + [JsonIgnore] + public IObservable CanGenerateObservable { get; private set; } + + [JsonIgnore] + public BehaviorSubject FlagsValidSubject = new(true); + private bool IsFlagStringValid(string flags) { try @@ -34,6 +42,9 @@ private bool IsFlagStringValid(string flags) } } + [JsonIgnore] + public string FlagInput { get; set { field = value.Trim(); this.RaisePropertyChanged(); } } = ""; + [JsonIgnore] public string Seed { @@ -92,7 +103,7 @@ public RandomizerViewModel(MainViewModel main) { // By writing the flags like this, it will update all the reactive elements watching each // individual fields. - Main.Config.Flags = config.Flags; + Main.Config.DeserializeFlags(config.SerializeFlags()); }); LoadRom = ReactiveCommand.CreateFromObservable( @@ -129,18 +140,16 @@ public RandomizerViewModel(MainViewModel main) ThemeVariantName = App.Current.RequestedThemeVariant?.Key.ToString() ?? "Default"; }); - CanGenerate = this.WhenAnyValue( - x => x.Flags, - x => x.Main.Config.Seed, - x => x.Main.RomFileViewModel.HasRomData, - (flags, seed, hasRomData) => - IsFlagStringValid(flags) && !string.IsNullOrWhiteSpace(seed) && hasRomData - ).CombineLatest(this.Main.GenerateRomViewModel.IsRunning, - (validInput, alreadyRunning) => validInput && !alreadyRunning); + var seedValidObservable = this.WhenAnyValue(x => x.Main.Config.Seed, seed => !string.IsNullOrWhiteSpace(seed)); + + CanGenerateObservable = Observable.CombineLatest( + FlagsValidSubject, seedValidObservable, Main.RomFileViewModel.HasRomDataObservable, Main.GenerateRomViewModel.IsRunning, + (flagsValid, seedValid, hasRom, isRunning) => flagsValid && seedValid && hasRom && !isRunning); + Generate = ReactiveCommand.Create(() => { Main.GenerateRomDialogOpen = true; - }, CanGenerate); + }, CanGenerateObservable); VisitDiscord = ReactiveCommand.CreateFromTask(async control => { @@ -166,7 +175,7 @@ public RandomizerViewModel(MainViewModel main) }); SaveAsPreset = ReactiveCommand.Create((string name) => { - var updatedPreset = new CustomPreset(name, new RandomizerConfiguration { Flags = Main.Config.Flags }); + var updatedPreset = new CustomPreset(name, new RandomizerConfiguration(Main.Config.SerializeFlags())); var collection = Main.SaveNewPresetViewModel.SavedPresets; // makeshift FindIndex since ObservableCollection doesn't have one int presetIndex = -1; @@ -190,42 +199,40 @@ public RandomizerViewModel(MainViewModel main) this.WhenActivated(OnActivate); } - private void OnActivate(CompositeDisposable disposable) + private void OnActivate(CompositeDisposable disposables) { - // If the Flags are entirely default, use the beginner preset - Flags = Main.Config.Flags == new RandomizerConfiguration().Flags - ? BeginnerPreset.Preset.Flags - : Main.Config.Flags.Trim() ?? ""; + var loadedFlags = Main.Config.SerializeFlags(); // this serializes the configuration + var defaultFlags = new RandomizerConfiguration().SerializeFlags(); + // If the flags are entirely default, use the beginner preset + if (loadedFlags == defaultFlags) + { + Main.Config.DeserializeFlags(BeginnerPreset.Preset.SerializeFlags()); + } + + // flag updates from RandomizerConfiguration always overwrites our flag input + Main.FlagsObservable + .Subscribe(flags => FlagInput = flags) + .DisposeWith(disposables); + + this.WhenAnyValue(x => x.FlagInput) + .WithLatestFrom(Main.FlagsObservable, + (Input, Current) => (Input, Current, IsValid: Input == Current || IsFlagStringValid(Input))) + .Do(x => FlagsValidSubject.OnNext(x.IsValid)) + .Where(x => x.IsValid && x.Input != x.Current) + .Subscribe(x => Main.Config.DeserializeFlags(x.Input)) + .DisposeWith(disposables); Main.Config.PropertyChanged += (sender, args) => { switch (args.PropertyName) { - case "Flags": - Flags = ((RandomizerConfiguration)sender!).Flags; - break; case "Seed": this.RaisePropertyChanged(nameof(Seed)); break; } }; - Main.RomFileViewModel.PropertyChanged += (_, args) => - { - switch (args.PropertyName) - { - case "HasRomData": - this.RaisePropertyChanged(nameof(CanGenerate)); - break; - } - }; - var flagsValidation = this.WhenAnyValue( - x => x.Flags, - IsFlagStringValid - ); - this.ValidationRule( - x => x.Flags, - flagsValidation, - "Invalid Flags"); + + this.ValidationRule(x => x.FlagInput, FlagsValidSubject, "Invalid Flags"); AddValidationRules(); } @@ -299,23 +306,6 @@ private void AddValidationRules() }); } - private string validatedFlags = ""; - - [JsonIgnore] - public string Flags - { - get => validatedFlags; - set - { - string trimmedValue = value.Trim() ?? ""; - if (IsFlagStringValid(trimmedValue) && value != Main.Config.Flags) - { - Main.Config.Flags = trimmedValue; - } - this.RaiseAndSetIfChanged(ref validatedFlags, trimmedValue); - } - } - [JsonIgnore] public bool IsDesktop { get; } = !OperatingSystem.IsBrowser(); @@ -347,8 +337,6 @@ public string Flags public ReactiveCommand LoadPreset { get; } [JsonIgnore] public ReactiveCommand LoadRom { get; } - [JsonIgnore] - public IObservable CanGenerate { get; private set; } // Unique identifier for the routable view model. [JsonIgnore] diff --git a/CrossPlatformUI/ViewModels/RomFileViewModel.cs b/CrossPlatformUI/ViewModels/RomFileViewModel.cs index d9ce9b34..43811cf7 100644 --- a/CrossPlatformUI/ViewModels/RomFileViewModel.cs +++ b/CrossPlatformUI/ViewModels/RomFileViewModel.cs @@ -31,6 +31,9 @@ public byte[] RomData [JsonIgnore] public bool HasRomData => !RomData.IsNullOrEmpty(); + [JsonIgnore] + public IObservable HasRomDataObservable => this.WhenAnyValue(x => x.HasRomData); + [JsonConstructor] #pragma warning disable CS8618 public RomFileViewModel() {} diff --git a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs index 9e3a8f2f..57235a97 100644 --- a/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs +++ b/CrossPlatformUI/ViewModels/SaveNewPresetViewModel.cs @@ -84,7 +84,7 @@ public SaveNewPresetViewModel(MainViewModel main) SavePreset = ReactiveCommand.Create(() => { Main.SaveNewPresetDialogOpen = false; // Setting the preset config through the flags creates a deep clone instead of a reference - var preset = new CustomPreset(PresetName, new RandomizerConfiguration { Flags = Main.Config.Flags }); + var preset = new CustomPreset(PresetName, new RandomizerConfiguration(Main.Config.SerializeFlags())); SavedPresets.Add(preset); }); CancelPreset = ReactiveCommand.Create(() => diff --git a/CrossPlatformUI/Views/RandomizerView.axaml b/CrossPlatformUI/Views/RandomizerView.axaml index c26ddbcc..c51c02cc 100644 --- a/CrossPlatformUI/Views/RandomizerView.axaml +++ b/CrossPlatformUI/Views/RandomizerView.axaml @@ -19,7 +19,7 @@ - +