diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL b/soh/assets/custom/objects/object_gacha/gGachaTokenDL
new file mode 100644
index 00000000000..f41466ff373
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0
new file mode 100644
index 00000000000..85ff7c127fc
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1
new file mode 100644
index 00000000000..fd28ee32bc7
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0
new file mode 100644
index 00000000000..18e0d84e209
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1
new file mode 100644
index 00000000000..db2f3412b35
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque
new file mode 100644
index 00000000000..efb6a26d31c
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque
new file mode 100644
index 00000000000..efb6a26d31c
--- /dev/null
+++ b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/soh/assets/soh_assets.h b/soh/assets/soh_assets.h
index 4799b53c01f..bc33c871839 100644
--- a/soh/assets/soh_assets.h
+++ b/soh/assets/soh_assets.h
@@ -532,3 +532,6 @@ static const ALIGN_ASSET(2) char gShipLogoDL[] = dgShipLogoDL;
#define dnintendo_rogo_static_Tex_LUS_000000 "__OTR__textures/nintendo_rogo_static/nintendo_rogo_static_Tex_LUS_000000"
static const ALIGN_ASSET(2) char nintendo_rogo_static_Tex_LUS_000000[] = dnintendo_rogo_static_Tex_LUS_000000;
+
+#define dgGachaTokenDL "__OTR__objects/object_gacha/gGachaTokenDL"
+static const ALIGN_ASSET(2) char gGachaTokenDL[] = dgGachaTokenDL;
diff --git a/soh/include/z64save.h b/soh/include/z64save.h
index 35f30c26398..de702cba25c 100644
--- a/soh/include/z64save.h
+++ b/soh/include/z64save.h
@@ -202,9 +202,16 @@ typedef struct {
#pragma region SoH
+#define GACHA_MAX_ITEMS RC_MAX
+
typedef struct ShipRandomizerSaveContextData {
u8 triforcePiecesCollected;
u8 bombchuUpgradeLevel;
+ u32 gachaTokens;
+ u32 gachaListIndex;
+ u32 gachaItemCount;
+ u32 gachaItems[GACHA_MAX_ITEMS];
+ u32 gachaChecks[GACHA_MAX_ITEMS];
} ShipRandomizerSaveContextData;
typedef struct ShipBossRushSaveContextData {
diff --git a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp
index a02a658877d..484a1c27ff4 100644
--- a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp
+++ b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp
@@ -433,6 +433,7 @@ static std::map cosmeticOptions = {
COSMETIC_OPTION("World.GossipStone", "Gossip Stone", COSMETICS_GROUP_WORLD, ColorRGBA8(200, 200, 200, 255), false, true, true),
COSMETIC_OPTION("World.RedIce", "Red Ice", COSMETICS_GROUP_WORLD, ColorRGBA8(255, 0, 0, 255), false, true, false),
COSMETIC_OPTION("World.MysteryItem", "Mystery Item", COSMETICS_GROUP_WORLD, ColorRGBA8( 0, 60, 100, 255), false, true, false),
+ COSMETIC_OPTION("World.GachaToken", "Gacha Token", COSMETICS_GROUP_WORLD, ColorRGBA8( 0, 100, 200, 255), false, true, true),
COSMETIC_OPTION("Navi.IdlePrimary", "Idle Primary", COSMETICS_GROUP_NAVI, ColorRGBA8(255, 255, 255, 255), false, true, false),
COSMETIC_OPTION("Navi.IdleSecondary", "Idle Secondary", COSMETICS_GROUP_NAVI, ColorRGBA8( 0, 0, 255, 0), false, true, true),
diff --git a/soh/soh/Enhancements/randomizer/3drando/fill.cpp b/soh/soh/Enhancements/randomizer/3drando/fill.cpp
index 799b9a34461..416f7ea0c28 100644
--- a/soh/soh/Enhancements/randomizer/3drando/fill.cpp
+++ b/soh/soh/Enhancements/randomizer/3drando/fill.cpp
@@ -10,6 +10,7 @@
#include "pool_functions.hpp"
#include "soh/Enhancements/randomizer/static_data.h"
#include "soh/Enhancements/debugger/performanceTimer.h"
+#include "gacha_fill.hpp"
#include
#include
@@ -1464,6 +1465,10 @@ int Fill() {
CreateAllHints();
CreateWarpSongTexts();
StopPerformanceTimer(PT_HINTS);
+
+ if (ctx->GetOption(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) {
+ BuildGachaList();
+ }
SPDLOG_DEBUG("Number of retries {}", retries);
return 1;
}
diff --git a/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp
new file mode 100644
index 00000000000..83132e65f85
--- /dev/null
+++ b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp
@@ -0,0 +1,184 @@
+#include "gacha_fill.hpp"
+
+#include "../SeedContext.h"
+#include "../static_data.h"
+#include "random.hpp"
+
+#include
+#include
+#include
+#include
+
+using namespace Rando;
+
+// Returns true if this location should remain as-is (vanilla placement or excluded from the
+// randomized pool) rather than being replaced by a gacha token.
+static bool IsVanillaPlaced(RandomizerCheck rc) {
+ auto ctx = Context::GetInstance();
+ auto loc = StaticData::GetLocation(rc);
+ auto itemLoc = ctx->GetItemLocation(rc);
+
+ if (itemLoc->GetPlacedRandomizerGet() == RG_NONE) {
+ return true; // start-with or excluded — not in the location table
+ }
+
+ // Link's Pocket: only replace with a token when the setting is "Anything".
+ // All other settings (Dungeon Reward, Advancement, specific reward type) must keep their item.
+ if (rc == RC_LINKS_POCKET) {
+ return !ctx->GetOption(RSK_LINKS_POCKET).Is(RO_LINKS_POCKET_ANYTHING);
+ }
+
+ // Skip Child Zelda: these locations are given directly at save init, not from the field.
+ if (ctx->GetOption(RSK_SKIP_CHILD_ZELDA).Is(RO_GENERIC_ON)) {
+ if (rc == RC_SONG_FROM_IMPA || rc == RC_HC_MALON_EGG || rc == RC_HC_ZELDAS_LETTER) {
+ return true;
+ }
+ }
+
+ // Master Sword shuffle with adult start: given at save init, not from the field.
+ if (ctx->GetOption(RSK_SHUFFLE_MASTER_SWORD).Is(RO_GENERIC_ON) &&
+ ctx->GetOption(RSK_SELECTED_STARTING_AGE).Is(RO_AGE_ADULT)) {
+ if (rc == RC_TOT_MASTER_SWORD) {
+ return true;
+ }
+ }
+
+ switch (loc->GetRCType()) {
+ case RCTYPE_MAP:
+ case RCTYPE_COMPASS:
+ return ctx->GetOption(RSK_SHUFFLE_MAPANDCOMPASS).Is(RO_DUNGEON_ITEM_LOC_VANILLA);
+ case RCTYPE_SMALL_KEY:
+ return ctx->GetOption(RSK_KEYSANITY).Is(RO_DUNGEON_ITEM_LOC_VANILLA);
+ case RCTYPE_GF_KEY:
+ return ctx->GetOption(RSK_GERUDO_KEYS).Is(RO_GERUDO_KEYS_VANILLA);
+ case RCTYPE_BOSS_KEY:
+ // Ganon's boss key uses its own setting
+ if (ctx->GetOption(RSK_GANONS_BOSS_KEY).Is(RO_GANON_BOSS_KEY_VANILLA)) {
+ return true;
+ }
+ return ctx->GetOption(RSK_BOSS_KEYSANITY).Is(RO_DUNGEON_ITEM_LOC_VANILLA);
+ case RCTYPE_GOSSIP_STONE:
+ case RCTYPE_STATIC_HINT:
+ return true; // stones never hold items
+ default:
+ return false;
+ }
+}
+
+void BuildGachaList() {
+ auto ctx = Context::GetInstance();
+
+ bool noLogic = ctx->GetOption(RSK_LOGIC_RULES).Is(RO_LOGIC_NO_LOGIC);
+
+ // --- Collect (RC, RG) pairs ---
+ // Advancement pairs are grouped by sphere; pairs within each sphere are shuffled together
+ // so the RC<->RG binding is preserved throughout the zone-placement algorithm.
+ using Pair = std::pair;
+
+ std::vector> sphereAdvPairs;
+ std::vector advancementLocs; // for dedup in other loop
+
+ if (!noLogic) {
+ for (auto& sphere : ctx->playthroughLocations) {
+ std::vector spherePairs;
+ for (RandomizerCheck rc : sphere) {
+ if (IsVanillaPlaced(rc)) continue;
+ RandomizerGet item = ctx->GetItemLocation(rc)->GetPlacedRandomizerGet();
+ if (item == RG_NONE || item == RG_GACHA_TOKEN) continue;
+ spherePairs.push_back({rc, item});
+ advancementLocs.push_back(rc);
+ }
+ Shuffle(spherePairs);
+ sphereAdvPairs.push_back(std::move(spherePairs));
+ }
+ }
+
+ std::vector otherPairs;
+ for (RandomizerCheck rc : ctx->allLocations) {
+ if (IsVanillaPlaced(rc)) continue;
+ if (!noLogic &&
+ std::find(advancementLocs.begin(), advancementLocs.end(), rc) != advancementLocs.end()) continue;
+ RandomizerGet item = ctx->GetItemLocation(rc)->GetPlacedRandomizerGet();
+ if (item == RG_NONE || item == RG_GACHA_TOKEN) continue;
+ otherPairs.push_back({rc, item});
+ }
+ Shuffle(otherPairs);
+
+ size_t totalAdv = 0;
+ for (auto& v : sphereAdvPairs) totalAdv += v.size();
+ size_t totalLocations = totalAdv + otherPairs.size();
+
+ // --- Build the final lists ---
+ std::vector gachaList;
+ std::vector gachaCheckList;
+
+ auto appendPairs = [&](const std::vector& pairs) {
+ for (auto& [rc, rg] : pairs) {
+ gachaList.push_back(rg);
+ gachaCheckList.push_back(rc);
+ }
+ };
+
+ if (noLogic || totalAdv == 0) {
+ // No logic or nothing to sequence: fully random order.
+ appendPairs(otherPairs);
+ } else if (otherPairs.empty()) {
+ for (auto& sphere : sphereAdvPairs)
+ appendPairs(sphere);
+ } else {
+ // Zone-based placement with logic guarantee.
+ //
+ // For each sphere S the player's token budget is proportional to how many
+ // advancement locations they've unlocked relative to the total:
+ // tokenLimit[S] = round(unlockedAdv[S] / totalAdv * totalLocations)
+ //
+ // Sphere S advancement items are placed anywhere within 0..tokenLimit[S]-1.
+ // Each zone is filled with the sphere's advancement pairs plus enough junk
+ // to reach the limit, then the zone is shuffled — random placement within
+ // the constraint, no forced even junk distribution.
+ gachaList.reserve(totalLocations);
+ gachaCheckList.reserve(totalLocations);
+ size_t unlockedAdv = 0;
+ size_t otherIdx = 0;
+
+ for (auto& spherePairs : sphereAdvPairs) {
+ if (spherePairs.empty()) continue;
+ unlockedAdv += spherePairs.size();
+
+ size_t tokenLimit = (size_t)round((double)unlockedAdv / totalAdv * totalLocations);
+ size_t currentSize = gachaList.size();
+
+ size_t junkForZone = 0;
+ if (tokenLimit > currentSize + spherePairs.size()) {
+ size_t available = otherPairs.size() - otherIdx;
+ junkForZone = std::min(tokenLimit - currentSize - spherePairs.size(), available);
+ }
+
+ std::vector zone(spherePairs.begin(), spherePairs.end());
+ for (size_t j = 0; j < junkForZone; j++) {
+ zone.push_back(otherPairs[otherIdx++]);
+ }
+ Shuffle(zone);
+ appendPairs(zone);
+ }
+
+ // Remaining junk after all sphere zones.
+ while (otherIdx < otherPairs.size()) {
+ auto& [rc, rg] = otherPairs[otherIdx++];
+ gachaList.push_back(rg);
+ gachaCheckList.push_back(rc);
+ }
+ }
+
+ SPDLOG_INFO("GachaFill: built list of {} items ({} advancement, {} other)",
+ gachaList.size(), totalAdv, otherPairs.size());
+
+ ctx->SetGachaList(gachaList);
+ ctx->SetGachaCheckList(gachaCheckList);
+
+ // --- Replace all non-vanilla randomized locations with RG_GACHA_TOKEN ---
+ for (RandomizerCheck rc : ctx->allLocations) {
+ if (IsVanillaPlaced(rc)) continue;
+ ctx->PlaceItemInLocation(rc, RG_GACHA_TOKEN);
+ }
+}
diff --git a/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp
new file mode 100644
index 00000000000..688c474811d
--- /dev/null
+++ b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+// Builds the ordered gacha item list from the completed Fill() placement and stores it in
+// the Context. Must be called after GeneratePlaythrough() and PareDownPlaythrough() succeed.
+void BuildGachaList();
diff --git a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp
index 5e1b84ec22a..bbb37c6a82f 100644
--- a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp
+++ b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp
@@ -361,6 +361,18 @@ void SpoilerLog_Write() {
WriteShuffledEntrances();
WriteAllLocations();
+ if (RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) {
+ const auto& gachaList = ctx->GetGachaList();
+ nlohmann::ordered_json gachaJson = nlohmann::ordered_json::array();
+ for (size_t i = 0; i < gachaList.size(); i++) {
+ gachaJson.push_back({
+ { "index", i },
+ { "item", Rando::StaticData::RetrieveItem(gachaList[i]).GetName().GetEnglish() },
+ });
+ }
+ jsonData["gachaList"] = gachaJson;
+ }
+
if (!std::filesystem::exists(Ship::Context::GetPathRelativeToAppDirectory("Randomizer"))) {
std::filesystem::create_directory(Ship::Context::GetPathRelativeToAppDirectory("Randomizer"));
}
diff --git a/soh/soh/Enhancements/randomizer/GachaMachine.cpp b/soh/soh/Enhancements/randomizer/GachaMachine.cpp
new file mode 100644
index 00000000000..b5b4827f483
--- /dev/null
+++ b/soh/soh/Enhancements/randomizer/GachaMachine.cpp
@@ -0,0 +1,166 @@
+#include "GachaMachine.h"
+
+#include
+
+extern "C" {
+#include
+#include
+#include
+extern PlayState* gPlayState;
+}
+
+#include "SeedContext.h"
+#include "static_data.h"
+#include "soh/Enhancements/custom-message/CustomMessageManager.h"
+
+#include
+#include
+
+using namespace Rando;
+using namespace std::string_literals;
+
+static std::queue sGachaPendingRCs;
+
+// ---------------------------------------------------------------------------
+// Scene-based category check
+// ---------------------------------------------------------------------------
+
+static bool IsDungeonScene(int scene) {
+ return (scene >= SCENE_DEKU_TREE && scene <= SCENE_INSIDE_GANONS_CASTLE) ||
+ scene == SCENE_INSIDE_GANONS_CASTLE_COLLAPSE;
+}
+
+static bool IsCityVillageScene(int scene) {
+ switch (scene) {
+ case SCENE_KOKIRI_FOREST:
+ case SCENE_KAKARIKO_VILLAGE:
+ case SCENE_GORON_CITY:
+ case SCENE_ZORAS_DOMAIN:
+ case SCENE_MARKET_DAY:
+ case SCENE_MARKET_NIGHT:
+ case SCENE_MARKET_RUINS:
+ case SCENE_TEMPLE_OF_TIME:
+ case SCENE_HYRULE_CASTLE:
+ case SCENE_BACK_ALLEY_DAY:
+ case SCENE_BACK_ALLEY_NIGHT:
+ return true;
+ default:
+ return false;
+ }
+}
+
+bool GachaMachine_IsStoneActive() {
+ int scene = gPlayState->sceneNum;
+ if (IsDungeonScene(scene))
+ return RAND_GET_OPTION(RSK_GACHA_STONES_DUNGEON).Is(RO_GENERIC_ON);
+ if (IsCityVillageScene(scene))
+ return RAND_GET_OPTION(RSK_GACHA_STONES_CITY).Is(RO_GENERIC_ON);
+ return RAND_GET_OPTION(RSK_GACHA_STONES_OVERWORLD).Is(RO_GENERIC_ON);
+}
+
+// ---------------------------------------------------------------------------
+// Helper: build a CustomMessage from a raw string and load it properly.
+// ---------------------------------------------------------------------------
+
+static void GachaLoadMessage(const std::string& text) {
+ CustomMessage raw(text);
+ CustomMessage formatted(raw.GetEnglish(MF_FORMATTED),
+ raw.GetGerman(MF_FORMATTED),
+ raw.GetFrench(MF_FORMATTED));
+ formatted.LoadIntoFont();
+}
+
+// ---------------------------------------------------------------------------
+// Interaction handler (called from OnOpenText hook)
+// ---------------------------------------------------------------------------
+
+void GachaMachine_Interact(uint16_t* textId, bool* loadFromMessageTable) {
+ *loadFromMessageTable = false;
+
+ // Clear any leftover pending RCs from a previous interaction.
+ while (!sGachaPendingRCs.empty()) sGachaPendingRCs.pop();
+
+ if (!GachaMachine_IsStoneActive()) {
+ GachaLoadMessage("This stone is dormant.^Find an active&Gacha Stone to spin.");
+ return;
+ }
+
+ auto& saveData = gSaveContext.ship.quest.data.randomizer;
+
+ if (saveData.gachaItemCount == 0) {
+ GachaLoadMessage("This gacha machine&has no items to give.");
+ return;
+ }
+
+ if (saveData.gachaListIndex >= saveData.gachaItemCount) {
+ GachaLoadMessage("The gacha machine is empty.&You have received all items!");
+ return;
+ }
+
+ if (saveData.gachaTokens <= saveData.gachaListIndex) {
+ GachaLoadMessage("You have no Gacha Tokens.^Find them at locations&throughout the world.");
+ return;
+ }
+
+ // Claim all unclaimed items: indices [gachaListIndex, gachaTokens), capped at the list size.
+ uint32_t newIndex = saveData.gachaTokens < saveData.gachaItemCount ? saveData.gachaTokens : saveData.gachaItemCount;
+ uint32_t count = newIndex - saveData.gachaListIndex;
+
+ auto ctx = Context::GetInstance();
+ for (uint32_t i = 0; i < count; i++) {
+ uint32_t idx = saveData.gachaListIndex + i;
+ RandomizerCheck rc = (RandomizerCheck)saveData.gachaChecks[idx];
+ RandomizerGet rg = (RandomizerGet)saveData.gachaItems[idx];
+
+ // Restore the real item at this location and clear its obtained status
+ // so the standard RC queue handler can process it normally.
+ auto loc = ctx->GetItemLocation(rc);
+ loc->SetPlacedItem(rg);
+ loc->SetCheckStatus(RCSHOW_UNCHECKED);
+
+ sGachaPendingRCs.push(rc);
+ }
+
+ saveData.gachaListIndex = newIndex;
+
+ GachaLoadMessage("You claimed " + std::to_string(count) + " items!");
+}
+
+// ---------------------------------------------------------------------------
+// Instant redeem (called before item give when all stone types are disabled)
+// ---------------------------------------------------------------------------
+
+bool GachaMachine_SubstituteToken(RandomizerCheck rc) {
+ if (RAND_GET_OPTION(RSK_GACHA_STONES_CITY).Is(RO_GENERIC_ON) ||
+ RAND_GET_OPTION(RSK_GACHA_STONES_OVERWORLD).Is(RO_GENERIC_ON) ||
+ RAND_GET_OPTION(RSK_GACHA_STONES_DUNGEON).Is(RO_GENERIC_ON)) {
+ return false;
+ }
+
+ auto& saveData = gSaveContext.ship.quest.data.randomizer;
+ if (saveData.gachaItemCount == 0) return false;
+ if (saveData.gachaListIndex >= saveData.gachaItemCount) return false;
+
+ saveData.gachaTokens++;
+
+ uint32_t idx = saveData.gachaListIndex;
+ RandomizerGet rg = (RandomizerGet)saveData.gachaItems[idx];
+
+ auto loc = Context::GetInstance()->GetItemLocation(rc);
+ loc->SetPlacedItem(rg);
+ loc->SetCheckStatus(RCSHOW_UNCHECKED);
+
+ saveData.gachaListIndex++;
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// RC delivery (called from RandomizerOnGameFrameUpdateHandler)
+// ---------------------------------------------------------------------------
+
+RandomizerCheck GachaMachine_PopNextPendingRC() {
+ if (sGachaPendingRCs.empty()) return RC_UNKNOWN_CHECK;
+ RandomizerCheck rc = sGachaPendingRCs.front();
+ sGachaPendingRCs.pop();
+ return rc;
+}
diff --git a/soh/soh/Enhancements/randomizer/GachaMachine.h b/soh/soh/Enhancements/randomizer/GachaMachine.h
new file mode 100644
index 00000000000..f893350f590
--- /dev/null
+++ b/soh/soh/Enhancements/randomizer/GachaMachine.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include
+#include "randomizerTypes.h"
+
+// Returns whether the gossip stone currently being talked to is an active gacha machine.
+// If false, the caller should show the "dormant stone" message instead of hints or gacha.
+bool GachaMachine_IsStoneActive();
+
+// Claim all items earned since the last visit: advances gachaListIndex up to gachaTokens,
+// queues each RC for delivery, and loads the result message into the text system.
+// Tokens are a monotonically increasing counter; nothing is ever deducted.
+// Always sets *loadFromMessageTable = false so our message is used.
+void GachaMachine_Interact(uint16_t* textId, bool* loadFromMessageTable);
+
+// If all stone categories are disabled, replaces the token at rc with its real gacha reward
+// before the item give fires. Returns true if substitution happened; caller must re-fetch
+// the GetItemEntry after a true return.
+bool GachaMachine_SubstituteToken(RandomizerCheck rc);
+
+// Returns the next pending gacha RandomizerCheck to push into the randomizer queue, or
+// RC_UNKNOWN_CHECK if nothing is pending. Called every frame from RandomizerOnGameFrameUpdateHandler.
+RandomizerCheck GachaMachine_PopNextPendingRC();
diff --git a/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp b/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp
index 7e2a2e193a2..1f7a1a8a51f 100644
--- a/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp
+++ b/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp
@@ -1,8 +1,9 @@
/**
* This file handles the custom messages for Gossip Stone
- * hints.
+ * hints, and intercepts stone interaction for Gacha Mode.
*/
#include
+#include "soh/Enhancements/randomizer/GachaMachine.h"
extern "C" {
extern PlayState* gPlayState;
@@ -12,6 +13,12 @@ extern PlayState* gPlayState;
}
void BuildHintStoneMessage(uint16_t* textId, bool* loadFromMessageTable) {
+ // Gacha Mode: redirect all stone interactions to the gacha machine handler.
+ if (RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) {
+ GachaMachine_Interact(textId, loadFromMessageTable);
+ return;
+ }
+
if ((RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).Is(RO_GOSSIP_STONES_NEED_TRUTH) &&
Player_GetMask(gPlayState) == PLAYER_MASK_TRUTH) ||
(RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).Is(RO_GOSSIP_STONES_NEED_STONE) &&
@@ -52,8 +59,11 @@ void BuildHintStoneMessage(uint16_t* textId, bool* loadFromMessageTable) {
}
void RegisterGossipStoneHints() {
+ // Fire for hint mode OR gacha mode (gacha mode overrides hint behaviour inside BuildHintStoneMessage).
COND_ID_HOOK(OnOpenText, TEXT_RANDOMIZER_GOSSIP_STONE_HINTS,
- RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).IsNot(RO_GOSSIP_STONES_NONE), BuildHintStoneMessage);
+ RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).IsNot(RO_GOSSIP_STONES_NONE) ||
+ RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON),
+ BuildHintStoneMessage);
}
static RegisterShipInitFunc initFunc(RegisterGossipStoneHints, { "IS_RANDO" });
\ No newline at end of file
diff --git a/soh/soh/Enhancements/randomizer/SeedContext.cpp b/soh/soh/Enhancements/randomizer/SeedContext.cpp
index 512984117d7..b2e8a0ae6c8 100644
--- a/soh/soh/Enhancements/randomizer/SeedContext.cpp
+++ b/soh/soh/Enhancements/randomizer/SeedContext.cpp
@@ -421,6 +421,7 @@ void Context::ParseSpoiler(const char* spoilerFileName) {
ParseTricksJson(spoilerFileJson);
mEntranceShuffler->ParseJson(spoilerFileJson);
ParseHintJson(spoilerFileJson);
+ ParseGachaListJson(spoilerFileJson);
mDungeons->ParseJson(spoilerFileJson);
mTrials->ParseJson(spoilerFileJson);
mSpoilerLoaded = true;
@@ -460,6 +461,15 @@ void Context::ParseItemLocationsJson(nlohmann::json spoilerFileJson) {
}
}
+void Context::ParseGachaListJson(nlohmann::json spoilerFileJson) {
+ if (!spoilerFileJson.contains("gachaList")) return;
+ std::vector list;
+ for (auto& entry : spoilerFileJson["gachaList"]) {
+ list.push_back(StaticData::itemNameToEnum[entry["item"].get()]);
+ }
+ gachaList = std::move(list);
+}
+
void Context::WriteHintJson(nlohmann::ordered_json& spoilerFileJson) {
for (Hint hint : hintTable) {
hint.logHint(spoilerFileJson);
@@ -565,6 +575,22 @@ std::shared_ptr Context::GetKaleido() {
return mKaleido;
}
+const std::vector& Context::GetGachaList() const {
+ return gachaList;
+}
+
+void Context::SetGachaList(std::vector list) {
+ gachaList = std::move(list);
+}
+
+const std::vector& Context::GetGachaCheckList() const {
+ return gachaCheckList;
+}
+
+void Context::SetGachaCheckList(std::vector list) {
+ gachaCheckList = std::move(list);
+}
+
std::string Context::GetHash() const {
return mHash;
}
diff --git a/soh/soh/Enhancements/randomizer/SeedContext.h b/soh/soh/Enhancements/randomizer/SeedContext.h
index 9ca42751a95..144a3566379 100644
--- a/soh/soh/Enhancements/randomizer/SeedContext.h
+++ b/soh/soh/Enhancements/randomizer/SeedContext.h
@@ -122,6 +122,7 @@ class Context {
void ParseItemLocationsJson(nlohmann::json spoilerFileJson);
void WriteHintJson(nlohmann::ordered_json& spoilerFileJson);
void ParseHintJson(nlohmann::json spoilerFileJson);
+ void ParseGachaListJson(nlohmann::json spoilerFileJson);
void ParseTricksJson(nlohmann::json spoilerFileJson);
std::map overrides = {};
std::vector> playthroughLocations = {};
@@ -132,6 +133,12 @@ class Context {
std::array hashIconIndexes = {};
bool playthroughBeatable = false;
bool allLocationsReachable = false;
+ std::vector gachaList = {};
+ std::vector gachaCheckList = {};
+ const std::vector& GetGachaList() const;
+ void SetGachaList(std::vector list);
+ const std::vector& GetGachaCheckList() const;
+ void SetGachaCheckList(std::vector list);
RandomizerArea GetAreaFromString(std::string str);
int CountEmptyLocations(bool countShops);
diff --git a/soh/soh/Enhancements/randomizer/draw.cpp b/soh/soh/Enhancements/randomizer/draw.cpp
index f16ae7440cd..5d4e52a14a0 100644
--- a/soh/soh/Enhancements/randomizer/draw.cpp
+++ b/soh/soh/Enhancements/randomizer/draw.cpp
@@ -1392,3 +1392,15 @@ extern "C" void Randomizer_DrawOverworldKey(PlayState* play, GetItemEntry* getIt
CLOSE_DISPS(play->state.gfxCtx);
}
+
+extern "C" void Randomizer_DrawGachaToken(PlayState* play, GetItemEntry* getItemEntry) {
+ OPEN_DISPS(play->state.gfxCtx);
+ Gfx_SetupDL_25Opa(play->state.gfxCtx);
+ Color_RGBA8 color = CVarGetColor(CVAR_COSMETIC("World.GachaToken.Value"), Color_RGBA8{ 0, 100, 200, 255 });
+ gDPSetEnvColor(POLY_OPA_DISP++, color.r, color.g, color.b, color.a);
+ Matrix_Scale(0.035f, 0.035f, 0.035f, MTXMODE_APPLY);
+ gSPMatrix(POLY_OPA_DISP++, Matrix_NewMtx(play->state.gfxCtx, (char*)__FILE__, __LINE__),
+ G_MTX_MODELVIEW | G_MTX_LOAD);
+ gSPDisplayList(POLY_OPA_DISP++, (Gfx*)gGachaTokenDL);
+ CLOSE_DISPS(play->state.gfxCtx);
+}
diff --git a/soh/soh/Enhancements/randomizer/draw.h b/soh/soh/Enhancements/randomizer/draw.h
index d674f562fac..2ad8faf1e7e 100644
--- a/soh/soh/Enhancements/randomizer/draw.h
+++ b/soh/soh/Enhancements/randomizer/draw.h
@@ -33,6 +33,7 @@ void Randomizer_DrawMysteryItem(PlayState* play, GetItemEntry* getItemEntry);
void Randomizer_DrawBombchuBagInLogic(PlayState* play, GetItemEntry* getItemEntry);
void Randomizer_DrawBombchuBag(PlayState* play, GetItemEntry* getItemEntry);
void Randomizer_DrawOverworldKey(PlayState* play, GetItemEntry* getItemEntry);
+void Randomizer_DrawGachaToken(PlayState* play, GetItemEntry* getItemEntry);
void Randomizer_DrawRocsFeather(PlayState* play, GetItemEntry* getItemEntry);
#define GET_ITEM_MYSTERY \
diff --git a/soh/soh/Enhancements/randomizer/hook_handlers.cpp b/soh/soh/Enhancements/randomizer/hook_handlers.cpp
index fb2a00b1636..2990f31f853 100644
--- a/soh/soh/Enhancements/randomizer/hook_handlers.cpp
+++ b/soh/soh/Enhancements/randomizer/hook_handlers.cpp
@@ -1,5 +1,6 @@
#include
#include "soh/OTRGlobals.h"
+#include "soh/Enhancements/randomizer/GachaMachine.h"
#include "soh/ResourceManagerHelpers.h"
#include "soh/Enhancements/enhancementTypes.h"
#include "soh/Enhancements/custom-message/CustomMessageTypes.h"
@@ -369,6 +370,9 @@ void RandomizerOnPlayerUpdateForRCQueueHandler() {
auto loc = Rando::Context::GetInstance()->GetItemLocation(rc);
RandomizerGet vanillaRandomizerGet = Rando::StaticData::GetLocation(rc)->GetVanillaItem();
GetItemID vanillaItem = (GetItemID)Rando::StaticData::RetrieveItem(vanillaRandomizerGet).GetItemID();
+ if (loc->GetPlacedRandomizerGet() == RG_GACHA_TOKEN) {
+ GachaMachine_SubstituteToken(rc);
+ }
GetItemEntry getItemEntry =
Rando::Context::GetInstance()->GetFinalGIEntry(rc, true, (GetItemID)vanillaRandomizerGet);
GetItemCategory getItemCategory = Randomizer_AdjustItemCategory(getItemEntry);
@@ -2486,6 +2490,11 @@ void RandomizerOnActorInitHandler(void* actorRef) {
}
void RandomizerOnGameFrameUpdateHandler() {
+ RandomizerCheck gachaRC = GachaMachine_PopNextPendingRC();
+ if (gachaRC != RC_UNKNOWN_CHECK) {
+ randomizerQueuedChecks.push(gachaRC);
+ }
+
if (Flags_GetRandomizerInf(RAND_INF_HAS_INFINITE_QUIVER)) {
AMMO(ITEM_BOW) = static_cast(CUR_CAPACITY(UPG_QUIVER));
}
diff --git a/soh/soh/Enhancements/randomizer/item_list.cpp b/soh/soh/Enhancements/randomizer/item_list.cpp
index 87721760618..00f46959005 100644
--- a/soh/soh/Enhancements/randomizer/item_list.cpp
+++ b/soh/soh/Enhancements/randomizer/item_list.cpp
@@ -456,6 +456,8 @@ void Rando::StaticData::InitItemTable() {
itemTable[RG_TRIFORCE_PIECE] = Item(RG_TRIFORCE_PIECE, Text{ "Triforce Piece", "Triforce Piece", "Triforce-Fragment" }, ITEMTYPE_ITEM, 0xDF, true, LOGIC_TRIFORCE_PIECES, RHT_TRIFORCE_PIECE, RG_TRIFORCE_PIECE, OBJECT_GI_BOMB_2, GID_TRIFORCE_PIECE, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_LONG, ITEM_CATEGORY_MAJOR, MOD_RANDOMIZER, {"a ", "ein ", "un "}).CustomIcon(gTriforcePieceTex);
itemTable[RG_ROCS_FEATHER] = Item(RG_ROCS_FEATHER, Text{ "Roc's Feather", "Roc's Feather", "Roc's Feather" }, ITEMTYPE_ITEM, 0xE0, true, LOGIC_ROCS_FEATHER, RHT_ROCS_FEATHER, RG_ROCS_FEATHER, OBJECT_GI_BOMB_2, GID_ROCS_FEATHER, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_LONG, ITEM_CATEGORY_MAJOR, MOD_RANDOMIZER, {"a ", "ein ", "un "}).CustomIcon(gRocsFeatherTex);
itemTable[RG_ROCS_FEATHER].SetCustomDrawFunc(Randomizer_DrawRocsFeather);
+ itemTable[RG_GACHA_TOKEN] = Item(RG_GACHA_TOKEN, Text{ "Gacha Token", "Jeton Gacha", "Gacha-Spielmarke" }, ITEMTYPE_ITEM, GI_RUPEE_GREEN, false, LOGIC_NONE, RHT_NONE, RG_GACHA_TOKEN, OBJECT_GI_SUTARU, GID_SKULL_TOKEN, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_SHORT, ITEM_CATEGORY_LESSER, MOD_RANDOMIZER, {"a ", "ein ", "un "});
+ itemTable[RG_GACHA_TOKEN].SetCustomDrawFunc(Randomizer_DrawGachaToken);
// clang-format on
diff --git a/soh/soh/Enhancements/randomizer/randomizer.cpp b/soh/soh/Enhancements/randomizer/randomizer.cpp
index e6dc71acc26..4cfee5d2c5d 100644
--- a/soh/soh/Enhancements/randomizer/randomizer.cpp
+++ b/soh/soh/Enhancements/randomizer/randomizer.cpp
@@ -4626,6 +4626,9 @@ extern "C" u16 Randomizer_Item_Give(PlayState* play, GetItemEntry giEntry) {
INV_CONTENT(ITEM_NAYRUS_LOVE) = ITEM_ROCS_FEATHER;
}
break;
+ case RG_GACHA_TOKEN:
+ gSaveContext.ship.quest.data.randomizer.gachaTokens++;
+ break;
default:
LUSLOG_WARN("Randomizer_Item_Give didn't have behaviour specified for getItemId=%d", item);
assert(false);
diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h
index 3b7806385f6..23a0fe584c2 100644
--- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h
+++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h
@@ -320,6 +320,9 @@ RANDO_ENUM_ITEM(RG_FISHING_HOLE_KEY)
// Custom Items
RANDO_ENUM_ITEM(RG_ROCS_FEATHER)
+// Gacha Mode
+RANDO_ENUM_ITEM(RG_GACHA_TOKEN)
+
// Logic Only
RANDO_ENUM_ITEM(RG_STICKS)
RANDO_ENUM_ITEM(RG_NUTS)
diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h
index 3079132fc61..0fe53380c70 100644
--- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h
+++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h
@@ -339,6 +339,7 @@ RANDO_ENUM_ITEM(RSG_MENU_SECTION_STARTING_ITEMS)
RANDO_ENUM_ITEM(RSG_MENU_COLUMN_STARTING_SONGS)
RANDO_ENUM_ITEM(RSG_MENU_SECTION_NORMAL_SONGS)
RANDO_ENUM_ITEM(RSG_MENU_SECTION_WARP_SONGS)
+RANDO_ENUM_ITEM(RSG_MENU_SECTION_GACHA)
RANDO_ENUM_ITEM(RSG_OPEN)
RANDO_ENUM_ITEM(RSG_WORLD)
RANDO_ENUM_ITEM(RSG_SHUFFLE)
diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h
index 5e1dc80f30b..d65a732a0fd 100644
--- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h
+++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h
@@ -250,6 +250,11 @@ RANDO_ENUM_ITEM(RSK_SHUFFLE_SIGNS)
RANDO_ENUM_ITEM(RSK_ROCS_FEATHER)
RANDO_ENUM_ITEM(RSK_SHUFFLE_ICICLES)
RANDO_ENUM_ITEM(RSK_SHUFFLE_RED_ICE)
+// Gacha Mode
+RANDO_ENUM_ITEM(RSK_GACHA_MODE)
+RANDO_ENUM_ITEM(RSK_GACHA_STONES_CITY)
+RANDO_ENUM_ITEM(RSK_GACHA_STONES_OVERWORLD)
+RANDO_ENUM_ITEM(RSK_GACHA_STONES_DUNGEON)
RANDO_ENUM_ITEM(RSK_MAX)
RANDO_ENUM_END(RandomizerSettingKey)
diff --git a/soh/soh/Enhancements/randomizer/savefile.cpp b/soh/soh/Enhancements/randomizer/savefile.cpp
index 064eabff646..9916a9380cb 100644
--- a/soh/soh/Enhancements/randomizer/savefile.cpp
+++ b/soh/soh/Enhancements/randomizer/savefile.cpp
@@ -249,6 +249,21 @@ extern "C" void Randomizer_InitSaveFile() {
// Reset Bombchu Bag Upgrade
gSaveContext.ship.quest.data.randomizer.bombchuUpgradeLevel = 0;
+ // Reset gacha state and bake the gacha list into the save so it's always available.
+ gSaveContext.ship.quest.data.randomizer.gachaTokens = 0;
+ gSaveContext.ship.quest.data.randomizer.gachaListIndex = 0;
+ gSaveContext.ship.quest.data.randomizer.gachaItemCount = 0;
+ if (ctx->GetOption(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) {
+ const auto& gachaList = ctx->GetGachaList();
+ const auto& gachaCheckList = ctx->GetGachaCheckList();
+ uint32_t count = (uint32_t)(gachaList.size() < GACHA_MAX_ITEMS ? gachaList.size() : GACHA_MAX_ITEMS);
+ gSaveContext.ship.quest.data.randomizer.gachaItemCount = count;
+ for (uint32_t i = 0; i < count; i++) {
+ gSaveContext.ship.quest.data.randomizer.gachaItems[i] = (uint32_t)gachaList[i];
+ gSaveContext.ship.quest.data.randomizer.gachaChecks[i] = (uint32_t)gachaCheckList[i];
+ }
+ }
+
SetStartingItems();
// Set Cutscene flags and texts to skip them.
diff --git a/soh/soh/Enhancements/randomizer/settings.cpp b/soh/soh/Enhancements/randomizer/settings.cpp
index ad92fede41d..e60dce520cb 100644
--- a/soh/soh/Enhancements/randomizer/settings.cpp
+++ b/soh/soh/Enhancements/randomizer/settings.cpp
@@ -1335,6 +1335,10 @@ void Settings::CreateOptions() {
OPT_CALLBACK(RSK_LOGIC_RULES, {
HandleStartingAgeUI();
});
+ OPT_BOOL(RSK_GACHA_MODE, "Gacha Mode", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaMode"), "Every location gives a Gacha Token. Spend tokens at Stones of Truth to receive items from a pre-ordered list that guarantees progression.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_OFF);
+ OPT_BOOL(RSK_GACHA_STONES_CITY, "Gacha: City/Village Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesCity"), "Stones in cities and villages (Kokiri Forest, Kakariko, Goron City, etc.) act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON);
+ OPT_BOOL(RSK_GACHA_STONES_OVERWORLD, "Gacha: Overworld/Grotto Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), "Stones in overworld areas and grottos act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON);
+ OPT_BOOL(RSK_GACHA_STONES_DUNGEON, "Gacha: Dungeon/Temple Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), "Stones inside dungeons and temples act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON);
OPT_BOOL(RSK_ALL_LOCATIONS_REACHABLE, "All Locations Reachable", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("AllLocationsReachable"), mOptionDescriptions[RSK_ALL_LOCATIONS_REACHABLE], WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON, false, nullptr, IMFLAG_SAME_LINE);
OPT_BOOL(RSK_SKULLS_SUNS_SONG, "Night Skulltula's Expect Sun's Song", CVAR_RANDOMIZER_SETTING("GsExpectSunsSong"), mOptionDescriptions[RSK_SKULLS_SUNS_SONG]);
OPT_U8(RSK_DAMAGE_MULTIPLIER, "Damage Multiplier", {"x1/2", "x1", "x2", "x4", "x8", "x16", "OHKO"}, OptionCategory::Setting, "", "", WIDGET_CVAR_SLIDER_INT, RO_DAMAGE_MULTIPLIER_DEFAULT);
@@ -1722,6 +1726,14 @@ void Settings::CreateOptions() {
}
}
mOptionGroups[RSG_TRICKS] = OptionGroup::SubGroup("Logical Tricks", tricksOption);
+ mOptionGroups[RSG_MENU_SECTION_GACHA] = OptionGroup::SubGroup("Gacha Mode",
+ {
+ &mOptions[RSK_GACHA_MODE],
+ &mOptions[RSK_GACHA_STONES_CITY],
+ &mOptions[RSK_GACHA_STONES_OVERWORLD],
+ &mOptions[RSK_GACHA_STONES_DUNGEON],
+ },
+ WidgetContainerType::SECTION);
mOptionGroups[RSG_MENU_SECTION_LOGIC] = OptionGroup::SubGroup("Logic",
{
&mOptions[RSK_LOGIC_RULES],
@@ -1749,7 +1761,7 @@ void Settings::CreateOptions() {
&mOptions[RSK_LACS_REWARD_COUNT], &mOptions[RSK_LACS_TOKEN_COUNT] },
WidgetContainerType::SECTION);
mOptionGroups[RSG_MENU_COLUMN_LOGIC_WINCON] = OptionGroup::SubGroup("",
- std::initializer_list{
+ {
&mOptionGroups[RSG_ITEM_POOL],
&mOptionGroups[RSG_MENU_SECTION_LOGIC],
&mOptionGroups[RSG_MENU_SECTION_WINCON],
diff --git a/soh/soh/Network/Anchor/JsonConversions.hpp b/soh/soh/Network/Anchor/JsonConversions.hpp
index 69ad1313607..089921e22ca 100644
--- a/soh/soh/Network/Anchor/JsonConversions.hpp
+++ b/soh/soh/Network/Anchor/JsonConversions.hpp
@@ -109,12 +109,16 @@ inline void to_json(json& j, const ShipRandomizerSaveContextData& shipRandomizer
j = json{
{ "triforcePiecesCollected", shipRandomizerSaveContextData.triforcePiecesCollected },
{ "bombchuUpgradeLevel", shipRandomizerSaveContextData.bombchuUpgradeLevel },
+ { "gachaTokens", shipRandomizerSaveContextData.gachaTokens },
+ { "gachaListIndex", shipRandomizerSaveContextData.gachaListIndex },
};
}
inline void from_json(const json& j, ShipRandomizerSaveContextData& shipRandomizerSaveContextData) {
j.at("triforcePiecesCollected").get_to(shipRandomizerSaveContextData.triforcePiecesCollected);
j.at("bombchuUpgradeLevel").get_to(shipRandomizerSaveContextData.bombchuUpgradeLevel);
+ shipRandomizerSaveContextData.gachaTokens = j.value("gachaTokens", (u32)0);
+ shipRandomizerSaveContextData.gachaListIndex = j.value("gachaListIndex", (u32)0);
}
inline void to_json(json& j, const ShipQuestSpecificSaveContextData& shipQuestSpecificSaveContextData) {
diff --git a/soh/soh/SaveManager.cpp b/soh/soh/SaveManager.cpp
index 89224c0ab57..6f755b1963f 100644
--- a/soh/soh/SaveManager.cpp
+++ b/soh/soh/SaveManager.cpp
@@ -231,6 +231,16 @@ void SaveManager::LoadRandomizer() {
gSaveContext.ship.quest.data.randomizer.triforcePiecesCollected);
SaveManager::Instance->LoadData("bombchuUpgradeLevel", gSaveContext.ship.quest.data.randomizer.bombchuUpgradeLevel);
+ SaveManager::Instance->LoadData("gachaTokens", gSaveContext.ship.quest.data.randomizer.gachaTokens, (uint32_t)0);
+ SaveManager::Instance->LoadData("gachaListIndex", gSaveContext.ship.quest.data.randomizer.gachaListIndex, (uint32_t)0);
+ SaveManager::Instance->LoadData("gachaItemCount", gSaveContext.ship.quest.data.randomizer.gachaItemCount, (uint32_t)0);
+ SaveManager::Instance->LoadArray("gachaItems", gSaveContext.ship.quest.data.randomizer.gachaItemCount, [&](size_t i) {
+ SaveManager::Instance->LoadData("", gSaveContext.ship.quest.data.randomizer.gachaItems[i], (uint32_t)0);
+ });
+ SaveManager::Instance->LoadArray("gachaChecks", gSaveContext.ship.quest.data.randomizer.gachaItemCount, [&](size_t i) {
+ SaveManager::Instance->LoadData("", gSaveContext.ship.quest.data.randomizer.gachaChecks[i], (uint32_t)0);
+ });
+
SaveManager::Instance->LoadData("pendingIceTrapCount", gSaveContext.ship.pendingIceTrapCount);
std::shared_ptr randomizer = OTRGlobals::Instance->gRandomizer;
@@ -385,6 +395,16 @@ void SaveManager::SaveRandomizer(SaveContext* saveContext, int sectionID, bool f
saveContext->ship.quest.data.randomizer.triforcePiecesCollected);
SaveManager::Instance->SaveData("bombchuUpgradeLevel", saveContext->ship.quest.data.randomizer.bombchuUpgradeLevel);
+ SaveManager::Instance->SaveData("gachaTokens", saveContext->ship.quest.data.randomizer.gachaTokens);
+ SaveManager::Instance->SaveData("gachaListIndex", saveContext->ship.quest.data.randomizer.gachaListIndex);
+ SaveManager::Instance->SaveData("gachaItemCount", saveContext->ship.quest.data.randomizer.gachaItemCount);
+ SaveManager::Instance->SaveArray("gachaItems", saveContext->ship.quest.data.randomizer.gachaItemCount, [&](size_t i) {
+ SaveManager::Instance->SaveData("", saveContext->ship.quest.data.randomizer.gachaItems[i]);
+ });
+ SaveManager::Instance->SaveArray("gachaChecks", saveContext->ship.quest.data.randomizer.gachaItemCount, [&](size_t i) {
+ SaveManager::Instance->SaveData("", saveContext->ship.quest.data.randomizer.gachaChecks[i]);
+ });
+
SaveManager::Instance->SaveData("pendingIceTrapCount", saveContext->ship.pendingIceTrapCount);
std::shared_ptr randomizer = OTRGlobals::Instance->gRandomizer;
diff --git a/soh/soh/SohGui/SohMenuRandomizer.cpp b/soh/soh/SohGui/SohMenuRandomizer.cpp
index be64ac81587..ff0554bc4d0 100644
--- a/soh/soh/SohGui/SohMenuRandomizer.cpp
+++ b/soh/soh/SohGui/SohMenuRandomizer.cpp
@@ -603,6 +603,77 @@ void SohMenu::AddMenuRandomizer() {
}
});
+ // Gacha Mode
+ AddWidget(path, "Gacha Mode", WIDGET_SEPARATOR_TEXT);
+ AddWidget(path, "Gacha Mode", WIDGET_CVAR_CHECKBOX)
+ .CVar(CVAR_RANDOMIZER_SETTING("GachaMode"))
+ .PreFunc([](WidgetInfo& info) {
+ if (GameInteractor::IsSaveLoaded()) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Cannot change after a seed has been loaded.";
+ }
+ })
+ .Options(CheckboxOptions().Tooltip(
+ "Every location gives a Gacha Token. Spend tokens at Gossip Stones to receive items."));
+ AddWidget(path, "Gacha: Settlements", WIDGET_CVAR_CHECKBOX)
+ .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesCity"))
+ .PreFunc([](WidgetInfo& info) {
+ if (GameInteractor::IsSaveLoaded()) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Cannot change after a seed has been loaded.";
+ } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Requires Gacha Mode to be enabled.";
+ }
+ })
+ .Options(CheckboxOptions()
+ .DefaultValue(true)
+ .Tooltip("Stones in settlements (Kokiri Forest, Kakariko, Goron City, etc.) act as Gacha Machines."));
+ AddWidget(path, "Gacha: Overworld/Grotto Stones", WIDGET_CVAR_CHECKBOX)
+ .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"))
+ .PreFunc([](WidgetInfo& info) {
+ if (GameInteractor::IsSaveLoaded()) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Cannot change after a seed has been loaded.";
+ } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Requires Gacha Mode to be enabled.";
+ }
+ })
+ .Options(CheckboxOptions()
+ .DefaultValue(true)
+ .Tooltip("Stones in overworld areas and grottos act as Gacha Machines."));
+ AddWidget(path, "Gacha: Dungeon/Temple Stones", WIDGET_CVAR_CHECKBOX)
+ .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"))
+ .PreFunc([](WidgetInfo& info) {
+ if (GameInteractor::IsSaveLoaded()) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Cannot change after a seed has been loaded.";
+ } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) {
+ info.options->disabled = true;
+ info.options->disabledTooltip = "Requires Gacha Mode to be enabled.";
+ }
+ })
+ .Options(CheckboxOptions()
+ .DefaultValue(true)
+ .Tooltip("Stones inside dungeons and temples act as Gacha Machines."));
+ AddWidget(path, "Visit an active Gossip Stone to redeem your tokens for items.", WIDGET_TEXT)
+ .PreFunc([](WidgetInfo& info) {
+ info.isHidden = !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0) ||
+ (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesCity"), 1) &&
+ !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), 1) &&
+ !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), 1));
+ })
+ .Options(TextOptions().Color(UIWidgets::Colors::Gray));
+ AddWidget(path, "With all stone types disabled, tokens are redeemed instantly on pickup.", WIDGET_TEXT)
+ .PreFunc([](WidgetInfo& info) {
+ info.isHidden = !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0) ||
+ CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesCity"), 1) ||
+ CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), 1) ||
+ CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), 1);
+ })
+ .Options(TextOptions().Color(UIWidgets::Colors::Orange));
+
// Enhancements
AddWidget(path, "Enhancements", WIDGET_SEPARATOR_TEXT);
AddWidget(path, "These enhancements are only useful in the Randomizer mode but do not affect the randomizer logic.",