Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions CoreSourceGenerator/FlagsSerializeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ private static bool IsPartialClass(SyntaxNode node)
{
FieldName = f.Name,
FieldType = f.Type.ToDisplayString(),
IsDifficultyOnly = HasDifficultyOnlyAttribute(f),
IsConditionallyIncluded = HasConditionallyIncludedInFlagsAttribute(f),
DefaultValue = GetDefaultValue(f),
IsEnum = f.Type.TypeKind == TypeKind.Enum,
Expand Down Expand Up @@ -133,6 +134,12 @@ private static bool HasConditionallyIncludedInFlagsAttribute(IFieldSymbol field)
.Any(attr => attr.AttributeClass?.Name.StartsWith("ConditionallyIncludeInFlags") ?? false);
}

private static bool HasDifficultyOnlyAttribute(IFieldSymbol field)
{
return field.GetAttributes()
.Any(attr => attr.AttributeClass?.Name.StartsWith("DifficultyOnly") ?? false);
}

private static string GetDefaultValue(IFieldSymbol f)
{
var defaultAttr = f.GetAttributes()
Expand Down Expand Up @@ -209,7 +216,7 @@ private static List<AttributeInfo> GetPassThroughAttributes(IFieldSymbol field)
// Look for attributes with "property:" target
if (attr.AttributeClass == null) continue;
var attrName = attr.AttributeClass.Name;
if (attrName.StartsWith("Reactive") || attrName.StartsWith("CustomFlagSerializer")) continue;
if (attrName.StartsWith("Reactive") || attrName.StartsWith("CustomFlagSerializer") || attrName.StartsWith("DifficultyOnly")) continue;
// if (attrName.EndsWith("Attribute"))
// attrName = attrName[..^9];

Expand Down Expand Up @@ -318,17 +325,27 @@ private static void GenerateReactiveProperty(StringBuilder sb, ReactiveFieldInfo
private static void GenerateSerializeMethod(StringBuilder sb, List<SerializedFieldInfo> fields, string indent)
{
sb.AppendLine();
sb.AppendLine($"{indent} public string Serialize()");
sb.AppendLine($"{indent} private string Serialize(bool includeDifficultyOnly)");
sb.AppendLine($"{indent} {{");
sb.AppendLine($"{indent} global::Z2Randomizer.RandomizerCore.Flags.FlagBuilder flags = new();");
sb.AppendLine();

foreach (var field in fields)
{
var serializeCall = GetSerializeCall(field);
var conditions = new List<string>();
if (field.IsDifficultyOnly)
{
conditions.Add("includeDifficultyOnly");
}
if (field.IsConditionallyIncluded)
{
sb.AppendLine($"{indent} if ({field.FieldName}Included()) {{ ");
conditions.Add($"{field.FieldName}Included()");
}

if (conditions.Count > 0)
{
sb.AppendLine($"{indent} if ({string.Join(" && ", conditions)}) {{ ");
sb.AppendLine($"{indent} {serializeCall};");
sb.AppendLine($"{indent} }}");
}
Expand All @@ -341,6 +358,16 @@ private static void GenerateSerializeMethod(StringBuilder sb, List<SerializedFie
sb.AppendLine();
sb.AppendLine($"{indent} return flags.ToString();");
sb.AppendLine($"{indent} }}");
sb.AppendLine();
sb.AppendLine($"{indent} public string Serialize()");
sb.AppendLine($"{indent} {{");
sb.AppendLine($"{indent} return Serialize(includeDifficultyOnly: true);");
sb.AppendLine($"{indent} }}");
sb.AppendLine();
sb.AppendLine($"{indent} public string SerializeSharedSeed()");
sb.AppendLine($"{indent} {{");
sb.AppendLine($"{indent} return Serialize(includeDifficultyOnly: false);");
sb.AppendLine($"{indent} }}");
}

private static void GenerateDeserializeMethod(StringBuilder sb, List<SerializedFieldInfo> fields, string indent)
Expand Down Expand Up @@ -579,6 +606,7 @@ public class SerializedFieldInfo
{
public string FieldName { get; set; } = string.Empty;
public string FieldType { get; set; } = string.Empty;
public bool IsDifficultyOnly { get; set; }
public bool IsConditionallyIncluded { get; set; }
public string? DefaultValue { get; set; }
public bool IsEnum { get; set; }
Expand All @@ -601,4 +629,4 @@ public class AttributeInfo
{
public string Name { get; set; } = string.Empty;
public List<string> Arguments { get; set; } = new();
}
}
14 changes: 14 additions & 0 deletions CrossPlatformUI/Lang/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,20 @@ randomly selected between these values (inclusive).
<value>Select the number of lives Link starts with when the game begins or on game over.

Random selects a random number of starting lives between 2 and 5.</value>
</data>
<data name="ShareSeedAcrossDifficultyToolTip" xml:space="preserve">
<value>When enabled, the following settings no longer change the shared 4-character seed prefix:
- Starting candle / cross
- Starting sword techniques (upstab / downstab)
- Starting lives
- Starting attack / magic / life levels
- Attack / magic / life level caps and "scale level requirements to cap"
- Attack and life effectiveness
- Enemy HP, boss HP, and enemy XP drop shuffle

The last 2 characters of the hash still reflect these settings, so two seeds with different difficulty will share a prefix but differ in the suffix.

Use this only when everyone intentionally wants the same base seed with different handicaps.</value>
</data>
<data name="RandomFlagRateToolTip" xml:space="preserve">
<value>For flags set to an indeterminate value (question mark), those flags will be on or off at
Expand Down
8 changes: 8 additions & 0 deletions CrossPlatformUI/Views/Tabs/StartView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@
>
<ToolTip.Tip><TextBlock Text="{x:Static lang:Resources.StartingSpellLimitToolTip}"/></ToolTip.Tip>
</ComboBox>
<Separator/>
<CheckBox
Margin="14 8 4 8"
IsChecked="{Binding Config.ShareSeedAcrossDifficulty}"
Content="Allow Difficulty Levels"
>
<ToolTip.Tip><TextBlock Text="{x:Static lang:Resources.ShareSeedAcrossDifficultyToolTip}"/></ToolTip.Tip>
</CheckBox>
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Margin="26 16" HorizontalAlignment="Left">
Expand Down
8 changes: 8 additions & 0 deletions RandomizerCore/Flags/DifficultyOnlyAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace Z2Randomizer.RandomizerCore.Flags;

[AttributeUsage(AttributeTargets.Field)]
public class DifficultyOnlyAttribute : Attribute
{
}
144 changes: 119 additions & 25 deletions RandomizerCore/Hyrule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,22 +229,29 @@ public async Task<RandomizerResult> Randomize(byte[] vanillaRomData, RandomizerC

SeedHash = BitConverter.ToInt32(MD5Hash.ComputeHash(Encoding.UTF8.GetBytes(config.Seed!)).AsSpan()[..4]);
r = new Random(SeedHash);
bool shareSeedAcrossDifficulty = config.ShareSeedAcrossDifficulty;
Random? difficultyRng = shareSeedAcrossDifficulty ? CreateDifficultyRng(config.Seed) : null;

config.CheckForFlagConflicts();
props = config.Export(r);
props = config.Export(r, includeDifficulty: !shareSeedAcrossDifficulty);
//To make sure there isn't any similarity between the spoiler and non-spoiler versions of the seed, spin the RNG a bit.
if(config.GenerateSpoiler)
{
r.NextBytes(new byte[64]);
difficultyRng?.NextBytes(new byte[64]);
}
#if UNSAFE_DEBUG
string export = JsonSerializer.Serialize(props, SourceGenerationContext.Default.RandomizerProperties);
Debug.WriteLine(export);
#endif
Flags = config.SerializeFlags();

// If shared difficulty is on, we need a stripped version of the
// flags for the shared part of the seed hash.
string sharedSeedFlags = shareSeedAcrossDifficulty ? config.SerializeSharedSeedFlags() : Flags;

using Assembler assembler = CreateAssemblyEngine();
logger.Info($"Started generation for flags: {Flags} seed: {config.Seed} seedhash: {SeedHash}");
logger.Info($"Started generation for flags: {Flags} sharedseedflags: {sharedSeedFlags} seed: {config.Seed} seedhash: {SeedHash}");
//character = new Character(props);
shuffler = new Shuffler(props);

Expand Down Expand Up @@ -413,7 +420,22 @@ public async Task<RandomizerResult> Randomize(byte[] vanillaRomData, RandomizerC

List<Text> texts = CustomTexts.GenerateTexts(AllLocationsForReal(), itemLocs, ROMData.GetGameText(), props, r);
StatRandomizer randomizedStats = new(ROMData, props);
randomizedStats.Randomize(r);
randomizedStats.Randomize(r, skipDifficultyOnly: shareSeedAcrossDifficulty);

// Apply difficulty after shared randomization, then let ApplyAsmPatches see full
// props. A second ApplyAsm pass would re-claim PRG4/PRG5 free space (js65 sees it
// as fully free) and clobber first-pass code like the palace elevator routines.
byte[]? sharedRngState = null;
if (shareSeedAcrossDifficulty)
{
sharedRngState = new byte[32];
r.NextBytes(sharedRngState);

config.ApplyDifficultyOnlySettings(props, difficultyRng!);
ReplaceDifficultyStartingItemsInWorld(difficultyRng!);
randomizedStats.RandomizeDifficultyOnly(difficultyRng!);
}

randomizedStats.Write(ROMData);

// ideally this should be calculated later, but custom music changes asm patches
Expand Down Expand Up @@ -498,31 +520,25 @@ public async Task<RandomizerResult> Randomize(byte[] vanillaRomData, RandomizerC
}
}

byte[] finalRNGState = new byte[32];

r.NextBytes(finalRNGState);
byte[] hash = MD5Hash.ComputeHash(Encoding.UTF8.GetBytes(
Flags +
SeedHash +
randoRomHash + // ideally this should be all that's required
// Util.ReadAllTextFromFile(config.GetRoomsFile()) +
Util.ByteArrayToHexString(finalRNGState)
));

UpdateRom();

//0 -> W to avoid 0/O confusion, also 6/G so 6 -> X (these are not hypothetical, they have already caused confusion)
byte[] z2Hash = ConvertHash(hash);
for(int i = 0; i < z2Hash.Length; i++)
byte[] z2Hash;
if (shareSeedAcrossDifficulty)
{
if(z2Hash[i] == 0xD0)
{
z2Hash[i] = 0xF0;
}
if (z2Hash[i] == 0xD6)
{
z2Hash[i] = 0xF1;
}
byte[] sharedHash = CalculateHash(sharedSeedFlags, SeedHash, randoRomHash, sharedRngState!);

byte[] difficultyRngState = new byte[32];
difficultyRng!.NextBytes(difficultyRngState);
byte[] finalHash = CalculateHash(Flags, SeedHash, randoRomHash, difficultyRngState);
z2Hash = CombineHashes(sharedHash, finalHash);
}
else
{
byte[] finalRngState = new byte[32];
r.NextBytes(finalRngState);
byte[] finalHash = CalculateHash(Flags, SeedHash, randoRomHash, finalRngState);
z2Hash = ConvertHash(finalHash);
SanitizeHashCharacters(z2Hash);
}

ROMData.Put(0x17C2C, z2Hash);
Expand Down Expand Up @@ -590,6 +606,84 @@ private static byte[] ConvertHash(byte[] hash)
];
}

private static byte[] CalculateHash(string flags, int seedHash, byte[] romHash, byte[] rngState)
{
return MD5Hash.ComputeHash(Encoding.UTF8.GetBytes(
flags +
seedHash +
romHash +
Util.ByteArrayToHexString(rngState)
));
}

private static Random CreateDifficultyRng(string? seed)
{
int difficultySeedHash = BitConverter.ToInt32(MD5Hash.ComputeHash(Encoding.UTF8.GetBytes($"{seed}:difficulty")).AsSpan()[..4]);
return new Random(difficultySeedHash);
}

private static byte[] CombineHashes(byte[] sharedHash, byte[] finalHash)
{
byte[] sharedZ2Hash = ConvertHash(sharedHash);
byte[] finalZ2Hash = ConvertHash(finalHash);
SanitizeHashCharacters(sharedZ2Hash);
SanitizeHashCharacters(finalZ2Hash);

return [
sharedZ2Hash[0], 0xF4,
sharedZ2Hash[2], 0xF4,
sharedZ2Hash[4], 0xF4,
sharedZ2Hash[6], 0xF4,
finalZ2Hash[8], 0xF4,
finalZ2Hash[10]
];
}

private static void SanitizeHashCharacters(byte[] z2Hash)
{
for (int i = 0; i < z2Hash.Length; i++)
{
if (z2Hash[i] == 0xD0)
{
z2Hash[i] = 0xF0;
}
if (z2Hash[i] == 0xD6)
{
z2Hash[i] = 0xF1;
}
}
}

// In shared-seed mode, candle/cross are placed in the world during the
// shared shuffle (StartCandle/StartCross were false at Export time). If
// the final difficulty flags have the player starting with either item,
// swap that item's world pickup for a minor item so the seed doesn't
// hand out a duplicate.
private void ReplaceDifficultyStartingItemsInWorld(Random r)
{
List<Collectable> minorItems = [
Collectable.BLUE_JAR, Collectable.RED_JAR, Collectable.SMALL_BAG,
Collectable.MEDIUM_BAG, Collectable.LARGE_BAG, Collectable.XL_BAG,
Collectable.ONEUP, Collectable.KEY
];
Collectable[] difficultyStartingItems = [Collectable.CANDLE, Collectable.CROSS];

foreach (Collectable item in difficultyStartingItems)
{
if (!props.StartsWithCollectable(item)) { continue; }
foreach (Location location in itemLocs)
{
for (int i = 0; i < location.Collectables.Count; i++)
{
if (location.Collectables[i] == item)
{
location.Collectables[i] = minorItems.Sample(r);
}
}
}
}
}

/*
Text Notes:

Expand Down
Loading
Loading