Stardew Valley PRNG Seed Cracking

by

Joseph B

July

2024

At Interrupt Labs, some of us started our journeys into vulnerability research by reverse engineering video games. Whilst these days we spend our time hacking things like phones, malware, browsers, and even cars, a few of us still like to investigate video games when we get a chance.

Introduction

Stardew Valley, a widely popular farming simulation game, uses a Pseudo-Random Number Generator (PRNG) to determine information such as what stock is available at a store, what treasure will be obtained from cracking a geode and what enchantment will be applied to a tool. Since so many aspects of the game are “random”, many players optimise their gameplay by using tools such as Stardew Predictor to extract the PRNG seed from their save file and determine the outcome of future events. Unfortunately, this option is not available to console or mobile players for a couple of reasons:

  • Some platforms (such as the Nintendo Switch) don’t allow save files to be transferred to an external storage device, meaning that they can’t be inspected to extract the PRNG seed.
  • Even if the seed could be determined, the PRNG and certain parts of game logic differ between platforms, meaning that any predictions would be incorrect.

This blog post describes the process of reverse engineering the Nintendo Switch version of Stardew Valley to build two tools:

Why the Switch?

We will focus on the Switch version of the game for a couple of reasons. Firstly, according to community polls (1, 2), the Switch is by far the most played platform (other than PC). Secondly, it is the easiest modern console to extract game binaries from (due to an unpatchable vulnerability present in early models of the console).

We won’t cover the process for extracting game binaries here, but the Switch has a large homebrew community and plenty of guides can be found online.

Locating the PRNG

Let’s start by using Ghidra Switch Loader to import Stardew Valley’s main binary into Ghidra. After the file is analysed, we can search for path strings to try and identify what sort of technologies the game uses. The following seem fairly interesting:

D:/Projects/Stardew/MonoGame/Switch/Brute/MonoGame.Framework/Microsoft/Xna/Framework/Graphics/stb_image.h
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/System/Threading/ThreadPool.icall.cpp
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/System/String.icall.cpp
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/System/IO/MonoIO.icall.cpp
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/System/Array.icall.cpp
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/1nternal/SyncBlock.cpp
D:/Projects/Stardew/MonoGame/Switch/Brute/mono/mscorlib/1nternal/ScratchArray.cpp

And searching for “monogame brute” brings us to a website which explains that BRUTE is a tool for converting C# games into C++. It mentions support for the Nintendo Switch and explicitly states that BRUTE is used by Stardew Valley.

BRUTE Website

