From b200b828cd53b882a85acec739d7cae4c859aa32 Mon Sep 17 00:00:00 2001 From: meldridge Date: Mon, 20 Jul 2026 13:23:50 +0930 Subject: [PATCH 1/3] Mark world items as seen in the Check Tracker when the player sees them Shuffled world items now mark their check as seen once the player has demonstrably seen the item on screen: freestanding items, gold skulltula token drops, heart pieces, small key drops, heart containers, Ruto's letter, the lake fire arrow, the bombchu bowling prizes, and the Lost Woods target reward. Being drawn is not proof of sight: the cull volume is larger than the view frustum (and enlarged further by Disable Draw Distance), and culling ignores walls entirely. An item counts as seen only when, for several consecutive game frames, it was drawn inside the real frustum, close enough to render at a recognizable size, and unoccluded on a ray from the camera eye. Eligible actors are exactly those whose randomizer handlers stamp an item entry while swapping the draw function to render it, so what was on screen is known. Marking is gated on that stamped entry faithfully representing the placed item: unobtainable items deliberately draw as a blue rupee, and recording those would reveal an item the player never saw. Ice trap disguises appear identically on both sides of that comparison, so a seen disguise records as its cover item, matching what the tracker already displays for seen checks. The treasure chest game rupees are excluded because their randomized draw renders through the Lens of Truth, so being drawn does not mean the player saw them. Mysterious Shuffle disables the feature entirely: its "?" model swap happens only in the draw call and is never written back to the actor, so the stamped entry a fidelity check would compare against is still the true item, not what was shown. Most check types also have no display-side mystery gate, so a false match here would print a name the player only ever saw disguised as "?". Co-Authored-By: Claude Fable 5 --- .../randomizer/randomizer_check_tracker.cpp | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp index 8cb108c7213..8f951586d36 100644 --- a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp +++ b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp @@ -16,12 +16,18 @@ #include "soh/Enhancements/randomizer/randomizer.h" #include "soh/ObjectExtension/ObjectExtension.h" #include "overlays/actors/ovl_En_GirlA/z_en_girla.h" +#include "overlays/actors/ovl_En_Ex_Item/z_en_ex_item.h" +#include "overlays/actors/ovl_En_Si/z_en_si.h" +#include "overlays/actors/ovl_Item_B_Heart/z_item_b_heart.h" +#include "overlays/actors/ovl_Item_Etcetera/z_item_etcetera.h" #include +#include #include #include #include #include +#include #include #include "location.h" #include "item_location.h" @@ -586,6 +592,152 @@ void CheckTrackerHintRevealed(RandomizerHint hintKey) { } } +// isDrawn alone isn't proof of sight (cull volume > frustum, enlarged +// further by Disable Draw Distance, and culling ignores walls). A world +// item counts as seen after several consecutive frames drawn inside the +// real frustum, close enough to read, and unoccluded from the camera. + +static constexpr float kSeenItemMaxDepth = 700.0f; // max view depth +static constexpr int kSeenItemFramesRequired = 8; // consecutive frames +static constexpr float kSeenItemRayYOffset = 15.0f; // aim above ground + +// Keyed by check, not actor, so respawns/room reloads can't dangle it. +static std::unordered_map seenItemFrames; + +// Resolves an actor's check + the item entry its draw function renders. +// False when the actor carries no check identity. +static bool SeenItemIdentity(Actor* actor, RandomizerCheck& rc, GetItemEntry& drawnEntry) { + switch (actor->id) { + case ACTOR_EN_ITEM00: { + EnItem00* item00 = reinterpret_cast(actor); + if (item00->actor.params != ITEM00_SOH_DUMMY) { + return false; + } + // Heart piece / small key drops only stamp the entry, not the check. + rc = item00->randoCheck != RC_UNKNOWN_CHECK ? item00->randoCheck + : OTRGlobals::Instance->gRandomizer->GetCheckFromActor( + actor->id, gPlayState->sceneNum, item00->ogParams); + drawnEntry = item00->itemEntry; + break; + } + case ACTOR_EN_SI: { + // Token drops encode their GS flag in params; unshuffled ones miss. + rc = OTRGlobals::Instance->gRandomizer->GetCheckFromActor(actor->id, gPlayState->sceneNum, actor->params); + drawnEntry = reinterpret_cast(actor)->sohGetItemEntry; + break; + } + case ACTOR_ITEM_B_HEART: { + rc = OTRGlobals::Instance->gRandomizer->GetCheckFromActor(actor->id, gPlayState->sceneNum, actor->params); + drawnEntry = reinterpret_cast(actor)->sohItemEntry; + break; + } + case ACTOR_ITEM_ETCETERA: { + // Excludes the chest-game rupees: they draw through the Lens of Truth. + int32_t type = actor->params & 0xFF; + if (type != ITEM_ETC_LETTER && type != ITEM_ETC_ARROW_FIRE) { + return false; + } + rc = OTRGlobals::Instance->gRandomizer->GetCheckFromActor(actor->id, gPlayState->sceneNum, actor->params); + drawnEntry = reinterpret_cast(actor)->sohItemEntry; + break; + } + case ACTOR_EN_EX_ITEM: { + // Mirrors the type mapping in RandomizerOnActorInitHandler. + EnExItem* exItem = reinterpret_cast(actor); + switch (exItem->type) { + case EXITEM_BOMB_BAG_COUNTER: + case EXITEM_BOMB_BAG_BOWLING: + rc = RC_MARKET_BOMBCHU_BOWLING_FIRST_PRIZE; + break; + case EXITEM_HEART_PIECE_COUNTER: + case EXITEM_HEART_PIECE_BOWLING: + rc = RC_MARKET_BOMBCHU_BOWLING_SECOND_PRIZE; + break; + case EXITEM_BULLET_BAG: + rc = RC_LW_TARGET_IN_WOODS; + break; + default: + return false; + } + drawnEntry = exItem->sohItemEntry; + break; + } + default: + return false; + } + return rc != RC_UNKNOWN_CHECK; +} + +static bool IsSeenItemVisible(Actor* actor) { + if (!actor->isDrawn) { + return false; + } + // Inside the actual view frustum, not just the (larger) uncull volume. + float w = actor->projectedW; + if (w <= 0.0f || std::fabs(actor->projectedPos.x) > w || std::fabs(actor->projectedPos.y) > w || + w > kSeenItemMaxDepth) { + return false; + } + // Unoccluded from the camera eye to the item's centre. + Vec3f eye = gPlayState->view.eye; + Vec3f target = actor->world.pos; + target.y += kSeenItemRayYOffset; + Vec3f hitPos; + CollisionPoly* poly; + s32 bgId; + return !BgCheck_AnyLineTest3(&gPlayState->colCtx, &eye, &target, &hitPos, &poly, true, true, true, true, &bgId); +} + +void CheckTrackerSeenItemsFrame() { + if (gPlayState == nullptr || !GameInteractor::IsSaveLoaded() || !IS_RANDO) { + return; + } + // Mystery-shuffled items draw as "?", not the stamped entry, so a + // fidelity match here would falsely mark what was never really shown. + if (mystery) { + return; + } + // Don't let a glimpse in one scene carry over into the next. + static int16_t lastSceneNum = -1; + if (gPlayState->sceneNum != lastSceneNum) { + lastSceneNum = gPlayState->sceneNum; + seenItemFrames.clear(); + } + bool anySeen = false; + for (int i = 0; i < ACTORCAT_MAX; i++) { + for (Actor* actor = gPlayState->actorCtx.actorLists[i].head; actor != nullptr; actor = actor->next) { + RandomizerCheck rc; + GetItemEntry drawnEntry; + if (!SeenItemIdentity(actor, rc, drawnEntry)) { + continue; + } + auto loc = OTRGlobals::Instance->gRandoContext->GetItemLocation(rc); + if (loc->GetCheckStatus() != RCSHOW_UNCHECKED) { + seenItemFrames.erase(rc); + continue; + } + if (!IsSeenItemVisible(actor)) { + seenItemFrames.erase(rc); + continue; + } + if (++seenItemFrames[rc] >= kSeenItemFramesRequired) { + seenItemFrames.erase(rc); + // Unobtainable items draw as a blue rupee; only mark when the + // drawn model matches the truth (disguises match both sides). + GetItemEntry truthful = OTRGlobals::Instance->gRandoContext->GetFinalGIEntry( + rc, false, (GetItemID)Rando::StaticData::GetLocation(rc)->GetVanillaItem()); + if (drawnEntry.modIndex == truthful.modIndex && drawnEntry.getItemId == truthful.getItemId) { + loc->SetCheckStatus(RCSHOW_SEEN_OR_HINTED); + anySeen = true; + } + } + } + } + if (anySeen) { + SaveManager::Instance->SaveSection(gSaveContext.fileNum, sectionId, true); + } +} + void CheckTrackerLoadGame(int32_t fileNum) { if (IS_BOSS_RUSH) { return; @@ -1036,6 +1188,7 @@ void Teardown() { areasSpoiled = 0; filterAreasHidden = { 0 }; filterChecksHidden = { 0 }; + seenItemFrames.clear(); lastLocationChecked = RC_UNKNOWN_CHECK; } @@ -2515,6 +2668,7 @@ void CheckTrackerWindow::InitElement() { GameInteractor::Instance->RegisterGameHook(CheckTrackerFlagSet); GameInteractor::Instance->RegisterGameHook(CheckTrackerDialogMessage); GameInteractor::Instance->RegisterGameHook(CheckTrackerHintRevealed); + GameInteractor::Instance->RegisterGameHook(CheckTrackerSeenItemsFrame); } void CheckTrackerWindow::UpdateElement() { From 45b86e835b664ecf1267861f068d032105713452 Mon Sep 17 00:00:00 2001 From: meldridge Date: Tue, 21 Jul 2026 20:47:31 +0930 Subject: [PATCH 2/3] Mark chests as category-seen in the Check Tracker When Chest Size And Texture Match Contents is on, a chest reveals its item category through its size and texture, so seeing one already tells the player the category. Mark that check with a new RCSHOW_CATEGORY status showing the category name -- not the item, which the chest never revealed. It is the lowest knowledge tier: a later item hint promotes it to seen, and opening the chest to collected. The chest scan rides the world-item per-frame handler and its visibility gauntlet. The mystery gate moves to the item path only: a mystery-shuffled item draws as "?" so seeing it reveals nothing, but a chest still shows its category under mystery, so that path marks regardless. The marking gate mirrors EnBox_UpdateTexture exactly, so the tracker only marks when the chest is actually drawn with a category-differentiated appearance. The category is recomputed at display time so it tracks the chest's dynamic downgrades (bombchus/bottle/skeleton key). The seven category labels are provided by a new ItemCategoryName() in item_category_adj (singular, as each names one check's contents). The previously-stubbed Color_Hinted_* CVars and "Hinted (WIP)" colour picker are wired up for the new status. Co-Authored-By: Claude Opus 4.8 --- .../randomizer/item_category_adj.cpp | 29 ++++ .../randomizer/item_category_adj.h | 6 + .../randomizerEnums/RandomizerMiscEnums.h | 1 + .../randomizer/randomizer_check_tracker.cpp | 140 +++++++++++++----- 4 files changed, 138 insertions(+), 38 deletions(-) diff --git a/soh/soh/Enhancements/randomizer/item_category_adj.cpp b/soh/soh/Enhancements/randomizer/item_category_adj.cpp index a6b3a53abb9..63c71bc30a4 100644 --- a/soh/soh/Enhancements/randomizer/item_category_adj.cpp +++ b/soh/soh/Enhancements/randomizer/item_category_adj.cpp @@ -31,3 +31,32 @@ GetItemCategory Randomizer_AdjustItemCategory(GetItemEntry item) { return category; } + +// Singular, since this names a single check's contents (e.g. what a chest's +// size and texture convey), not a group of them. +const CustomMessage& ItemCategoryName(GetItemCategory category) { + static const CustomMessage major = CustomMessage("Major Item", "Wichtiger Gegenstand", "Objet majeur"); + static const CustomMessage bossKeys = CustomMessage("Boss Key", "Master-Schlüssel", "Clé d'Or"); + static const CustomMessage smallKeys = CustomMessage("Small Key", "Kleiner Schlüssel", "Petite Clé"); + static const CustomMessage tokens = CustomMessage("Skulltula Token", "Skulltula-Symbol", "Symbole de Skulltula"); + static const CustomMessage hearts = CustomMessage("Heart", "Herz", "Cœur"); + static const CustomMessage lesser = CustomMessage("Lesser Item", "Kleinerer Gegenstand", "Objet mineur"); + static const CustomMessage junk = CustomMessage("Junk Item", "Nutzloser Gegenstand", "Objet inutile"); + switch (category) { + case ITEM_CATEGORY_MAJOR: + return major; + case ITEM_CATEGORY_BOSS_KEY: + return bossKeys; + case ITEM_CATEGORY_SMALL_KEY: + return smallKeys; + case ITEM_CATEGORY_SKULLTULA_TOKEN: + return tokens; + case ITEM_CATEGORY_HEALTH: + return hearts; + case ITEM_CATEGORY_LESSER: + return lesser; + case ITEM_CATEGORY_JUNK: + default: + return junk; + } +} diff --git a/soh/soh/Enhancements/randomizer/item_category_adj.h b/soh/soh/Enhancements/randomizer/item_category_adj.h index 53a57e7d4f0..17cbffeff59 100644 --- a/soh/soh/Enhancements/randomizer/item_category_adj.h +++ b/soh/soh/Enhancements/randomizer/item_category_adj.h @@ -13,6 +13,12 @@ GetItemCategory Randomizer_AdjustItemCategory(GetItemEntry item); #ifdef __cplusplus } + +#include "soh/Enhancements/custom-message/CustomMessageManager.h" + +// Localized name for an item category, e.g. the label a chest's size and +// texture convey. Shared by the hint and check trackers. +const CustomMessage& ItemCategoryName(GetItemCategory category); #endif #endif diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h index 83316836af9..768ca4338c0 100644 --- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h +++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h @@ -185,6 +185,7 @@ RANDO_ENUM_END(RandomizerCheckArea) // Check tracker check visibility categories RANDO_ENUM_BEGIN(RandomizerCheckStatus) RANDO_ENUM_ITEM(RCSHOW_UNCHECKED) +RANDO_ENUM_ITEM(RCSHOW_CATEGORY) RANDO_ENUM_ITEM(RCSHOW_SEEN_OR_HINTED) RANDO_ENUM_ITEM(RCSHOW_IDENTIFIED) RANDO_ENUM_ITEM(RCSHOW_SCUMMED) diff --git a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp index 8f951586d36..2ab84416a5d 100644 --- a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp +++ b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp @@ -16,10 +16,12 @@ #include "soh/Enhancements/randomizer/randomizer.h" #include "soh/ObjectExtension/ObjectExtension.h" #include "overlays/actors/ovl_En_GirlA/z_en_girla.h" +#include "overlays/actors/ovl_En_Box/z_en_box.h" #include "overlays/actors/ovl_En_Ex_Item/z_en_ex_item.h" #include "overlays/actors/ovl_En_Si/z_en_si.h" #include "overlays/actors/ovl_Item_B_Heart/z_item_b_heart.h" #include "overlays/actors/ovl_Item_Etcetera/z_item_etcetera.h" +#include "soh/Enhancements/randomizer/item_category_adj.h" #include #include @@ -203,6 +205,7 @@ void UpdateOrdering(RandomizerCheckArea); int sectionId; bool hideUnchecked = false; +bool hideCategory = false; bool hideScummed = false; bool hideSeen = false; bool hideSkipped = false; @@ -252,7 +255,7 @@ const Color_RGBA8 Color_Unchecked_Extra_Default = { 255, 255, 255, 255 }; const Color_RGBA8 Color_Skipped_Main_Default = { 160, 160, 160, 255 }; // Grey const Color_RGBA8 Color_Skipped_Extra_Default = { 160, 160, 160, 255 }; // Grey const Color_RGBA8 Color_Seen_Extra_Default = { 255, 255, 255, 255 }; // TODO -const Color_RGBA8 Color_Hinted_Extra_Default = { 255, 255, 255, 255 }; // TODO +const Color_RGBA8 Color_Hinted_Extra_Default = { 193, 84, 216, 255 }; // Purple const Color_RGBA8 Color_Collected_Extra_Default = { 242, 101, 34, 255 }; // Orange const Color_RGBA8 Color_Scummed_Extra_Default = { 0, 174, 239, 255 }; // Blue const Color_RGBA8 Color_Saved_Extra_Default = { 0, 185, 0, 255 }; // Green @@ -269,8 +272,8 @@ Color_RGBA8 Color_Skipped_Main = { 160, 160, 160, 255 }; // Grey Color_RGBA8 Color_Skipped_Extra = { 160, 160, 160, 255 }; // Grey Color_RGBA8 Color_Seen_Main = { 255, 255, 255, 255 }; // TODO Color_RGBA8 Color_Seen_Extra = { 160, 160, 160, 255 }; // TODO -Color_RGBA8 Color_Hinted_Main = { 255, 255, 255, 255 }; // TODO -Color_RGBA8 Color_Hinted_Extra = { 255, 255, 255, 255 }; // TODO +Color_RGBA8 Color_Hinted_Main = { 255, 255, 255, 255 }; // White +Color_RGBA8 Color_Hinted_Extra = { 193, 84, 216, 255 }; // Purple Color_RGBA8 Color_Collected_Main = { 255, 255, 255, 255 }; // White Color_RGBA8 Color_Collected_Extra = { 242, 101, 34, 255 }; // Orange Color_RGBA8 Color_Scummed_Main = { 255, 255, 255, 255 }; // White @@ -321,11 +324,12 @@ bool IsCheckHidden(RandomizerCheck rc) { bool skipped = itemLocation->GetIsSkipped(); bool obtained = itemLocation->HasObtained(); bool seen = status == RCSHOW_SEEN_OR_HINTED || status == RCSHOW_IDENTIFIED; + bool category = status == RCSHOW_CATEGORY; bool scummed = status == RCSHOW_SCUMMED; bool unchecked = status == RCSHOW_UNCHECKED; - return !showHidden && - ((skipped && hideSkipped) || (seen && hideSeen) || (scummed && hideScummed) || (unchecked && hideUnchecked)); + return !showHidden && ((skipped && hideSkipped) || (seen && hideSeen) || (category && hideCategory) || + (scummed && hideScummed) || (unchecked && hideUnchecked)); } void RecalculateAreaTotals(RandomizerCheckArea rcArea) { @@ -575,7 +579,9 @@ static bool ApplyItemHintToChecks(RandomizerHint hintKey) { // The hint could mean several items, no spoilers! continue; } - if (loc->GetCheckStatus() == RCSHOW_UNCHECKED) { + // A hint names the item, so it outranks a chest's category-only sighting. + RandomizerCheckStatus status = loc->GetCheckStatus(); + if (status == RCSHOW_UNCHECKED || status == RCSHOW_CATEGORY) { loc->SetCheckStatus(RCSHOW_SEEN_OR_HINTED); changed = true; } @@ -597,9 +603,12 @@ void CheckTrackerHintRevealed(RandomizerHint hintKey) { // item counts as seen after several consecutive frames drawn inside the // real frustum, close enough to read, and unoccluded from the camera. -static constexpr float kSeenItemMaxDepth = 700.0f; // max view depth -static constexpr int kSeenItemFramesRequired = 8; // consecutive frames -static constexpr float kSeenItemRayYOffset = 15.0f; // aim above ground +// Seen-detection tuning, shared by the world-item and chest paths. +static constexpr float kSeenMaxDepth = 1200.0f; // max view depth to count +static constexpr int kSeenFramesRequired = 8; // consecutive frames looked at +// Occlusion-ray targets — these correct for actor height, not eagerness. +static constexpr float kSeenItemRayYOffset = 15.0f; // above a grounded item +static constexpr float kSeenChestRayYOffset = 20.0f; // a chest's mid-height // Keyed by check, not actor, so respawns/room reloads can't dangle it. static std::unordered_map seenItemFrames; @@ -668,35 +677,50 @@ static bool SeenItemIdentity(Actor* actor, RandomizerCheck& rc, GetItemEntry& dr return rc != RC_UNKNOWN_CHECK; } -static bool IsSeenItemVisible(Actor* actor) { +// ignoreBgId lets a chest disregard its own collision so the ray to its +// centre, which necessarily passes through its front face, doesn't read as +// occluded. -1 ignores nothing (dyna ids are 0..49, scene is 50). +static bool IsSeenVisible(Actor* actor, float yOffset, s32 ignoreBgId = -1) { if (!actor->isDrawn) { return false; } // Inside the actual view frustum, not just the (larger) uncull volume. float w = actor->projectedW; if (w <= 0.0f || std::fabs(actor->projectedPos.x) > w || std::fabs(actor->projectedPos.y) > w || - w > kSeenItemMaxDepth) { + w > kSeenMaxDepth) { return false; } - // Unoccluded from the camera eye to the item's centre. + // Unoccluded from the camera eye to the actor's centre. Vec3f eye = gPlayState->view.eye; Vec3f target = actor->world.pos; - target.y += kSeenItemRayYOffset; + target.y += yOffset; Vec3f hitPos; CollisionPoly* poly; s32 bgId; - return !BgCheck_AnyLineTest3(&gPlayState->colCtx, &eye, &target, &hitPos, &poly, true, true, true, true, &bgId); + bool hit = BgCheck_AnyLineTest3(&gPlayState->colCtx, &eye, &target, &hitPos, &poly, true, true, true, true, &bgId); + // A hit on the actor's own collision is the frontmost surface, so it is + // visible; a hit on anything else is a real occluder. + return !hit || bgId == ignoreBgId; +} + +// A shuffled chest currently showing its item category (CSMC), whose category +// the player can therefore read off the chest. Mirrors EnBox_UpdateTexture's +// gate so we only mark when the chest actually looks the part. +static bool SeenChestCategory(Actor* actor, RandomizerCheck& rc) { + if (actor->id != ACTOR_EN_BOX || !CVarGetInteger(CVAR_ENHANCEMENT("ChestSizeAndTextureMatchContents"), 0) || + (CVarGetInteger(CVAR_ENHANCEMENT("ChestSizeDependsStoneOfAgony"), 0) && + !CHECK_QUEST_ITEM(QUEST_STONE_OF_AGONY)) || + (gPlayState->sceneNum == SCENE_TREASURE_BOX_SHOP && actor->room != 6)) { + return false; + } + rc = OTRGlobals::Instance->gRandomizer->GetCheckFromActor(actor->id, gPlayState->sceneNum, actor->params); + return rc != RC_UNKNOWN_CHECK && OTRGlobals::Instance->gRandoContext->IsLocationShuffled(rc); } void CheckTrackerSeenItemsFrame() { if (gPlayState == nullptr || !GameInteractor::IsSaveLoaded() || !IS_RANDO) { return; } - // Mystery-shuffled items draw as "?", not the stamped entry, so a - // fidelity match here would falsely mark what was never really shown. - if (mystery) { - return; - } // Don't let a glimpse in one scene carry over into the next. static int16_t lastSceneNum = -1; if (gPlayState->sceneNum != lastSceneNum) { @@ -708,7 +732,12 @@ void CheckTrackerSeenItemsFrame() { for (Actor* actor = gPlayState->actorCtx.actorLists[i].head; actor != nullptr; actor = actor->next) { RandomizerCheck rc; GetItemEntry drawnEntry; - if (!SeenItemIdentity(actor, rc, drawnEntry)) { + // Mystery-shuffled items draw as "?", not their stamped entry, so + // seeing one reveals nothing and it must not mark. A chest still + // shows its category under mystery, so that path runs regardless. + bool isItem = !mystery && SeenItemIdentity(actor, rc, drawnEntry); + bool isChest = !isItem && SeenChestCategory(actor, rc); + if (!isItem && !isChest) { continue; } auto loc = OTRGlobals::Instance->gRandoContext->GetItemLocation(rc); @@ -716,20 +745,29 @@ void CheckTrackerSeenItemsFrame() { seenItemFrames.erase(rc); continue; } - if (!IsSeenItemVisible(actor)) { + // A chest ignores its own dyna collision when testing occlusion. + s32 ignoreBgId = isChest ? reinterpret_cast(actor)->dyna.bgId : -1; + if (!IsSeenVisible(actor, isChest ? kSeenChestRayYOffset : kSeenItemRayYOffset, ignoreBgId)) { seenItemFrames.erase(rc); continue; } - if (++seenItemFrames[rc] >= kSeenItemFramesRequired) { - seenItemFrames.erase(rc); - // Unobtainable items draw as a blue rupee; only mark when the - // drawn model matches the truth (disguises match both sides). - GetItemEntry truthful = OTRGlobals::Instance->gRandoContext->GetFinalGIEntry( - rc, false, (GetItemID)Rando::StaticData::GetLocation(rc)->GetVanillaItem()); - if (drawnEntry.modIndex == truthful.modIndex && drawnEntry.getItemId == truthful.getItemId) { - loc->SetCheckStatus(RCSHOW_SEEN_OR_HINTED); - anySeen = true; - } + if (++seenItemFrames[rc] < kSeenFramesRequired) { + continue; + } + seenItemFrames.erase(rc); + if (isChest) { + // The chest shows only a category; the label is derived on draw. + loc->SetCheckStatus(RCSHOW_CATEGORY); + anySeen = true; + continue; + } + // Unobtainable items draw as a blue rupee; only mark when the drawn + // model matches the truth (disguises match both sides). + GetItemEntry truthful = OTRGlobals::Instance->gRandoContext->GetFinalGIEntry( + rc, false, (GetItemID)Rando::StaticData::GetLocation(rc)->GetVanillaItem()); + if (drawnEntry.modIndex == truthful.modIndex && drawnEntry.getItemId == truthful.getItemId) { + loc->SetCheckStatus(RCSHOW_SEEN_OR_HINTED); + anySeen = true; } } } @@ -1229,6 +1267,7 @@ void CheckTrackerWindow::DrawElement() { Color_Saved_Main = CVarGetColor(CVAR_TRACKER_CHECK("Saved.MainColor.Value"), Color_Main_Default); Color_Saved_Extra = CVarGetColor(CVAR_TRACKER_CHECK("Saved.ExtraColor.Value"), Color_Saved_Extra_Default); hideUnchecked = CVarGetInteger(CVAR_TRACKER_CHECK("Unchecked.Hide"), 0); + hideCategory = CVarGetInteger(CVAR_TRACKER_CHECK("Hinted.Hide"), 0); hideScummed = CVarGetInteger(CVAR_TRACKER_CHECK("Scummed.Hide"), 0); hideSeen = CVarGetInteger(CVAR_TRACKER_CHECK("Seen.Hide"), 0); hideSkipped = CVarGetInteger(CVAR_TRACKER_CHECK("Skipped.Hide"), 0); @@ -1552,6 +1591,13 @@ bool ShouldShowCheck(RandomizerCheck check) { search += Rando::StaticData::RetrieveItem(OTRGlobals::Instance->gRandoContext->overrides[check].LooksLike()) .GetName() .GetForLanguage(gSaveContext.language); + } else if (itemLoc->GetCheckStatus() == RCSHOW_CATEGORY) { + // Category-seen: searchable by category, never the item name. + GetItemEntry entry = OTRGlobals::Instance->gRandoContext->GetFinalGIEntry( + check, false, (GetItemID)Rando::StaticData::GetLocation(check)->GetVanillaItem()); + search += + " " + + ItemCategoryName(Randomizer_AdjustItemCategory(entry)).GetForLanguage(gSaveContext.language, MF_CLEAN); } return (IsVisibleInCheckTracker(check) && (checkSearch.Filters.Size == 0 || checkSearch.PassFilter(search.c_str()))); @@ -2169,6 +2215,12 @@ void DrawLocation(RandomizerCheck rc) { ? Color_Seen_Extra : Color_Seen_Main; extraColor = Color_Seen_Extra; + } else if (status == RCSHOW_CATEGORY) { + if (!showHidden && hideCategory) { + return; + } + mainColor = Color_Hinted_Main; + extraColor = Color_Hinted_Extra; } else if (status == RCSHOW_SCUMMED) { if (!showHidden && hideScummed) { return; @@ -2200,11 +2252,11 @@ void DrawLocation(RandomizerCheck rc) { txt = "* " + txt; } - // Draw button - for Skipped/Seen/Scummed/Unchecked only + // Draw button - for Category/Skipped/Seen/Scummed/Unchecked only ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, { 4.0f, 3.0f }); float sz = ImGui::GetFrameHeight(); - if (status == RCSHOW_UNCHECKED || status == RCSHOW_SEEN_OR_HINTED || status == RCSHOW_IDENTIFIED || - status == RCSHOW_SCUMMED || skipped) { + if (status == RCSHOW_UNCHECKED || status == RCSHOW_CATEGORY || status == RCSHOW_SEEN_OR_HINTED || + status == RCSHOW_IDENTIFIED || status == RCSHOW_SCUMMED || skipped) { if (UIWidgets::StateButton(std::to_string(rc).c_str(), skipped ? ICON_FA_PLUS : ICON_FA_TIMES, ImVec2(sz, sz), UIWidgets::ButtonOptions().Color(THEME_COLOR))) { if (skipped) { @@ -2257,6 +2309,17 @@ void DrawLocation(RandomizerCheck rc) { if (status != RCSHOW_UNCHECKED) { switch (status) { + case RCSHOW_CATEGORY: { + // A seen chest reveals only its category (via CSMC), so show the + // category the chest draws, not the item. Recomputed here so it + // tracks the chest's dynamic downgrades. Not mystery-gated: the + // chest shows the category under mystery too. + GetItemEntry entry = OTRGlobals::Instance->gRandoContext->GetFinalGIEntry( + rc, false, (GetItemID)Rando::StaticData::GetLocation(rc)->GetVanillaItem()); + txt = ItemCategoryName(Randomizer_AdjustItemCategory(entry)) + .GetForLanguage(gSaveContext.language, MF_CLEAN); + break; + } case RCSHOW_SAVED: case RCSHOW_COLLECTED: case RCSHOW_SCUMMED: @@ -2636,10 +2699,11 @@ void CheckTrackerSettingsWindow::DrawElement() { Color_Scummed_Main, Color_Scummed_Extra, Color_Main_Default, Color_Scummed_Extra_Default, CVAR_TRACKER_CHECK("Scummed.Hide"), "Checks you collect, but then reload before saving so you no longer have them.", THEME_COLOR); - // CheckTracker::ImGuiDrawTwoColorPickerSection("Hinted (WIP)", CVAR_TRACKER_CHECK("Hinted.MainColor"), - // CVAR_TRACKER_CHECK("Hinted.ExtraColor"), Color_Hinted_Main, Color_Hinted_Extra, - // Color_Main_Default, Color_Hinted_Extra_Default, CVAR_TRACKER_CHECK("Hinted.Hide"), "", - // THEME_COLOR); + CheckTracker::ImGuiDrawTwoColorPickerSection( + "Category", CVAR_TRACKER_CHECK("Hinted.MainColor"), CVAR_TRACKER_CHECK("Hinted.ExtraColor"), + Color_Hinted_Main, Color_Hinted_Extra, Color_Main_Default, Color_Hinted_Extra_Default, + CVAR_TRACKER_CHECK("Hinted.Hide"), + "Chests whose item category you have seen via Chest Size And Texture Match Contents.", THEME_COLOR); CheckTracker::ImGuiDrawTwoColorPickerSection( "Collected", CVAR_TRACKER_CHECK("Collected.MainColor"), CVAR_TRACKER_CHECK("Collected.ExtraColor"), Color_Collected_Main, Color_Collected_Extra, Color_Main_Default, Color_Collected_Extra_Default, From 9ce1b2731bbe84d3c289ecf6b043d611689d4876 Mon Sep 17 00:00:00 2001 From: meldridge Date: Sat, 25 Jul 2026 15:02:10 +0930 Subject: [PATCH 3/3] Restrict seen-item marking to an allowlist of check types Most shuffle features route their items through EnItem00, so any new one would inherit seen-marking without review. Gate the world-item path on an explicit set of check types instead: standard checks, Skulltula tokens and boss rewards. Freestanding rupees and hearts are excluded - they sit inside bushes and grass, whose actor collision the occlusion ray cannot see, so they marked while hidden from the player. Also drop the awarded Bombchu Bowling prizes and the Lost Woods target prize, keeping only the two prizes displayed on the bowling counter. The awarded variants fly straight to Link and are collected immediately, so marking them as seen conveys nothing. Co-Authored-By: Claude Opus 5 --- .../randomizer/randomizer_check_tracker.cpp | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp index 2ab84416a5d..9d546d783d6 100644 --- a/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp +++ b/soh/soh/Enhancements/randomizer/randomizer_check_tracker.cpp @@ -613,6 +613,22 @@ static constexpr float kSeenChestRayYOffset = 20.0f; // a chest's mid-height // Keyed by check, not actor, so respawns/room reloads can't dangle it. static std::unordered_map seenItemFrames; +// The check types a world sighting may mark, as an allowlist: most shuffle +// features route their items through EnItem00, so a new one has to opt in here +// rather than start marking on its own. Freestanding rupees and hearts are left +// out deliberately - they sit inside bushes and grass, whose actor collision the +// occlusion ray can't see, so they would mark while hidden. +static bool SeenItemTypeAllowed(RandomizerCheck rc) { + switch (Rando::StaticData::GetLocation(rc)->GetRCType()) { + case RCTYPE_STANDARD: + case RCTYPE_SKULL_TOKEN: + case RCTYPE_BOSS_HEART_OR_OTHER_REWARD: + return true; + default: + return false; + } +} + // Resolves an actor's check + the item entry its draw function renders. // False when the actor carries no check identity. static bool SeenItemIdentity(Actor* actor, RandomizerCheck& rc, GetItemEntry& drawnEntry) { @@ -651,20 +667,17 @@ static bool SeenItemIdentity(Actor* actor, RandomizerCheck& rc, GetItemEntry& dr break; } case ACTOR_EN_EX_ITEM: { - // Mirrors the type mapping in RandomizerOnActorInitHandler. + // Only the prizes on display at the Bombchu Bowling counter: the awarded + // variants and the Lost Woods target's fly straight to Link, so seeing + // one tells the player nothing they aren't about to be told anyway. EnExItem* exItem = reinterpret_cast(actor); switch (exItem->type) { case EXITEM_BOMB_BAG_COUNTER: - case EXITEM_BOMB_BAG_BOWLING: rc = RC_MARKET_BOMBCHU_BOWLING_FIRST_PRIZE; break; case EXITEM_HEART_PIECE_COUNTER: - case EXITEM_HEART_PIECE_BOWLING: rc = RC_MARKET_BOMBCHU_BOWLING_SECOND_PRIZE; break; - case EXITEM_BULLET_BAG: - rc = RC_LW_TARGET_IN_WOODS; - break; default: return false; } @@ -674,7 +687,7 @@ static bool SeenItemIdentity(Actor* actor, RandomizerCheck& rc, GetItemEntry& dr default: return false; } - return rc != RC_UNKNOWN_CHECK; + return rc != RC_UNKNOWN_CHECK && SeenItemTypeAllowed(rc); } // ignoreBgId lets a chest disregard its own collision so the ray to its