The PC version of Stardew Valley is developed using MonoGame (a C# framework), so it seems likely that the Switch version is very similar, but transpiled to C++. Unfortunately, BRUTE is closed-source (likely due to console SDK NDAs), so we can’t explore how the transpilation works. We can, however, use the PC version as reference for locating different parts of the game logic, which is very helpful since tools such as ILSpy can decompile C# bytecode almost perfectly.

We’ll search through the PC version for a small method that uses the PRNG and contains a fairly unique string. GenerateBundles looks ideal:

public static void GenerateBundles(BundleType bundle_type, bool use_seed = true)
{
	Random r = null;
	r = ((!use_seed) ? new Random() : new Random((int)uniqueIDForThisGame * 9));
	if (bundle_type == BundleType.Remixed)
	{
		Dictionary<string, string> bundle_data = new BundleGenerator().Generate("Data\\RandomBundles", r);
		netWorldState.Value.SetBundleData(bundle_data);
	}
	else
	{
		netWorldState.Value.SetBundleData(content.LoadBase<Dictionary<string, string>>("Data\\Bundles"));
	}
}

It has both the plain and seeded constructor, and a unique looking Data\\RandomBundles string.

Searching for the string in Ghidra yields one result, which is referenced by one function. The function is too big for Ghidra to decompile, but the assembly looks like this:

adrp       x0 ,0x7107417000
add        x0 => u_Data\RandomBundles_710741768a  ,x0 ,#0x68a
mov        w1 ,w25
bl         FUN_7100012a2
adrp       x8 ,0x710a992000
ldr        x8 ,[x8 , #0x268 ]=> PTR_DAT_710a992268
str        x0 ,[x8 ]=> DAT_710ab78df8

A similar pattern is repeated many times:

  • Move a string pointer into x0.
  • Move a number into w1 (seems to be the string length).
  • Call FUN_7100012a2.
  • Store x0 at some address.

Towards the bottom of FUN_7100012a2 there is a log message with the string BRUTE_AllocConstString. At this point we can make an educated guess that BRUTE_AllocConstString is called with a string and its length, and returns a pointer to that string, allocated in some way.

Looking at DAT_710ab78df8, we see that it is read once by FUN_71011127b, which decompiles to the following:

void FUN_71011127b0(int param_1,ulong param_2)

{
  int iVar1;
  undefined8 uVar2;
  code **ppcVar3;
  undefined8 uVar4;
  long lVar5;
  
  if (DAT_710c157bac != 0) {
    FUN_7100001040();
  }
  if ((param_2 & 1) == 0) {
    uVar2 = FUN_710027d9c0(0);
  }
  else {
    uVar2 = FUN_710027da60(0,DAT_710ab96428 * 9);
  }
  if (param_1 != 1) {
    lVar5 = *(long *)(DAT_710ab96568 + 0x58);
    uVar2 = FUN_7100f43120(DAT_710ab95da0,DAT_710ab78a30);
    if (((DAT_710ab11be8 & 1) == 0) && (iVar1 = __cxa_guard_acquire(&DAT_710ab11be8), iVar1 != 0 )) {
      DAT_710ab11be0 = FUN_7105874aac();
      __cxa_guard_release(&DAT_710ab11be8);
    }
    ppcVar3 = (code **)FUN_7100000dc0(lVar5,DAT_710ab11be0,0x2a);
                    /* WARNING: Could not recover jumptable at 0x007101112870. Too many branches */
                    /* WARNING: Treating indirect jump as call */
    (**ppcVar3)(lVar5 + (ulong)*(byte *)(ppcVar3 + 1),uVar2);
    return;
  }
  uVar4 = FUN_7100f7e550(0);
  uVar2 = FUN_7100f7e5f0(uVar4,DAT_710ab78df8,uVar2);
  lVar5 = *(long *)(DAT_710ab96568 + 0x58);
  if (((DAT_710ab11be8 & 1) == 0) && (iVar1 = __cxa_guard_acquire(&DAT_710ab11be8), iVar1 != 0))  {
    DAT_710ab11be0 = FUN_7105874aac();
    __cxa_guard_release(&DAT_710ab11be8);
  }
  ppcVar3 = (code **)FUN_7100000dc0(lVar5,DAT_710ab11be0,0x2a);
                    /* WARNING: Could not recover jumptable at 0x0071011128f8. Too many branches */
                    /* WARNING: Treating indirect jump as call */
  (**ppcVar3)(lVar5 + (ulong)*(byte *)(ppcVar3 + 1),uVar2);
  return;
}

This seems to be the GenerateBundles method, and from the * 9, it seems likely that FUN_710027da60 is the PRNG constructor. Here is FUN_710027da60’s decompilation:

long FUN_710027da60(long param_1,int param_2)
{
  int iVar1;
  
  if (param_1 == 0) {
    if (((DAT_710aafd2f0 & 1) == 0) && (iVar1 = __cxa_guard_acquire(&DAT_710aafd2f0), iVar1 != 0 )) {
      DAT_710aafd2e8 = FUN_7101cc237c();
      __cxa_guard_release(&DAT_710aafd2f0);
    }
    param_1 = FUN_71000007b0(DAT_710aafd2e8);
  }
  FUN_710027bf90(param_1);
  *(undefined8 *)(param_1 + 0x14) = 0x2937ba43ade68b1;
  *(int *)(param_1 + 0x10) = param_2 * 0x12bf507d + 0x12d687;
  *(undefined4 *)(param_1 + 0x1c) = 0x63d771;
  return param_1;
}

We can deduce that the second parameter is the seed and the first parameter is the location at which the PRNG class will be stored (NULL causing new memory to be allocated, as is a common pattern).

A search for “314527869 6543217 1234567” (some of the integers in the constructor, converted to decimal), we discover a paper that introduces a PRNG called JKISS. After a bit more searching, we discover this commit which suggests that at some point in the past, JKISS was used as mscorlib’s PRNG. This makes a lot of sense, since the PC version of Stardew Valley uses a different, more modern version of mscorlib’s PRNG.

After some renaming in Ghidra, the PRNG’s constructor looks like this:

Random * Random_seeded(Random *this,uint32_t seed)
{
  // ...
  this->y = 987654321;
  this->z = 43219876;
  this->x = seed * 314527869 + 1234567;
  this->c = 6543217;
  return this;
}

Which is very similar to the old mscorlib PRNG constructor:

public Random (int Seed)
{
	x = (uint) Seed;
	y = (uint) 987654321;
	z = (uint) 43219876;
	c = (uint) 6543217;
}

Although, oddly, the multiplication and addition operation, usually only applied to x during a generation step, seems to also be applied in the constructor for the BRUTE version.

Now lets’s try and find how BRUTE implements Next and NextDouble. Looking at references to the PRNG’s constructor and tracing the return value, we start to see calls like this:

local_34 = (**(code **)(*(long *)(*(long *)random + 8) + 0x50))
                      (&random->field_0x0 + *(byte *)(*(long *)(*(long *)random + 8) + 0x58) ,3)
;

Which suggests that some sort of VTable is being used to resolve the methods. After a bit of searching, we come accross FUN_7101cc24fc which contains snippets such as:

ppwVar4[0x3c] = L"NextBytes";
*(undefined4 *)((long)ppwVar4 + 0x204) = 0x1c6;
ppwVar4[0x42] = (wchar16 *)&DAT_7101a497a0;
ppwVar4[0x43] = (wchar16 *)ppwVar3;
ppwVar4[0x48] = L"NextDouble";
ppwVar4[0x3e] = (wchar16 *)FUN_7101cc29ec;
ppwVar4[0x3f] = (wchar16 *)FUN_710027de30;

With a bit of deduction, we can work out parts of the structures and clean the function up:

vtable * make_Random_vtable(void)
{
  vtable_parameter *seed_parameters;
  vtable_parameter *pvVar1;
  vtable_parameter *buffer_parameters;
  vtable *vtable;
  seed_parameters = (vtable_parameter *)malloc_and_memset(0x18);
  seed_parameters->name = L"Seed";
  seed_parameters->field1_0x8 = &LAB_7101a49024;
  pvVar1 = (vtable_parameter *)malloc_and_memset(0x30);
  pvVar1->name = L"minValue";
  pvVar1->field1_0x8 = &LAB_7101a49024;
  pvVar1[1].name = L"maxValue";
  pvVar1[1].field1_0x8 = &LAB_7101a49024;
  buffer_parameters = (vtable_parameter *)malloc_and_memset(0x18);
  buffer_parameters->name = L"buffer";
  buffer_parameters->field1_0x8 = &DAT_7100526e60;
  vtable = (vtable *)malloc_and_memset(0x300);
  vtable->field15_0x24 = 0x1886;
  vtable->name = L".ctor";
  vtable[1].name = L".ctor";
  vtable->field9_0x10 = &LAB_7101cc22dc;
  vtable->method = Random_unseeded;
  vtable->field24_0x30 = &DAT_7101a497a0;
  vtable[1].field9_0x10 = &DAT_7101cc22e0;
  vtable[1].method = Random_seeded;
  vtable[1].field15_0x24 = 0x1886;
  vtable[1].field24_0x30 = &DAT_7101a497a0;
  vtable[1].parameters = seed_parameters;
  vtable[1].field_0x40 = 1;
  vtable[2].name = L"Next";
  vtable[2].field24_0x30 = &LAB_7101a49024;
  vtable[2].parameters = pvVar1;
  vtable[2].field9_0x10 = &DAT_7101cc26f0;
  vtable[2].method = Random_Next_2;
  vtable[2].field15_0x24 = 0x1c6;
  vtable[2].field_0x40 = 2;
  vtable[3].name = L"Next";
  vtable[3].field9_0x10 = &DAT_7101cc2848;
  vtable[3].method = Random_Next_1;
  vtable[3].field15_0x24 = 0x1c6;
  vtable[3].field24_0x30 = &LAB_7101a49024;
  vtable[3].parameters = seed_parameters;
  vtable[3].field_0x40 = 1;
  vtable[4].name = L"Next";
  vtable[4].field9_0x10 = &DAT_7101cc2948;
  vtable[4].method = Random_Next_0;
  vtable[4].field15_0x24 = 0x1c6;
  vtable[5].field_0x40 = 1;
  vtable[4].field24_0x30 = &LAB_7101a49024;
  vtable[5].name = L"NextBytes";
  vtable[5].field15_0x24 = 0x1c6;
  vtable[5].field24_0x30 = &DAT_7101a497a0;
  vtable[5].parameters = buffer_parameters;
  vtable[6].name = L"NextDouble";
  vtable[5].field9_0x10 = FUN_7101cc29ec;
  vtable[5].method = Random_NextBytes;
  vtable[6].method = Random_NextDouble;
  vtable[6].field9_0x10 = &DAT_7101cc2a1c;
  vtable[6].field15_0x24 = 0x1c6;
  vtable[6].field24_0x30 = &DAT_7101a55404;
  vtable[7].name = L"Sample";
  vtable[7].field9_0x10 = &DAT_7101cc2ac0;
  vtable[7].field15_0x24 = 0x1c4;
  vtable[7].method = Random_Sample;
  vtable[7].field24_0x30 = &DAT_7101a55404;
  return vtable;
}

Looking at the function for Next with two parameters (after a bit of renaming):

int Random_Next_2(Random *this,int32_t min,int32_t max)
{
  undefined8 *puVar1;
  undefined8 uVar2;
  long t;
  uint result_div_diff;
  uint result;
  uint diff;
  uint32_t x_;
  uint y_;
  if (DAT_710c157bac != 0) {
    FUN_7100001040();
  }
  diff = max - min;
  if (min <= max) {
    if (1 < diff) {
      x_ = this->x * 0x12bf507d + 0x12d687;
      y_ = this->y ^ this->y << 5;
      y_ = y_ ^ y_ >> 7;
      t = (ulong)this->c + (ulong)this->z * 0xfffa2849;
      y_ = y_ ^ y_ << 0x16;
      this->x = x_;
      this->y = y_;
      result = y_ + x_ + (int)t;
      result_div_diff = 0;
      if (diff != 0) {
        result_div_diff = result / diff;
      }
      *(long *)&this->z = t;
      min = (result - result_div_diff * diff) + min;
    }
    return min;
  }
  puVar1 = (undefined8 *)__cxa_allocate_exception(8);
  uVar2 = FUN_710019d640(0,DAT_710ab807a0);
  *puVar1 = uVar2;
                    /* WARNING: Subroutine does not return */
  __cxa_throw(puVar1,&DAT_710a047208,0);
}

We can see that it is identical to that in the old mscorlib PRNG (except that Jkiss () is inlined):

uint JKiss ()
{
	x = 314527869 * x + 1234567;
	y ^= y << 5;
	y ^= y >> 7;
	y ^= y << 22;
	ulong t = ((ulong) 4294584393 * z + c);
	c = (uint) (t >> 32);
	z = (uint) t;
	return (x + y + z);
}
public virtual int Next (int minValue, int maxValue)
{
	if (minValue > maxValue)
		throw new ArgumentOutOfRangeException ("Maximum value is less than minimal value.");
	// special case: a difference of one (or less) will always return the minimum
	// e.g. -1,-1 or -1,0 will always return -1
	uint diff = (uint) (maxValue - minValue);
	if (diff <= 1)
		return minValue;
	return minValue + ((int) (JKiss () % diff));
}

The bizarre use of result - (result / diff) * diff, rather than result % diff is likely because % in C# is actually the remainder operation, rather than the modulus, not that it really matters here since result will always be positive.

The other operations, such as NextDouble also seem to be identical to the old mscorlib PRNG, so we can conclude that the Switch version of Stardew Valley uses this PRNG, but with the constructor slightly modified as mentioned above.

Cracking the Seed

Finding a Good Use

We will start by using the PC version of Stardew Valley to identify possible methods of cracking the seed, since the code-bases seem very similar and analysing compiled C# is far easier than analysing compiled C++.

Using ILSpy to find uses of Game1.uniqueIDForThisGame (the seed) yields 162 results. Some of these can be instantly discarded:

  • Removing results where the seed is divided or multiplied (since we want to recover the full seed) leaves 94.
  • Removing results that combine the seed with coordinates (since these can be difficult for the player to determine) leaves 66.
  • Removing results that never end up being shown to the user leaves 50.
  • Deduplicating results leaves 46.

Of the remaining results, the only one not used to seed a PRNG is used in calculating if a Metal Head enemy will drop the Squire’s Helmet item. This, however, uses the seed modulo 100, so can also be discarded.

For the remaining PRNG results, we can build a table with:

  • What the PRNG decides.
  • The requirements to show the PRNG results.
  • The number of possible PRNG outputs, as displayed to the user (log base 2). This should be significantly more than 32 (the size of the seed) to reduce the risk of collisions.

Description

Requirements

Possibilities (log_2)

Item enchantment.

Forge.

2

Star token shop random stock.

Fair (once per year).

2

Passed out message.

Passing out.

2

Gem bird positions

Ginger Island.

4

Gem bird types.

Ginger Island.

6

Regular and Qi special orders.

Year 2 for regular. 100 golden walnuts for Qi.

14

Weekly crops that earn extra.

Selling every crop, every week, for a month.

11

Beach farm forage items.

Beach farm.

2

Trash Bear first two items.

Year 3. Trash Bear quests not complete.

4

Delivery quest item.

None.

4

Fake farmer names.

None.

16

Stardew Hero scores.

None.

6

Daily quest type.

None.

2

Struck lightning rods.

Lightning. Time of event.

Number of lightning rods

Trash bear second two items.

Year 3. Trash Bear quests not complete.

6

First child gender.

Having a child.

1

Child skin colour.

Having a child. Parent skin colours differ.

1

Crop targeted by fairy.

Fairy event. Coordinates.

log_2(Number of crops)

UFO, meteorite of owl event location.

Event. Coordinates.

log_2(Number of empty farm tiles)

Witch event slime colours.

Witch event. Chooses slime hut. Colour analysis.

log_2(pow(1000, Green slime count))

Random Night Market stock.

Feast of the Winter Star (once per year).

131

Random Traveling Cart stock.

Friday or Sunday.

131

Gorilla eats banana.

Ginger Island.

1

Bone hints.

Ginger Island. Not all bones dontated.

3

Golden walnut hints.

Ginger Island. Not all golden walnuts found.

4

Golden walnuts from fishing.

Ginger Island. Not all golden walnuts fished.

1

Marsupial present.

Ginger Island.

1

Slime locations (secret).

Ginger Island. Coordinates.

26

Moonlight jelly locations (south).

Ginger Island. Winter. Coordinates.

log_2(pow(Number of valid water tiles, 50))

Moonlight jelly locations (south east).

Ginger Island. Winter. Coordinates.

log_2(pow(Number of valid water tiles, 50))

Moonlight jelly locations (south east cave).

Ginger Island. Winter. Coordinates.

log_2(pow(Number of valid water tiles, 50))

Moonlight jelly locations (west).

Ginger Island. Winter. Coordinates.

log_2(pow(Number of valid water tiles, 50))

Slime locations (west).

Ginger Island. Coordinates.

36

Mine shaft levels fallen.

None.

3

Movie Theater lobby NPCs.

Movie Theater.

14

Movie Theater lobby NPC speech.

Movie Theater.

2

Movie Theater screen NPCs.

Movie Theater.

4

Trash Bear sprite.

Trash Bear quests complete.

1

Volcano Dungeon random stock.

Ginger Island.

2

Woods slime presence.

Depends on non-seeded random.

0

Shop owner dialogue.

None.

2

Calico Jack cards.

Casino.

12

Phone ringing and NPC.

Phone.

4

Quest types.

None.

10

Sap quantity.

Tap.

2

From the table, it seems that random Traveling Cart stock is the only result that is both easy for the player to access, and gives enough information to crack the seed. Let’s explore the C# code in more detail.

We start at StardewValley.Locations.Forest.checkAction, which calls StardewValley.Utility.getTravelingMerchantStock with the seed, plus the number of days played (this can easlily be calculated from the in-game day, season and year):

public override bool checkAction(Location tileLocation, xTile.Dimensions.Rectangle viewport, Farmer who)
{
	//...
	if (travelingMerchantDay && Game1.timeOfDay < 2000)
	{
		if (tileLocation.X == 27 && tileLocation.Y == 11)
		{
			Game1.activeClickableMenu = new ShopMenu(Utility.getTravelingMerchantStock((int)(Game1.uniqueIDForThisGame + Game1.stats.DaysPlayed)), 0, "Traveler", Utility.onTravelingMerchantShopPurchase);
			return true;
		}
		// ...
	}
	// ...
}

Utility.getTravelingMerchantStock in-turn calls StardewValley.Utility.generateLocalTravelingMerchantStock:

public static Dictionary<ISalable, int[]> getTravelingMerchantStock(int seed)
{
	Dictionary<ISalable, int[]> localStock = generateLocalTravelingMerchantStock(seed);
	// ...
	return localStock;
}

And StardewValley.Utility.generateLocalTravelingMerchantStock uses the seed plus days played combination to seed the PRNG and generate the shop stock:

private static Dictionary<ISalable, int[]> generateLocalTravelingMerchantStock(int seed)
{
	Dictionary<ISalable, int[]> stock = new Dictionary<ISalable, int[]>();
	HashSet<int> stockIndices = new HashSet<int>();
	Random r = new Random(seed);
	bool add_guaranteed_item = false;
	if (Game1.netWorldState.Value.VisitsUntilY1Guarantee == 0)
	{
		add_guaranteed_item = true;
	}
	for (int i = 0; i < 10; i++)
	{
		int index = r.Next(2, 790);
		while (true)
		{
			index++;
			index %= 790;
			if (Game1.objectInformation.ContainsKey(index) && !isObjectOffLimitsForSale(index))
			{
				if (index == 266 || index == 485)
				{
					add_guaranteed_item = false;
				}
				string[] split2 = Game1.objectInformation[index].Split('/');
				if (split2[3].Contains('-') && Convert.ToInt32(split2[1]) > 0 && !split2[3].Contains("-13") && !split2[3].Equals("Quest") && !split2[0].Equals("Weeds") && !split2[3].Contains("Minerals") && !split2[3].Contains("Arch") && addToStock(stock, stockIndices, new Object(index, 1), new int[2]
				{
					Math.Max(r.Next(1, 11) * 100, lll(split2[1]) * r.Next(3, 6)),
					(!(r.NextDouble() < 0.1)) ? 1 : 5
				}))
				{
					break;
				}
			}
		}
	}
	// ...
	return stock;
}

Comparing the C# to C++

Let’s now compare the PC C# code to the Switch C++ code and see if there are any functional differences. We’ll rename variables and functions in Ghidra’s decompilation as we go, so that it is easier to see what is what.

First there is the initialisation of some variables (including the PRNG):

Dictionary<ISalable, int[]> stock = new Dictionary<ISalable, int[]>();
HashSet<int> stockIndices = new HashSet<int>();
Random r = new Random(seed);
stock = Dictionary(0);
stockIndices = HashSet(0);
r = (long *)Random_seeded((Random *)0x0,seed);

Then there is the add_guaranteed_item decision:

if (Game1.netWorldState.Value.VisitsUntilY1Guarantee == 0)
{
	add_guaranteed_item = true;
}
add_guaranteed_item = VisitsUntilY1Guarantee == 0;

Next, we enter the for loop and generate the index using the PRNG:

for (int i = 0; i < 10; i++)
{
	int index = r.Next(2, 790);
i = 0;
do {
  if (DAT_710c157bac != 0) {
    FUN_7100001040();
  }
  index = (**(code **)(*(long *)(*r + 8) + 0x40))
                    ((long)r + (ulong)*(byte *)(*(long *)(*r + 8) + 0x48),2,790);

Now we enter the while (true) loop (Ghidra chooses to represent this as many do-while loops, rather than an infinite loop with a break):

while (true)
{
do {
  do {
    do {
      do {
        do {
          do {
            do {
              do {
                do {

Then we have the incrementation of the index, followed by checks to ensure an object with that index exists and that said object is not off-limits for sale:

index++;
index %= 790;
if (Game1.objectInformation.ContainsKey(index) && !isObjectOffLimitsForSale(index))
{
  index = (index + 1) % 790;
  // ...
} while (((objectInformation_ContainsKey & 1) == 0) ||
        (isObjectOffLimitsForSale = ::isObjectOffLimitsForSale(index),
        (isObjectOffLimitsForSale & 1) != 0));

Following that, there is another add_guaranteed_item decision:

if (index == 266 || index == 485)
{
	add_guaranteed_item = false;
}
if ((index == 485) || (index == 266)) {
  add_guaranteed_item = false;
}

And the split2 array is created:

string[] split2 = Game1.objectInformation[index].Split('/');
split2 = String.Split(objectInformation,/_string);

Finally, we have a big if-statement with additional checks, followed by use of the PRNG to determine the item’s price and quantity. Here, however, the PC and Switch versions diverge slightly, since in the Switch version, the price and quantity generation occur before any of the other checks. This is quite important for us, since it means the pattern of PRNG calls will differ between versions. Here is the code:

if (split2[3].Contains('-') && Convert.ToInt32(split2[1]) > 0 && !split2[3].Contains("-13") && !split2[3].Equals("Quest") && !split2[0].Equals("Weeds") && !split2[3].Contains("Minerals") && !split2[3].Contains("Arch") && addToStock(stock, stockIndices, new Object(index, 1), new int[2]
{
	Math.Max(r.Next(1, 11) * 100, lll(split2[1]) * r.Next(3, 6)),
	(!(r.NextDouble() < 0.1)) ? 1 : 5
}))
{
	break;
}
                  split2_1_int = Convert.ToInt32(*(undefined8 *)(*(long *)(split2 + 0x10) + 8) );
                  variable_multiplier_ =
                        (**(code **)(*(long *)(*r + 8) + 0x40))
                                  ((long)r + (ulong)*(byte *)(*(long *)(*r + 8) + 0x48),3,6);
                  price = Math.Max(constant_multiplier * 100,variable_multiplier_ * split2_1_i nt);
                  quantity_decider =
                        (double)(**(code **)(*(long *)(*r + 8) + 0x80))
                                          ((long)r + (ulong)*(byte *)(*(long *)(*r + 8) + 0x88));
                  uVar9 = 5;
                  quantity = uVar9;
                  if (0.1 <= quantity_decider) {
                    quantity = 1;
                  }
                  // ...
                  price_quantity = Array(DAT_710aaf5578,4,&local_64,1);
                  price_quantity_inner = *(undefined4 **)(lVar4 + 0x10);
                  *price_quantity_inner = price;
                  price_quantity_inner[1] = quantity;
                  // ...
                  split2_3 = *(ulong *)(*(long *)(split2 + 0x10) + 0x18);
                  // ...
                  lVar5 = FUN_7100000af0(split2_3,DAT_710ab30900);
                  if (lVar5 == 0) {
                    split2_3_contains_- = FUN_7100fd6920(spli2_3,L'-',0);
                  }
                  else {
                    // ...
                    split2_3_contains_- = (**ppcVar3)(lVar5 + (ulong)*(byte *)(ppcVar3 + 1),L'- ');
                  }
                } while ((split2_3_contains_- & 1) == 0);
                // ...
                split2_1_int_ = Convert.ToInt32(*(undefined8 *)(*(long *)(split2 + 0x10) + 8)) ;
              } while (split2_1_int_ < 1);
              // ...
              split2_3_contains_-13 =
                    String.Contains(*(undefined8 *)(*(long *)(split2 + 0x10) + 0x18),-13_CONST_S TR)
              ;
            } while ((split2_3_contains_-13 & 1) != 0);
            // ...
            split2_3_equals_Quest =
                  String.Equals(*(undefined8 *)(*(long *)(split2 + 0x10) + 0x18),Quest_CONST_STR );
          } while ((split2_3_equals_Quest & 1) != 0);
          // ...
          string2_0_equals_Weeds = String.Equals(**(undefined8 **)(split2 + 0x10),Weeds_CONST_ STR)
          ;
        } while ((string2_0_equals_Weeds & 1) != 0);
        // ...
        split2_3_equals_Minerals =
              String.Contains(*(undefined8 *)(*(long *)(split2 + 0x10) + 0x18),Minerals_CONST_ST R);
      } while ((split2_3_equals_Minerals & 1) != 0);
      // ...
      split2_3_contains_Arch =
            String.Contains(*(undefined8 *)(*(long *)(split2 + 0x10) + 0x18),Arch_CONST_STR);
    } while ((split2_3_contains_Arch & 1) != 0);
    object = Object(0,index,1,0,0xffffffff,0);
    add_to_stock_result = addToStock(stock,stockIndices,object,price_quantity);
  } while ((add_to_stock_result & 1) == 0);
  i = i + 1;
} while (i < 10);

Aside: Array Creation in If-Statements

It would be interesting to see if the behaviour we’ve observed above (i.e. arrays created in if-statements being moved to before the if-statement) is a quirk/bug in BRUTE, or if Stardew Valley’s developer just decided to change the logic in getTravelingMerchantStock.

After a bit of searching, we can find a similar example in StardewValley.Locations.LibraryMuseum.getRewardsForPlayer:

if (!who.specialBigCraftables.Contains(1301) && museumContainsTheseItems(new int[3] { 579, 581, 582 }, museumItems))
{

Which gets converted to the following:

array = Array(DAT_710aaf5578,4,&local_80,1);
array_inner = *(int32_t **)(array + 0x10);
array_inner[2] = 582;
*(undefined8 *)array_inner = 0x24500000243;
result = NetIntList.Contains(*(undefined8 *)(who + 0x1e0),1301);
if (((result & 1) == 0) &&
    (result = museumContainsTheseItems(result,array,museumItems), (result & 1) != 0))

Again, the array is initialised before the if-statement, so it looks like this is a problem with BRUTE.

Developing the Seed Cracker

We won’t discuss the development of the seed cracker too much. It is a client-side web app written in Rust using the Yew framework. The PRNG and Traveling Cart logic is re-written in Rust, and multiple WebAssembly worker threads are used to iterate over all possible seeds until the generated stock matches that inputted by the user.

Several optimisations are employed so that seed cracking only takes a few seconds:

  • As soon as one piece of generated information (index, quantity or price) does not match that provided by the user, the seed is discarded.
  • PHF is used to generate two sets of excluded indices at compile-time to replace the two complex if-statements in generateLocalTravelingMerchantStock. This drastically reduces the execution time.
  • PHF is also used to generate a mapping of each randomly generated index to what it will become after the while(true) loop is complete. This allows invalid seeds to be discarded even faster.

The seed cracker can be used here and the source code is available here.

Developing the Predictor

Knowing your seed is useless without a tool to predict future events based on it. After some consideration, it seemed like developing a new tool would be cleaner (and perhaps quicker) than adapting MouseyPounds' existing solution.

Again, we won’t discuss the development too much. The predictor uses the same technologies as the seed cracker, although significantly newer versions (this project has taken over a year…). It automatically converts game assets (after processign with xnbcli) to Rust structures, which should make responding to changes in the game significantly easier. It was also developed with multiple platforms in mind, so adding more in the future shouldn’t be too much of a challenge.

The predictor currently supports six random events (Traveling Cart, Krobus, Sandy, Pierre and Joja stock, as well as Geodes) and there are plans to add more. It can be used here and the source code is available here.

Conclusion

Overall, this project has been far more successful than I thought it would be (the moment the seed cracker actually worked felt like a miracle). That being said, there is still a lot of work to do:

  • Adding support for other platforms (PlayStation, Xbox, iOS and Android). I wouldn’t be surprised if some of these just worked with the existing code, but that needs to be tested.
  • Adding more events to the predictor (enchantments and Calico Jack would be particularly useful).
  • Adding support for the new 1.6 update (when it is released on consoles).

Anyway, thank you for reading :)

Please click on "Preferences" to confirm your cookie preferences. By default, the essential cookies are always activated. View our Cookie Policy for more information.